This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new f58be6f2 [core] Write BLOB collections and read packed video files
(#1044)
f58be6f2 is described below
commit f58be6f2187eb20335e8d3ed51a48319ac3455cd
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 6 11:44:16 2026 +0800
[core] Write BLOB collections and read packed video files (#1044)
---
crates/paimon/src/arrow/format/blob.rs | 432 +++------------
crates/paimon/src/arrow/format/blob/writer.rs | 613 +++++++++++++++++++++
crates/paimon/src/arrow/format/delta_varint.rs | 97 ++++
crates/paimon/src/arrow/format/mod.rs | 12 +-
crates/paimon/src/arrow/format/video.rs | 514 +++++++++++++++++
crates/paimon/src/spec/core_options.rs | 7 +
crates/paimon/src/spec/mod.rs | 3 +
crates/paimon/src/spec/schema.rs | 31 +-
crates/paimon/src/spec/video_frame_descriptor.rs | 215 ++++++++
crates/paimon/src/table/data_evolution_reader.rs | 15 +-
.../table/data_evolution_reader/blob_fallback.rs | 62 ++-
crates/paimon/src/table/data_evolution_writer.rs | 4 +-
.../src/table/dedicated_format_file_writer.rs | 7 +-
crates/paimon/src/table/managed_blob_writer.rs | 2 +-
crates/paimon/src/table/source.rs | 2 +-
crates/paimon/src/table/table_commit.rs | 2 +-
crates/paimon/src/table/table_scan.rs | 2 +-
crates/paimon/src/table/table_write.rs | 7 +-
crates/paimon/src/table/write_batch_normalize.rs | 286 +++++++++-
.../testdata/video/video-frame-descriptor-v1.hex | 18 +
crates/paimon/testdata/video/video-v1.hex | 18 +
crates/paimon/tests/blob_collection_write_test.rs | 270 +++++++++
22 files changed, 2214 insertions(+), 405 deletions(-)
diff --git a/crates/paimon/src/arrow/format/blob.rs
b/crates/paimon/src/arrow/format/blob.rs
index c1da6905..36d538bb 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -15,17 +15,22 @@
// specific language governing permissions and limitations
// under the License.
+use super::delta_varint::{
+ decode_delta_varints, encode_delta_varints as encode_delta_varints_write,
+};
+#[cfg(test)]
+use super::delta_varint::{decode_varint, encode_varint};
use super::metadata_cache::FileMetadataCache;
-use super::{FilePredicates, FormatFileReader, FormatFileWriter,
FormatWriteResult};
+use super::{FilePredicates, FormatFileReader};
use crate::arrow::build_target_arrow_schema;
-use crate::io::{BlobIndexCacheContext, FileRead, FileWrite};
+use crate::io::{BlobIndexCacheContext, FileRead};
use crate::spec::{BlobDescriptor, DataField, DataType};
use crate::table::{ArrowRecordBatchStream, RowRange};
use crate::Error;
use arrow_array::builder::{LargeBinaryBuilder, ListBuilder};
use arrow_array::{
- Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
Int16Array,
- Int32Array, Int64Array, Int8Array, LargeBinaryArray, MapArray,
RecordBatch, RecordBatchOptions,
+ ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
Int16Array, Int32Array,
+ Int64Array, Int8Array, LargeBinaryArray, MapArray, RecordBatch,
RecordBatchOptions,
StringArray, StructArray, Time32MillisecondArray,
};
use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer};
@@ -37,6 +42,9 @@ use futures::{StreamExt, TryStreamExt};
use std::ops::Range;
use std::sync::Arc;
+mod writer;
+pub(crate) use writer::BlobFormatWriter;
+
pub(crate) struct BlobFormatReader {
descriptor_mode: bool,
file_path: String,
@@ -1911,6 +1919,15 @@ impl BlobEntry {
next_offset = entry_end;
}
+ if next_offset != data_region_end {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Corrupt blob file: indexed records use {next_offset}
bytes, but data region contains {data_region_end} bytes."
+ ),
+ source: None,
+ });
+ }
+
Ok(entries)
}
}
@@ -2037,138 +2054,6 @@ impl RowSelectionCursor {
}
}
-fn decode_delta_varints(bytes: &[u8]) -> crate::Result<Vec<i64>> {
- let mut values = Vec::new();
- let mut cursor = 0usize;
- let mut previous = 0_i64;
-
- while cursor < bytes.len() {
- let (delta, consumed) = decode_varint(&bytes[cursor..])?;
- cursor += consumed;
-
- let value = if values.is_empty() {
- delta
- } else {
- previous
- .checked_add(delta)
- .ok_or_else(|| Error::DataInvalid {
- message: format!(
- "Blob delta-varint index overflow after previous value
{previous}"
- ),
- source: None,
- })?
- };
- values.push(value);
- previous = value;
- }
-
- Ok(values)
-}
-
-fn decode_varint(bytes: &[u8]) -> crate::Result<(i64, usize)> {
- let mut value = 0_u64;
- let mut shift = 0_u32;
-
- for (idx, byte) in bytes.iter().copied().enumerate() {
- value |= u64::from(byte & 0x7f) << shift;
- if (byte & 0x80) == 0 {
- let decoded = ((value >> 1) as i64) ^ (-((value & 1) as i64));
- return Ok((decoded, idx + 1));
- }
-
- shift += 7;
- if shift > 63 {
- return Err(Error::DataInvalid {
- message: "Blob delta-varint index overflow".to_string(),
- source: None,
- });
- }
- }
-
- Err(Error::DataInvalid {
- message: "Unexpected end of blob delta-varint index".to_string(),
- source: None,
- })
-}
-
-// --- Blob Format Writer ---
-
-pub(crate) struct BlobFormatWriter {
- writer: Box<dyn FileWrite>,
- file_io: Option<crate::io::FileIO>,
- bytes_written: u64,
- lengths: Vec<i64>,
-}
-
-impl BlobFormatWriter {
- pub(crate) async fn new(
- output: &crate::io::OutputFile,
- file_io: Option<crate::io::FileIO>,
- ) -> crate::Result<Self> {
- let writer = output.writer().await?;
- Ok(Self {
- writer,
- file_io,
- bytes_written: 0,
- lengths: Vec::new(),
- })
- }
-
- /// Append one managed BLOB and return the payload range in this pack.
- /// The range excludes the four-byte entry magic and twelve-byte trailer,
- /// matching Java `BlobFormatWriter`'s descriptor callback.
- pub(crate) async fn write_managed_value(&mut self, value: &[u8]) ->
crate::Result<(i64, i64)> {
- let start = self.bytes_written;
- let schema =
Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new(
- "blob",
- ArrowDataType::LargeBinary,
- false,
- )]));
- let batch = RecordBatch::try_new(
- schema,
- vec![Arc::new(LargeBinaryArray::from(vec![Some(value)]))],
- )
- .map_err(|error| Error::DataInvalid {
- message: format!("Failed to build managed BLOB input: {error}"),
- source: Some(Box::new(error)),
- })?;
- self.write(&batch).await?;
- let entry_len =
- self.bytes_written
- .checked_sub(start)
- .ok_or_else(|| Error::DataInvalid {
- message: "Managed BLOB writer position moved
backwards".to_string(),
- source: None,
- })?;
- let payload_len =
- entry_len
- .checked_sub(BLOB_ENTRY_OVERHEAD)
- .ok_or_else(|| Error::DataInvalid {
- message: "Managed BLOB entry is shorter than its
framing".to_string(),
- source: None,
- })?;
- let offset =
- start
- .checked_add(BLOB_INLINE_HEADER_SIZE)
- .ok_or_else(|| Error::DataInvalid {
- message: "Managed BLOB payload offset overflows
u64".to_string(),
- source: None,
- })?;
- Ok((
- i64::try_from(offset).map_err(|error| Error::DataInvalid {
- message: "Managed BLOB payload offset exceeds i64".to_string(),
- source: Some(Box::new(error)),
- })?,
- i64::try_from(payload_len).map_err(|error| Error::DataInvalid {
- message: "Managed BLOB payload length exceeds i64".to_string(),
- source: Some(Box::new(error)),
- })?,
- ))
- }
-}
-
-const BLOB_WRITE_BUFFER_SIZE: u64 = 8 * 1024 * 1024; // 8 MB
-
fn checked_blob_entry_length(payload_len: u64) -> crate::Result<i64> {
let entry_length = payload_len
.checked_add(BLOB_ENTRY_OVERHEAD)
@@ -2186,212 +2071,6 @@ fn checked_blob_entry_length(payload_len: u64) ->
crate::Result<i64> {
})
}
-#[async_trait]
-impl FormatFileWriter for BlobFormatWriter {
- async fn write(&mut self, batch: &RecordBatch) -> crate::Result<()> {
- if batch.num_rows() == 0 {
- return Ok(());
- }
-
- let col = batch
- .column(0)
- .as_any()
- .downcast_ref::<arrow_array::LargeBinaryArray>()
- .ok_or_else(|| Error::DataInvalid {
- message: "BlobFormatWriter expects a single LargeBinary
column".to_string(),
- source: None,
- })?;
-
- for row_idx in 0..col.len() {
- if col.is_null(row_idx) {
- self.lengths.push(-1);
- continue;
- }
-
- let value = col.value(row_idx);
-
- if BlobDescriptor::is_blob_descriptor(value) {
- let desc = BlobDescriptor::deserialize(value)?;
- let range = desc.range_spec()?;
-
- let file_io = self.file_io.as_ref().ok_or_else(||
Error::DataInvalid {
- message:
- "BlobFormatWriter received a BlobDescriptor but has no
FileIO to resolve it"
- .to_string(),
- source: None,
- })?;
- let input = crate::io::uri_reader::UriInput::new(file_io,
desc.uri())?;
- let offset = range.offset();
- let payload_len = match range.length() {
- Some(length) => length,
- None => input
- .size()
- .await
- .map_err(|e| Error::UnexpectedError {
- message: format!(
- "Failed to read metadata for BlobDescriptor
'{}': {e}",
-
crate::io::uri_reader::sanitize_blob_uri(desc.uri())
- ),
- source: Some(Box::new(e)),
- })?
- .saturating_sub(offset),
- };
- let end = offset
- .checked_add(payload_len)
- .ok_or_else(|| Error::DataInvalid {
- message: format!(
- "BlobDescriptor range overflows u64:
offset={offset}, length={payload_len}"
- ),
- source: None,
- })?;
- let entry_length = checked_blob_entry_length(payload_len)?;
- let entry_length_u64 = entry_length as u64;
- let bytes_written = self
- .bytes_written
- .checked_add(entry_length_u64)
- .ok_or_else(|| Error::DataInvalid {
- message: format!(
- "Blob file size overflows u64: current_size={},
entry_length={entry_length_u64}",
- self.bytes_written
- ),
- source: None,
- })?;
- let reader = if payload_len == 0 {
- None
- } else {
- Some(input.reader_for_range(offset..end).await?)
- };
-
- let mut hasher = crc32fast::Hasher::new();
-
- hasher.update(&BLOB_MAGIC_NUMBER_BYTES);
- self.writer
- .write(Bytes::copy_from_slice(&BLOB_MAGIC_NUMBER_BYTES))
- .await?;
-
- // Stream payload in chunks to avoid loading entire blob into
memory
- if let Some(reader) = reader.as_ref() {
- let mut pos = offset;
- while pos < end {
- let chunk_end =
pos.saturating_add(BLOB_WRITE_BUFFER_SIZE).min(end);
- let chunk =
reader.read(pos..chunk_end).await.map_err(|e| {
- Error::UnexpectedError {
- message: format!(
- "Failed to read BlobDescriptor '{}' range
{pos}..{chunk_end}: {e}",
-
crate::io::uri_reader::sanitize_blob_uri(desc.uri())
- ),
- source: Some(Box::new(e)),
- }
- })?;
- let actual_len = chunk.len() as u64;
- let expected_len = chunk_end - pos;
- if actual_len != expected_len {
- return Err(Error::DataInvalid {
- message: format!(
- "Failed to read BlobDescriptor '{}': short
read for range {pos}..{chunk_end}, expected={expected_len} bytes,
actual={actual_len} bytes",
-
crate::io::uri_reader::sanitize_blob_uri(desc.uri())
- ),
- source: None,
- });
- }
- hasher.update(&chunk);
- self.writer.write(chunk).await?;
- pos = chunk_end;
- }
- }
-
- let entry_length_bytes = entry_length.to_le_bytes();
- hasher.update(&entry_length_bytes);
- self.writer
- .write(Bytes::copy_from_slice(&entry_length_bytes))
- .await?;
-
- self.writer
-
.write(Bytes::copy_from_slice(&hasher.finalize().to_le_bytes()))
- .await?;
-
- self.lengths.push(entry_length);
- self.bytes_written = bytes_written;
- } else {
- let entry_length = (value.len() + BLOB_ENTRY_OVERHEAD as
usize) as i64;
- self.lengths.push(entry_length);
-
- let mut buf = Vec::with_capacity(entry_length as usize);
- let mut hasher = crc32fast::Hasher::new();
-
- hasher.update(&BLOB_MAGIC_NUMBER_BYTES);
- buf.extend_from_slice(&BLOB_MAGIC_NUMBER_BYTES);
-
- hasher.update(value);
- buf.extend_from_slice(value);
-
- let entry_length_bytes = entry_length.to_le_bytes();
- hasher.update(&entry_length_bytes);
- buf.extend_from_slice(&entry_length_bytes);
-
- buf.extend_from_slice(&hasher.finalize().to_le_bytes());
-
- self.writer.write(Bytes::from(buf)).await?;
- self.bytes_written += entry_length as u64;
- }
- }
-
- Ok(())
- }
-
- fn num_bytes(&self) -> usize {
- self.bytes_written as usize
- }
-
- fn in_progress_size(&self) -> usize {
- 0
- }
-
- async fn flush(&mut self) -> crate::Result<()> {
- Ok(())
- }
-
- async fn close(mut self: Box<Self>) -> crate::Result<FormatWriteResult> {
- let index_bytes = encode_delta_varints_write(&self.lengths);
- let index_length = index_bytes.len() as i32;
-
- self.writer.write(Bytes::from(index_bytes)).await?;
- self.writer
- .write(Bytes::copy_from_slice(&index_length.to_le_bytes()))
- .await?;
- self.writer
- .write(Bytes::from_static(&[BLOB_FORMAT_VERSION]))
- .await?;
-
- let total = self.bytes_written + index_length as u64 +
BLOB_FOOTER_SIZE;
- self.writer.close().await?;
- Ok(FormatWriteResult::new(total))
- }
-}
-
-fn encode_delta_varints_write(values: &[i64]) -> Vec<u8> {
- if values.is_empty() {
- return Vec::new();
- }
- let mut encoded = Vec::new();
- let mut previous = 0_i64;
- for (idx, &value) in values.iter().enumerate() {
- let delta = if idx == 0 { value } else { value - previous };
- previous = value;
- encode_varint(delta, &mut encoded);
- }
- encoded
-}
-
-fn encode_varint(value: i64, out: &mut Vec<u8>) {
- let mut remaining = ((value << 1) ^ (value >> 63)) as u64;
- while (remaining & !0x7f) != 0 {
- out.push(((remaining & 0x7f) as u8) | 0x80);
- remaining >>= 7;
- }
- out.push(remaining as u8);
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -2591,11 +2270,11 @@ mod tests {
#[tokio::test]
async fn test_index_tail_prefetch_boundaries() {
for rows in [0, 1, 4090, 4091, 4092, 9000] {
- let mut data = vec![0; 8192];
let index = encode_delta_varints_write(&vec![-1; rows]);
assert_eq!(index.len(), rows);
- data.extend_from_slice(&index);
- data.extend_from_slice(&(index.len() as i32).to_le_bytes());
+ // NULL entries have no physical records or padding before the
index.
+ let mut data = index;
+ data.extend_from_slice(&(rows as i32).to_le_bytes());
data.push(BLOB_FORMAT_VERSION);
let size = data.len() as u64;
let reader = TrackingFileRead::new(Bytes::from(data));
@@ -2606,9 +2285,9 @@ mod tests {
.iter()
.all(|entry| matches!(entry, BlobEntry::Null)));
let mut expected = Vec::with_capacity(2);
- expected.push(size - 4096..size);
+ expected.push(size.saturating_sub(4096)..size);
if rows + 5 > 4096 {
- expected.push(8192..size - BLOB_FOOTER_SIZE);
+ expected.push(0..size - BLOB_FOOTER_SIZE);
}
assert_eq!(reader.ranges(), expected);
}
@@ -2728,16 +2407,19 @@ mod tests {
#[tokio::test]
async fn test_index_tail_prefetch_sixteen_cold_files() {
+ use blob_test_utils::BlobFixtureValue::{Null, Value};
+
let mut requests = 0;
let mut bytes = 0;
let mut legacy_bytes = 0;
let mut legacy_requests = 0;
+ let payload = vec![0; 8192 - BLOB_ENTRY_OVERHEAD as usize];
for rows in (1526..).take(16) {
- let index = encode_delta_varints_write(&vec![-1; rows]);
- let mut data = vec![0; 8192];
- data.extend_from_slice(&index);
- data.extend_from_slice(&(index.len() as i32).to_le_bytes());
- data.push(1);
+ // Retain a data region large enough to fill the prefetched tail,
+ // but account for it with a complete, indexed physical record.
+ let mut values = vec![Null; rows];
+ values[0] = Value(&payload);
+ let data =
blob_test_utils::build_blob_file_bytes_with_values(&values);
let size = data.len() as u64;
let reader = TrackingFileRead::new(Bytes::from(data.clone()));
let legacy_reader = TrackingFileRead::new(Bytes::from(data));
@@ -2769,7 +2451,7 @@ mod tests {
.sum::<u64>();
}
assert_eq!(legacy_requests, 32);
- assert_eq!((requests, bytes, legacy_bytes), (16, 65536, 24616));
+ assert_eq!((requests, bytes, legacy_bytes), (16, 65536, 24680));
}
#[tokio::test]
@@ -4461,6 +4143,50 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_blob_reader_rejects_unindexed_bytes_before_footer() {
+ use blob_test_utils::BlobFixtureValue::{Null, Placeholder, Value};
+
+ for rows in [
+ vec![Value(b"hello"), Null, Placeholder, Value(b"")],
+ vec![Null, Placeholder],
+ vec![],
+ ] {
+ let valid =
blob_test_utils::build_blob_file_bytes_with_values(&rows);
+ let footer_start = valid.len() - BLOB_FOOTER_SIZE as usize;
+ let index_length =
+ i32::from_le_bytes(valid[footer_start..footer_start +
4].try_into().unwrap())
+ as usize;
+ let index_start = footer_start - index_length;
+ let mut corrupt = valid.clone();
+ // Preserve the complete records (and their CRCs), footer and
index.
+ // Only add bytes which no indexed record accounts for.
+ corrupt.splice(index_start..index_start,
b"unindexed".iter().copied());
+
+ for descriptor_mode in [false, true] {
+ let reader = IndexedBlobReader::open(
+ Box::new(BytesFileRead(Bytes::from(valid.clone()))),
+ valid.len() as u64,
+ "file:/valid.blob".into(),
+ descriptor_mode,
+ )
+ .await
+ .unwrap();
+ assert_eq!(reader.num_rows(), rows.len());
+
+ let result = IndexedBlobReader::open(
+ Box::new(BytesFileRead(Bytes::from(corrupt.clone()))),
+ corrupt.len() as u64,
+ "file:/corrupt.blob".into(),
+ descriptor_mode,
+ )
+ .await;
+ assert!(matches!(result, Err(Error::DataInvalid { message, ..
})
+ if message == format!("Corrupt blob file: indexed records
use {index_start} bytes, but data region contains {} bytes.", index_start +
b"unindexed".len())));
+ }
+ }
+ }
+
#[tokio::test]
async fn test_blob_reader_rejects_truncated_entry() {
let mut file_bytes =
blob_test_utils::build_blob_file_bytes(&basic_blob_rows());
diff --git a/crates/paimon/src/arrow/format/blob/writer.rs
b/crates/paimon/src/arrow/format/blob/writer.rs
new file mode 100644
index 00000000..7aedc97b
--- /dev/null
+++ b/crates/paimon/src/arrow/format/blob/writer.rs
@@ -0,0 +1,613 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Java BlobElementSerializer record layouts, sharing framing and streamed
payload copying.
+
+use super::{
+ checked_blob_entry_length, encode_delta_varints_write,
validate_read_fields, BlobFieldKind,
+ BLOB_ARRAY_MAGIC_NUMBER, BLOB_ARRAY_VERSION, BLOB_FORMAT_VERSION,
BLOB_MAGIC_NUMBER_BYTES,
+ BLOB_MAP_MAGIC_NUMBER, BLOB_MAP_VERSION,
+};
+use crate::arrow::format::{FormatFileWriter, FormatWriteResult};
+use crate::io::{FileIO, FileWrite, OutputFile};
+use crate::spec::{BlobDescriptor, DataField, DataType};
+use crate::{Error, Result};
+use arrow_array::{
+ Array, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
Int16Array, Int32Array,
+ Int64Array, Int8Array, LargeBinaryArray, ListArray, MapArray, RecordBatch,
StringArray,
+ Time32MillisecondArray,
+};
+use async_trait::async_trait;
+use bytes::Bytes;
+use crc32fast::Hasher;
+use std::collections::HashSet;
+
+const COPY_BUFFER_SIZE: u64 = 8 * 1024 * 1024;
+
+pub(crate) struct BlobFormatWriter {
+ writer: Box<dyn FileWrite>,
+ file_io: Option<FileIO>,
+ kind: BlobFieldKind,
+ bytes_written: u64,
+ lengths: Vec<i64>,
+}
+
+fn invalid(message: impl Into<String>) -> Error {
+ Error::DataInvalid {
+ message: message.into(),
+ source: None,
+ }
+}
+
+impl BlobFormatWriter {
+ pub(crate) async fn new(
+ output: &OutputFile,
+ file_io: Option<FileIO>,
+ field: Option<&DataField>,
+ ) -> Result<Self> {
+ let kind = match field {
+ Some(field) =>
validate_read_fields(std::slice::from_ref(field))?.unwrap(),
+ None => BlobFieldKind::Scalar,
+ };
+ if let BlobFieldKind::Map(key_type) = &kind {
+ validate_key_type(key_type)?;
+ }
+ Ok(Self {
+ writer: output.writer().await?,
+ file_io,
+ kind,
+ bytes_written: 0,
+ lengths: Vec::new(),
+ })
+ }
+
+ /// The callback range excludes the common entry magic and trailer.
+ pub(crate) async fn write_managed_value(&mut self, value: &[u8]) ->
Result<(i64, i64)> {
+ if !matches!(self.kind, BlobFieldKind::Scalar) {
+ return Err(invalid("Managed BLOB packs require scalar elements"));
+ }
+ let start = self.bytes_written;
+ let length = self.write_scalar(value).await?;
+ Ok((
+ i64::try_from(start + 4).map_err(|_| invalid("Managed BLOB offset
exceeds i64"))?,
+ length,
+ ))
+ }
+
+ async fn write_bytes(&mut self, bytes: Bytes, hasher: &mut Hasher) ->
Result<()> {
+ let end = self
+ .bytes_written
+ .checked_add(bytes.len() as u64)
+ .ok_or_else(|| invalid("BLOB file size overflows u64"))?;
+ hasher.update(&bytes);
+ self.writer.write(bytes).await?;
+ self.bytes_written = end;
+ Ok(())
+ }
+
+ async fn start_record(&mut self) -> Result<(u64, Hasher)> {
+ let start = self.bytes_written;
+ let mut hasher = Hasher::new();
+ self.write_bytes(
+ Bytes::copy_from_slice(&BLOB_MAGIC_NUMBER_BYTES),
+ &mut hasher,
+ )
+ .await?;
+ Ok((start, hasher))
+ }
+
+ async fn finish_record(&mut self, start: u64, mut hasher: Hasher) ->
Result<()> {
+ let length = checked_blob_entry_length(self.bytes_written - start -
4)?;
+ self.write_bytes(Bytes::copy_from_slice(&length.to_le_bytes()), &mut
hasher)
+ .await?;
+ let crc = hasher.finalize().to_le_bytes();
+ // CRC itself is outside its checksum domain.
+ self.writer.write(Bytes::copy_from_slice(&crc)).await?;
+ self.bytes_written = self
+ .bytes_written
+ .checked_add(4)
+ .ok_or_else(|| invalid("BLOB file size overflows u64"))?;
+ self.lengths.push(length);
+ Ok(())
+ }
+
+ async fn write_scalar(&mut self, value: &[u8]) -> Result<i64> {
+ let (start, mut hasher) = self.start_record().await?;
+ let length = self.write_payload(value, &mut hasher).await?;
+ self.finish_record(start, hasher).await?;
+ Ok(length)
+ }
+
+ /// Resolve descriptors without materializing a complete referenced object.
+ async fn write_payload(&mut self, value: &[u8], hasher: &mut Hasher) ->
Result<i64> {
+ if !BlobDescriptor::is_blob_descriptor(value) {
+ self.write_bytes(Bytes::copy_from_slice(value), hasher)
+ .await?;
+ return i64::try_from(value.len()).map_err(|_| invalid("BLOB
payload exceeds i64"));
+ }
+ let descriptor = BlobDescriptor::deserialize(value)?;
+ let range = descriptor.range_spec()?;
+ let file_io = self.file_io.as_ref().ok_or_else(|| {
+ invalid("BlobFormatWriter received a BlobDescriptor but has no
FileIO to resolve it")
+ })?;
+ let input = crate::io::uri_reader::UriInput::new(file_io,
descriptor.uri())?;
+ let offset = range.offset();
+ let length = match range.length() {
+ Some(length) => length,
+ None => input
+ .size()
+ .await
+ .map_err(|error| Error::UnexpectedError {
+ message: format!(
+ "Failed to read metadata for BlobDescriptor '{}':
{error}",
+
crate::io::uri_reader::sanitize_blob_uri(descriptor.uri())
+ ),
+ source: Some(Box::new(error)),
+ })?
+ .saturating_sub(offset),
+ };
+ let end = offset
+ .checked_add(length)
+ .ok_or_else(|| invalid("BlobDescriptor range overflows u64"))?;
+ let length_i64 = i64::try_from(length).map_err(|_| invalid("BLOB
payload exceeds i64"))?;
+ if length == 0 {
+ return Ok(0);
+ }
+ let reader = input.reader_for_range(offset..end).await?;
+ let mut position = offset;
+ while position < end {
+ let chunk_end = position.saturating_add(COPY_BUFFER_SIZE).min(end);
+ let chunk =
+ reader
+ .read(position..chunk_end)
+ .await
+ .map_err(|error| Error::UnexpectedError {
+ message: format!(
+ "Failed to read BlobDescriptor '{}' range
{position}..{chunk_end}: {error}",
+
crate::io::uri_reader::sanitize_blob_uri(descriptor.uri())
+ ),
+ source: Some(Box::new(error)),
+ })?;
+ if chunk.len() as u64 != chunk_end - position {
+ return Err(invalid(format!("Failed to read BlobDescriptor
'{}': short read for range {position}..{chunk_end}, expected={} bytes,
actual={} bytes",
+
crate::io::uri_reader::sanitize_blob_uri(descriptor.uri()), chunk_end -
position, chunk.len())));
+ }
+ self.write_bytes(chunk, hasher).await?;
+ position = chunk_end;
+ }
+ Ok(length_i64)
+ }
+
+ async fn write_blob_values(
+ &mut self,
+ values: &LargeBinaryArray,
+ hasher: &mut Hasher,
+ ) -> Result<Vec<i64>> {
+ let mut lengths = Vec::with_capacity(values.len());
+ for value in values {
+ lengths.push(match value {
+ None => -1,
+ Some(value) => self.write_payload(value, hasher).await?,
+ });
+ }
+ Ok(lengths)
+ }
+
+ async fn write_index(&mut self, values: &[i64], hasher: &mut Hasher) ->
Result<i32> {
+ let bytes = encode_delta_varints_write(values);
+ let length =
+ i32::try_from(bytes.len()).map_err(|_| invalid("BLOB element index
exceeds i32"))?;
+ self.write_bytes(Bytes::from(bytes), hasher).await?;
+ Ok(length)
+ }
+
+ async fn write_collection(
+ &mut self,
+ values: &LargeBinaryArray,
+ keys: Option<Vec<Vec<u8>>>,
+ ) -> Result<()> {
+ let (start, mut hasher) = self.start_record().await?;
+ let (magic, version) = if keys.is_some() {
+ (BLOB_MAP_MAGIC_NUMBER, BLOB_MAP_VERSION)
+ } else {
+ (BLOB_ARRAY_MAGIC_NUMBER, BLOB_ARRAY_VERSION)
+ };
+ let count =
+ i32::try_from(values.len()).map_err(|_| invalid("BLOB element
count exceeds i32"))?;
+ let mut header = Vec::with_capacity(9);
+ header.extend_from_slice(&magic.to_le_bytes());
+ header.push(version);
+ header.extend_from_slice(&count.to_le_bytes());
+ self.write_bytes(header.into(), &mut hasher).await?;
+ let key_lengths = if let Some(keys) = keys {
+ let mut lengths = Vec::with_capacity(keys.len());
+ for key in keys {
+ lengths.push(
+ i64::try_from(key.len()).map_err(|_| invalid("BLOB MAP key
exceeds i64"))?,
+ );
+ self.write_bytes(key.into(), &mut hasher).await?;
+ }
+ Some(lengths)
+ } else {
+ None
+ };
+ let value_lengths = self.write_blob_values(values, &mut hasher).await?;
+ let key_index_length = if let Some(lengths) = key_lengths {
+ Some(self.write_index(&lengths, &mut hasher).await?)
+ } else {
+ None
+ };
+ let value_index_length = self.write_index(&value_lengths, &mut
hasher).await?;
+ if let Some(length) = key_index_length {
+ self.write_bytes(Bytes::copy_from_slice(&length.to_le_bytes()),
&mut hasher)
+ .await?;
+ }
+ self.write_bytes(
+ Bytes::copy_from_slice(&value_index_length.to_le_bytes()),
+ &mut hasher,
+ )
+ .await?;
+ self.finish_record(start, hasher).await
+ }
+}
+
+#[async_trait]
+impl FormatFileWriter for BlobFormatWriter {
+ async fn write(&mut self, batch: &RecordBatch) -> Result<()> {
+ if batch.num_columns() != 1 {
+ return Err(invalid("BlobFormatWriter requires exactly one
column"));
+ }
+ let column = batch.column(0);
+ let kind = self.kind.clone();
+ for row in 0..batch.num_rows() {
+ if column.is_null(row) {
+ self.lengths.push(-1);
+ continue;
+ }
+ match &kind {
+ BlobFieldKind::Scalar => {
+ self.write_scalar(blob_values(column.as_ref())?.value(row))
+ .await?;
+ }
+ BlobFieldKind::Array => {
+ let list = column
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .ok_or_else(|| invalid("ARRAY<BLOB> writer requires
ListArray"))?;
+
self.write_collection(blob_values(list.value(row).as_ref())?, None)
+ .await?;
+ }
+ BlobFieldKind::Map(key_type) => {
+ let map = column
+ .as_any()
+ .downcast_ref::<MapArray>()
+ .ok_or_else(|| invalid("MAP<X, BLOB> writer requires
MapArray"))?;
+ let entries = map.value(row);
+ // Java validates all keys before starting the record.
+ let keys = serialize_map_keys(entries.column(0).as_ref(),
key_type)?;
+
self.write_collection(blob_values(entries.column(1).as_ref())?, Some(keys))
+ .await?;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ fn num_bytes(&self) -> usize {
+ self.bytes_written as usize
+ }
+ fn in_progress_size(&self) -> usize {
+ 0
+ }
+ async fn flush(&mut self) -> Result<()> {
+ Ok(())
+ }
+ async fn close(mut self: Box<Self>) -> Result<FormatWriteResult> {
+ let index = encode_delta_varints_write(&self.lengths);
+ let length =
+ i32::try_from(index.len()).map_err(|_| invalid("BLOB file index
exceeds i32"))?;
+ self.writer.write(index.into()).await?;
+ self.writer
+ .write(Bytes::copy_from_slice(&length.to_le_bytes()))
+ .await?;
+ self.writer
+ .write(Bytes::from_static(&[BLOB_FORMAT_VERSION]))
+ .await?;
+ self.writer.close().await?;
+ Ok(FormatWriteResult::new(
+ self.bytes_written + length as u64 + 5,
+ ))
+ }
+}
+
+fn blob_values(array: &dyn Array) -> Result<&LargeBinaryArray> {
+ array
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .ok_or_else(|| invalid("BLOB values require LargeBinaryArray"))
+}
+
+fn validate_key_type(key_type: &DataType) -> Result<()> {
+ match key_type {
+ DataType::Boolean(_)
+ | DataType::TinyInt(_)
+ | DataType::SmallInt(_)
+ | DataType::Int(_)
+ | DataType::BigInt(_)
+ | DataType::Date(_)
+ | DataType::Time(_)
+ | DataType::Decimal(_)
+ | DataType::Char(_)
+ | DataType::VarChar(_)
+ | DataType::Binary(_)
+ | DataType::VarBinary(_) => Ok(()),
+ other => Err(Error::Unsupported {
+ message: format!("Unsupported key type for MAP<X, BLOB>:
{other:?}"),
+ }),
+ }
+}
+
+fn serialize_map_keys(array: &dyn Array, key_type: &DataType) ->
Result<Vec<Vec<u8>>> {
+ let mut seen = HashSet::new();
+ let mut keys = Vec::with_capacity(array.len());
+ for row in 0..array.len() {
+ if array.is_null(row) {
+ return Err(invalid("Arrow MAP keys must not be null"));
+ }
+ macro_rules! fixed {
+ ($array:ty) => {
+ array
+ .as_any()
+ .downcast_ref::<$array>()
+ .ok_or_else(|| {
+ invalid("MAP<X, BLOB> key array does not match its
declared type")
+ })?
+ .value(row)
+ .to_le_bytes()
+ .to_vec()
+ };
+ }
+ let key = match key_type {
+ DataType::TinyInt(_) => fixed!(Int8Array),
+ DataType::SmallInt(_) => fixed!(Int16Array),
+ DataType::Int(_) => fixed!(Int32Array),
+ DataType::BigInt(_) => fixed!(Int64Array),
+ DataType::Date(_) => fixed!(Date32Array),
+ DataType::Time(_) => fixed!(Time32MillisecondArray),
+ DataType::Boolean(_) => vec![u8::from(
+ array
+ .as_any()
+ .downcast_ref::<BooleanArray>()
+ .ok_or_else(|| invalid("MAP BOOLEAN key requires
BooleanArray"))?
+ .value(row),
+ )],
+ DataType::Char(_) | DataType::VarChar(_) => array
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .ok_or_else(|| invalid("MAP string key requires StringArray"))?
+ .value(row)
+ .as_bytes()
+ .to_vec(),
+ DataType::Binary(_) | DataType::VarBinary(_) => array
+ .as_any()
+ .downcast_ref::<BinaryArray>()
+ .ok_or_else(|| invalid("MAP binary key requires BinaryArray"))?
+ .value(row)
+ .to_vec(),
+ DataType::Decimal(decimal) => {
+ let value = array
+ .as_any()
+ .downcast_ref::<Decimal128Array>()
+ .ok_or_else(|| invalid("MAP DECIMAL key requires
Decimal128Array"))?
+ .value(row);
+ if decimal.precision() <= 18 {
+ i64::try_from(value)
+ .map_err(|_| invalid("Compact MAP DECIMAL key exceeds
i64"))?
+ .to_le_bytes()
+ .to_vec()
+ } else {
+ // BigInteger.toByteArray(): minimal signed
two's-complement, big endian.
+ let bytes = value.to_be_bytes();
+ let mut start = 0;
+ while start < 15
+ && ((bytes[start] == 0 && bytes[start + 1] < 0x80)
+ || (bytes[start] == 0xff && bytes[start + 1] >=
0x80))
+ {
+ start += 1;
+ }
+ bytes[start..].to_vec()
+ }
+ }
+ other => {
+ return Err(Error::Unsupported {
+ message: format!("Unsupported key type for MAP<X, BLOB>:
{other:?}"),
+ })
+ }
+ };
+ if !seen.insert(key.clone()) {
+ return Err(invalid("MAP<X, BLOB> keys must be unique"));
+ }
+ keys.push(key);
+ }
+ Ok(keys)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{
+ BigIntType, BinaryType, BooleanType, CharType, DateType, DecimalType,
FloatType, IntType,
+ SmallIntType, TimeType, TinyIntType, VarBinaryType, VarCharType,
+ };
+ use arrow_array::ArrayRef;
+ use std::sync::Arc;
+
+ #[tokio::test]
+ async fn production_array_records_match_java_golden_bytes_including_crc() {
+ use crate::io::{FileIOBuilder, FileRead};
+ use crate::spec::{ArrayType, BlobType};
+ use arrow_array::builder::{LargeBinaryBuilder, ListBuilder};
+ let io = FileIOBuilder::new("memory").build().unwrap();
+ let output = io.new_output("memory:/array.blob").unwrap();
+ let field = DataField::new(
+ 0,
+ "payloads".into(),
+ DataType::Array(ArrayType::new(DataType::Blob(BlobType::new()))),
+ );
+ let mut list = ListBuilder::new(LargeBinaryBuilder::new());
+ list.values().append_value(b"hello");
+ list.values().append_null();
+ list.values().append_value(b"world");
+ list.append(true);
+ list.append(false);
+ list.append(false);
+ list.append(true);
+ let batch = RecordBatch::try_from_iter([("payloads",
Arc::new(list.finish()) as ArrayRef)])
+ .unwrap();
+ let mut writer = BlobFormatWriter::new(&output, Some(io.clone()),
Some(&field))
+ .await
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ let result = Box::new(writer).close().await.unwrap();
+ let written = io
+ .new_input("memory:/array.blob")
+ .unwrap()
+ .reader()
+ .await
+ .unwrap()
+ .read(0..result.file_size)
+ .await
+ .unwrap();
+ let golden =
include_bytes!("../../../../testdata/blob/blob-array.blob");
+ fn data_end(bytes: &[u8]) -> usize {
+ bytes.len()
+ - 5
+ - i32::from_le_bytes(bytes[bytes.len() - 5..bytes.len() -
1].try_into().unwrap())
+ as usize
+ }
+ // The fixture's placeholder and our second NULL have no physical
+ // record; only their footer indexes differ. All physical records,
+ // including framing and CRC, must be byte-identical to Java.
+ assert_eq!(&written[..data_end(&written)],
&golden[..data_end(golden)]);
+ }
+
+ #[test]
+ fn map_keys_match_java_serializers_and_reader_for_every_supported_type() {
+ let types: Vec<(DataType, ArrayRef)> = vec![
+ (
+ DataType::Boolean(BooleanType::new()),
+ Arc::new(BooleanArray::from(vec![true, false])),
+ ),
+ (
+ DataType::TinyInt(TinyIntType::new()),
+ Arc::new(Int8Array::from(vec![i8::MIN, i8::MAX])),
+ ),
+ (
+ DataType::SmallInt(SmallIntType::new()),
+ Arc::new(Int16Array::from(vec![i16::MIN, i16::MAX])),
+ ),
+ (
+ DataType::Int(IntType::new()),
+ Arc::new(Int32Array::from(vec![i32::MIN, i32::MAX])),
+ ),
+ (
+ DataType::BigInt(BigIntType::new()),
+ Arc::new(Int64Array::from(vec![i64::MIN, i64::MAX])),
+ ),
+ (
+ DataType::Date(DateType::new()),
+ Arc::new(Date32Array::from(vec![-1, 0])),
+ ),
+ (
+ DataType::Time(TimeType::new(3).unwrap()),
+ Arc::new(Time32MillisecondArray::from(vec![0, 123])),
+ ),
+ (
+ DataType::Char(CharType::new(10).unwrap()),
+ Arc::new(StringArray::from(vec!["", "你好"])),
+ ),
+ (
+ DataType::VarChar(VarCharType::new(10).unwrap()),
+ Arc::new(StringArray::from(vec!["", "你好"])),
+ ),
+ (
+ DataType::Binary(BinaryType::new(2).unwrap()),
+ Arc::new(BinaryArray::from_iter_values([b"ab", b"cd"])),
+ ),
+ (
+ DataType::VarBinary(VarBinaryType::new(10).unwrap()),
+ Arc::new(BinaryArray::from_iter_values([b"".as_slice(),
b"abc"])),
+ ),
+ (
+ DataType::Decimal(DecimalType::new(18, 2).unwrap()),
+ Arc::new(
+ Decimal128Array::from(vec![-999, 128])
+ .with_precision_and_scale(18, 2)
+ .unwrap(),
+ ),
+ ),
+ (
+ DataType::Decimal(DecimalType::new(38, 2).unwrap()),
+ Arc::new(
+ Decimal128Array::from(vec![
+ 0,
+ 127,
+ 128,
+ -128,
+ -129,
+ 99999999999999999999999999999999999999,
+ ])
+ .with_precision_and_scale(38, 2)
+ .unwrap(),
+ ),
+ ),
+ ];
+ for (data_type, array) in types {
+ let keys = serialize_map_keys(array.as_ref(), &data_type).unwrap();
+ let bytes: Vec<_> =
keys.iter().cloned().map(Bytes::from).collect();
+ let restored = super::super::decode_blob_map_keys(&bytes,
&data_type).unwrap();
+ assert_eq!(restored.to_data(), array.to_data(), "{data_type:?}");
+ match data_type {
+ DataType::Boolean(_) => assert_eq!(keys, vec![vec![1],
vec![0]]),
+ DataType::Int(_) => {
+ assert_eq!(keys, vec![vec![0, 0, 0, 128], vec![255, 255,
255, 127]])
+ }
+ DataType::Decimal(decimal) if decimal.precision() > 18 =>
assert_eq!(
+ &keys[..5],
+ &[vec![0], vec![127], vec![0, 128], vec![128], vec![255,
127]]
+ ),
+ _ => {}
+ }
+ }
+ }
+
+ #[test]
+ fn duplicate_map_keys_are_rejected_before_the_record() {
+ for keys in [
+ Arc::new(StringArray::from(vec!["a", "a"])) as ArrayRef,
+ Arc::new(StringArray::from(vec!["", ""])) as ArrayRef,
+ ] {
+ let error = serialize_map_keys(
+ keys.as_ref(),
+ &DataType::VarChar(VarCharType::new(10).unwrap()),
+ )
+ .unwrap_err();
+ assert!(error.to_string().contains("unique"));
+ }
+
assert!(validate_key_type(&DataType::Float(FloatType::new())).is_err());
+ }
+}
diff --git a/crates/paimon/src/arrow/format/delta_varint.rs
b/crates/paimon/src/arrow/format/delta_varint.rs
new file mode 100644
index 00000000..4b266b98
--- /dev/null
+++ b/crates/paimon/src/arrow/format/delta_varint.rs
@@ -0,0 +1,97 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Java DeltaVarintCompressor signed-long indexes.
+
+use crate::Error;
+
+pub(crate) fn decode_delta_varints(bytes: &[u8]) -> crate::Result<Vec<i64>> {
+ let mut values = Vec::new();
+ let mut cursor = 0usize;
+ let mut previous = 0_i64;
+
+ while cursor < bytes.len() {
+ let (delta, consumed) = decode_varint(&bytes[cursor..])?;
+ cursor += consumed;
+
+ let value = if values.is_empty() {
+ delta
+ } else {
+ previous
+ .checked_add(delta)
+ .ok_or_else(|| Error::DataInvalid {
+ message: format!(
+ "Blob delta-varint index overflow after previous value
{previous}"
+ ),
+ source: None,
+ })?
+ };
+ values.push(value);
+ previous = value;
+ }
+
+ Ok(values)
+}
+
+pub(super) fn decode_varint(bytes: &[u8]) -> crate::Result<(i64, usize)> {
+ let mut value = 0_u64;
+ let mut shift = 0_u32;
+
+ for (idx, byte) in bytes.iter().copied().enumerate() {
+ value |= u64::from(byte & 0x7f) << shift;
+ if (byte & 0x80) == 0 {
+ let decoded = ((value >> 1) as i64) ^ (-((value & 1) as i64));
+ return Ok((decoded, idx + 1));
+ }
+
+ shift += 7;
+ if shift > 63 {
+ return Err(Error::DataInvalid {
+ message: "Blob delta-varint index overflow".to_string(),
+ source: None,
+ });
+ }
+ }
+
+ Err(Error::DataInvalid {
+ message: "Unexpected end of blob delta-varint index".to_string(),
+ source: None,
+ })
+}
+
+pub(crate) fn encode_delta_varints(values: &[i64]) -> Vec<u8> {
+ if values.is_empty() {
+ return Vec::new();
+ }
+ let mut encoded = Vec::new();
+ let mut previous = 0_i64;
+ for (idx, &value) in values.iter().enumerate() {
+ let delta = if idx == 0 { value } else { value - previous };
+ previous = value;
+ encode_varint(delta, &mut encoded);
+ }
+ encoded
+}
+
+pub(super) fn encode_varint(value: i64, out: &mut Vec<u8>) {
+ let mut remaining = ((value << 1) ^ (value >> 63)) as u64;
+ while (remaining & !0x7f) != 0 {
+ out.push(((remaining & 0x7f) as u8) | 0x80);
+ remaining >>= 7;
+ }
+ out.push(remaining as u8);
+}
diff --git a/crates/paimon/src/arrow/format/mod.rs
b/crates/paimon/src/arrow/format/mod.rs
index a400b067..8e141314 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -18,6 +18,7 @@
mod avro;
mod avro_write;
pub(crate) mod blob;
+mod delta_varint;
mod metadata_cache;
mod mosaic;
mod mosaic_write;
@@ -27,6 +28,7 @@ mod row;
mod shredding;
pub(crate) mod text;
mod variant_projection;
+pub(crate) mod video;
#[cfg(feature = "vortex")]
mod vortex;
@@ -396,6 +398,8 @@ pub(crate) fn create_format_reader_with_budget(
blob::BlobFormatReader::new(path.to_string(), blob_as_descriptor)
.with_blob_parallelism(blob_parallelism),
)
+ } else if lower.ends_with(".video") {
+ Box::new(video::VideoFormatReader::new(path.to_string()))
} else if lower.ends_with(".orc") {
Box::new(orc::OrcFormatReader)
} else if let Some((kind, compression)) = text::TextKind::from_path(path) {
@@ -443,6 +447,7 @@ fn supported_read_formats() -> Vec<&'static str> {
vec![
".parquet",
".blob",
+ ".video",
".orc",
".csv",
".json",
@@ -573,7 +578,12 @@ impl FormatWriterFactory for PlainFormatWriterFactory {
let lower = path.to_ascii_lowercase();
if lower.ends_with(".blob") {
Ok(Box::new(
- blob::BlobFormatWriter::new(output, file_io).await?,
+ blob::BlobFormatWriter::new(
+ output,
+ file_io,
+ write_fields.and_then(|fields| fields.first()),
+ )
+ .await?,
))
} else if lower.ends_with(".orc") {
if !matches!(
diff --git a/crates/paimon/src/arrow/format/video.rs
b/crates/paimon/src/arrow/format/video.rs
new file mode 100644
index 00000000..944ff6a2
--- /dev/null
+++ b/crates/paimon/src/arrow/format/video.rs
@@ -0,0 +1,514 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Packed video v1 metadata, matching Java VideoFileMeta and
VideoFormatReader.
+//! Reading returns frame descriptors; encoded videos and keyframe indexes
stay lazy.
+
+use super::blob::{build_blob_batch, BlobReadValue};
+use super::delta_varint::decode_delta_varints;
+use super::{FilePredicates, FormatFileReader};
+use crate::arrow::build_target_arrow_schema;
+use crate::io::FileRead;
+use crate::spec::{DataField, DataType, VideoFrameDescriptor};
+use crate::table::{ArrowRecordBatchStream, RowRange};
+use crate::{Error, Result};
+use arrow_array::{RecordBatch, RecordBatchOptions};
+use async_stream::try_stream;
+use async_trait::async_trait;
+use bytes::Bytes;
+use futures::StreamExt;
+
+const FOOTER_SIZE: u64 = 25;
+const MAGIC: i32 = 0x4F454449;
+const VERSION: u8 = 1;
+const MAX_KEYFRAME_INDEX_BYTES: i64 = 16 * 1024 * 1024;
+const MAX_TOTAL_KEYFRAME_INDEX_BYTES: i64 = 64 * 1024 * 1024;
+
+fn corrupt(message: impl Into<String>) -> Error {
+ Error::DataInvalid {
+ message: format!("Corrupt video file: {}", message.into()),
+ source: None,
+ }
+}
+
+struct VideoFileMeta {
+ physical_lengths: Vec<i64>,
+ physical_offsets: Vec<i64>,
+ keyframe_lengths: Vec<i64>,
+ keyframe_offsets: Vec<i64>,
+ run_ends: Vec<usize>,
+ run_references: Vec<i64>,
+ run_first_frames: Vec<i64>,
+}
+
+impl VideoFileMeta {
+ async fn load(reader: &dyn FileRead, file_size: u64) -> Result<Self> {
+ if file_size < FOOTER_SIZE {
+ return Err(corrupt("file is smaller than its footer"));
+ }
+ let footer_start = file_size - FOOTER_SIZE;
+ let footer = reader.read(footer_start..file_size).await?;
+ if footer.len() != FOOTER_SIZE as usize {
+ return Err(corrupt("short footer read"));
+ }
+ if i32::from_le_bytes(footer[20..24].try_into().unwrap()) != MAGIC {
+ return Err(corrupt("invalid footer magic"));
+ }
+ if footer[24] != VERSION {
+ return Err(Error::Unsupported {
+ message: format!("Unsupported video format version {}",
footer[24]),
+ });
+ }
+ let lengths: Vec<_> = footer[..20]
+ .as_chunks::<4>()
+ .0
+ .iter()
+ .map(|bytes| i32::from_le_bytes(*bytes))
+ .collect();
+ if lengths.iter().any(|length| *length < 0) {
+ return Err(corrupt("negative index length"));
+ }
+ let total_length: u64 = lengths.iter().map(|length| *length as
u64).sum();
+ let index_start = footer_start
+ .checked_sub(total_length)
+ .ok_or_else(|| corrupt("indexes exceed file size"))?;
+ let index = reader.read(index_start..footer_start).await?;
+ if index.len() as u64 != total_length {
+ return Err(corrupt("short index read"));
+ }
+ let mut cursor = 0;
+ let mut indexes = Vec::with_capacity(5);
+ for length in lengths {
+ let end = cursor + length as usize;
+ indexes.push(decode_delta_varints(&index[cursor..end])?);
+ cursor = end;
+ }
+ let mut indexes = indexes.into_iter();
+ let physical_lengths = indexes.next().unwrap();
+ let keyframe_lengths = indexes.next().unwrap();
+ let run_lengths = indexes.next().unwrap();
+ let run_references = indexes.next().unwrap();
+ let run_first_frames = indexes.next().unwrap();
+ if physical_lengths.len() != keyframe_lengths.len() {
+ return Err(corrupt("physical video and keyframe index counts
differ"));
+ }
+ let mut keyframe_size = 0i64;
+ for length in &keyframe_lengths {
+ if !(0..=MAX_KEYFRAME_INDEX_BYTES).contains(length) {
+ return Err(corrupt(
+ "invalid keyframe index length or index exceeds the 16 MiB
limit",
+ ));
+ }
+ keyframe_size += length;
+ if keyframe_size > MAX_TOTAL_KEYFRAME_INDEX_BYTES {
+ return Err(corrupt("keyframe indexes exceed the 64 MiB
limit"));
+ }
+ }
+ let index_start =
+ i64::try_from(index_start).map_err(|_| corrupt("file offset
exceeds i64"))?;
+ let keyframe_start = index_start
+ .checked_sub(keyframe_size)
+ .filter(|offset| *offset >= 0)
+ .ok_or_else(|| corrupt("keyframe indexes exceed file size"))?;
+ let mut physical_offsets = Vec::with_capacity(physical_lengths.len());
+ let mut offset = 0;
+ for length in &physical_lengths {
+ if *length <= 0 || *length > keyframe_start - offset {
+ return Err(corrupt("invalid physical video length"));
+ }
+ physical_offsets.push(offset);
+ offset += length;
+ }
+ if offset != keyframe_start {
+ return Err(corrupt(
+ "physical video lengths do not match payload region",
+ ));
+ }
+ let mut keyframe_offsets = Vec::with_capacity(keyframe_lengths.len());
+ for length in &keyframe_lengths {
+ keyframe_offsets.push(offset);
+ offset += length;
+ }
+ if run_lengths.len() != run_references.len() || run_lengths.len() !=
run_first_frames.len()
+ {
+ return Err(corrupt("run indexes have different counts"));
+ }
+ let mut rows = 0i64;
+ let mut run_ends = Vec::with_capacity(run_lengths.len());
+ for ((length, reference), frame) in run_lengths
+ .iter()
+ .zip(&run_references)
+ .zip(&run_first_frames)
+ {
+ if *length <= 0 || *length > i64::from(i32::MAX) - rows {
+ return Err(corrupt("invalid run length or row count exceeds
i32"));
+ }
+ if *reference != -1 && *reference != -2 {
+ if *reference < 0 || *reference as u64 >=
physical_lengths.len() as u64 {
+ return Err(corrupt("run references an invalid physical
video"));
+ }
+ if *frame < 0 || frame.checked_add(length - 1).is_none() {
+ return Err(corrupt("invalid first frame or frame index
overflow"));
+ }
+ }
+ rows += length;
+ run_ends.push(rows as usize);
+ }
+ Ok(Self {
+ physical_lengths,
+ physical_offsets,
+ keyframe_lengths,
+ keyframe_offsets,
+ run_ends,
+ run_references,
+ run_first_frames,
+ })
+ }
+
+ fn num_rows(&self) -> usize {
+ self.run_ends.last().copied().unwrap_or(0)
+ }
+
+ fn frame(&self, position: usize, path: &str) -> Result<BlobReadValue> {
+ if position >= self.num_rows() {
+ return Err(corrupt("selected row is outside row count"));
+ }
+ let run = self.run_ends.partition_point(|end| *end <= position);
+ let reference = self.run_references[run];
+ match reference {
+ -1 => Ok(BlobReadValue::Null),
+ -2 => Ok(BlobReadValue::Placeholder),
+ _ => {
+ let ordinal = reference as usize;
+ let start = if run == 0 { 0 } else { self.run_ends[run - 1] };
+ let length = self.keyframe_lengths[ordinal];
+ let frame = VideoFrameDescriptor::new(
+ path.into(),
+ self.physical_offsets[ordinal],
+ self.physical_lengths[ordinal],
+ self.run_first_frames[run] + (position - start) as i64,
+ if length == 0 {
+ -1
+ } else {
+ self.keyframe_offsets[ordinal]
+ },
+ length,
+ )?;
+ Ok(BlobReadValue::Value(Bytes::from(frame.serialize())))
+ }
+ }
+ }
+}
+
+pub(crate) struct IndexedVideoReader {
+ metadata: VideoFileMeta,
+ path: String,
+}
+
+impl IndexedVideoReader {
+ pub(crate) async fn open(reader: &dyn FileRead, file_size: u64, path:
String) -> Result<Self> {
+ Ok(Self {
+ metadata: VideoFileMeta::load(reader, file_size).await?,
+ path,
+ })
+ }
+ pub(crate) fn num_rows(&self) -> usize {
+ self.metadata.num_rows()
+ }
+ pub(crate) fn read_positions(&self, positions: &[usize]) ->
Result<Vec<BlobReadValue>> {
+ positions
+ .iter()
+ .map(|position| self.metadata.frame(*position, &self.path))
+ .collect()
+ }
+}
+
+pub(crate) struct VideoFormatReader {
+ path: String,
+}
+
+impl VideoFormatReader {
+ pub(crate) fn new(path: String) -> Self {
+ Self { path }
+ }
+}
+
+#[async_trait]
+impl FormatFileReader for VideoFormatReader {
+ async fn read_batch_stream(
+ &self,
+ reader: Box<dyn FileRead>,
+ file_size: u64,
+ read_fields: &[DataField],
+ predicates: Option<&FilePredicates>,
+ batch_size: Option<usize>,
+ row_selection: Option<Vec<RowRange>>,
+ ) -> Result<ArrowRecordBatchStream> {
+ if let Some(predicates) = predicates {
+ crate::table::row_id_predicate::reject_row_id_filter(
+ &predicates.predicates,
+ "video files",
+ )?;
+ }
+ if read_fields.len() > 1
+ || read_fields
+ .first()
+ .is_some_and(|field| !matches!(field.data_type(),
DataType::Blob(_)))
+ {
+ return Err(corrupt("video format only supports one scalar BLOB
field"));
+ }
+ let schema = build_target_arrow_schema(read_fields)?;
+ let indexed =
+ IndexedVideoReader::open(reader.as_ref(), file_size,
self.path.clone()).await?;
+ let mut positions: Box<dyn Iterator<Item = usize> + Send> = match
row_selection {
+ None => Box::new(0..indexed.num_rows()),
+ Some(ranges) => {
+ for range in &ranges {
+ if range.from() < 0
+ || range.to() < range.from()
+ || range.to() as u64 >= indexed.num_rows() as u64
+ {
+ return Err(corrupt("selected row is outside row
count"));
+ }
+ }
+ Box::new(
+ ranges
+ .into_iter()
+ .flat_map(|range| range.from() as usize..=range.to()
as usize),
+ )
+ }
+ };
+ let batch_size = batch_size.unwrap_or(1024).max(1);
+ Ok(try_stream! {
+ loop {
+ let selected: Vec<_> =
positions.by_ref().take(batch_size).collect();
+ if selected.is_empty() { break; }
+ if schema.fields().is_empty() {
+ yield RecordBatch::try_new_with_options(schema.clone(),
vec![],
+
&RecordBatchOptions::new().with_row_count(Some(selected.len())))
+ .map_err(|error| corrupt(error.to_string()))?;
+ } else {
+ yield build_blob_batch(&schema,
indexed.read_positions(&selected)?)?;
+ }
+ }
+ }
+ .boxed())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::btree::test_util::BytesFileRead;
+ use crate::spec::{BlobType, IntType};
+ use futures::TryStreamExt;
+
+ fn java_fixture() -> Bytes {
+ let text = include_str!("../../../testdata/video/video-v1.hex");
+ hex::decode(
+ text.lines()
+ .find(|line| !line.is_empty() && !line.starts_with('#'))
+ .unwrap(),
+ )
+ .unwrap()
+ .into()
+ }
+
+ fn file(indexes: [&[i64]; 5], payload: &[u8]) -> Bytes {
+ let mut bytes = payload.to_vec();
+ let mut lengths = Vec::new();
+ for values in indexes {
+ let index =
super::super::delta_varint::encode_delta_varints(values);
+ lengths.push(index.len() as i32);
+ bytes.extend(index);
+ }
+ for length in lengths {
+ bytes.extend(length.to_le_bytes());
+ }
+ bytes.extend(MAGIC.to_le_bytes());
+ bytes.push(VERSION);
+ bytes.into()
+ }
+
+ #[tokio::test]
+ async fn
latest_java_fixture_preserves_frames_indexes_nulls_and_placeholders() {
+ let bytes = java_fixture();
+ let reader = IndexedVideoReader::open(
+ &BytesFileRead(bytes.clone()),
+ bytes.len() as u64,
+ "file:/data.video".into(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(reader.num_rows(), 7);
+ let values = reader.read_positions(&[0, 1, 2, 3, 4, 5, 6]).unwrap();
+ for (index, frame, offset, length, keyframe) in [
+ (0, 2, 0, 3, true),
+ (1, 3, 0, 3, true),
+ (4, 7, 3, 4, false),
+ (5, 8, 3, 4, false),
+ (6, 10, 0, 3, true),
+ ] {
+ let BlobReadValue::Value(bytes) = &values[index] else {
+ panic!("expected frame")
+ };
+ let descriptor = VideoFrameDescriptor::deserialize(bytes).unwrap();
+ assert_eq!(descriptor.frame_index(), frame);
+ assert_eq!(descriptor.payload_descriptor().offset(), offset);
+ assert_eq!(descriptor.payload_descriptor().length(), length);
+ assert_eq!(descriptor.keyframe_index_descriptor().is_some(),
keyframe);
+ if let Some(mapping) = descriptor.keyframe_index_descriptor() {
+ assert_eq!(mapping.offset(), 7);
+ assert_eq!(mapping.length(), 54);
+ }
+ }
+ assert!(matches!(values[2], BlobReadValue::Null));
+ assert!(matches!(values[3], BlobReadValue::Placeholder));
+ assert!(reader.read_positions(&[7]).is_err());
+ }
+
+ #[tokio::test]
+ async fn selection_batching_and_zero_column_reads_keep_logical_rows() {
+ let bytes = java_fixture();
+ let format = VideoFormatReader::new("file:/data.video".into());
+ let field = DataField::new(0, "video".into(),
DataType::Blob(BlobType::new()));
+ for fields in [vec![field], vec![]] {
+ let stream = format
+ .read_batch_stream(
+ Box::new(BytesFileRead(bytes.clone())),
+ bytes.len() as u64,
+ &fields,
+ None,
+ Some(2),
+ Some(vec![RowRange::new(1, 3), RowRange::new(6, 6)]),
+ )
+ .await
+ .unwrap();
+ let batches: Vec<_> = stream.try_collect().await.unwrap();
+ assert_eq!(
+ batches
+ .iter()
+ .map(RecordBatch::num_rows)
+ .collect::<Vec<_>>(),
+ vec![2, 2]
+ );
+ assert!(batches
+ .iter()
+ .all(|batch| batch.num_columns() == fields.len()));
+ }
+ for selection in [vec![RowRange::new(7, 7)], vec![RowRange::new(-1,
0)]] {
+ assert!(format
+ .read_batch_stream(
+ Box::new(BytesFileRead(bytes.clone())),
+ bytes.len() as u64,
+ &[],
+ None,
+ None,
+ Some(selection)
+ )
+ .await
+ .is_err());
+ }
+ let empty = format
+ .read_batch_stream(
+ Box::new(BytesFileRead(bytes.clone())),
+ bytes.len() as u64,
+ &[],
+ None,
+ None,
+ Some(vec![]),
+ )
+ .await
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert!(empty.is_empty());
+ assert!(format
+ .read_batch_stream(
+ Box::new(BytesFileRead(bytes.clone())),
+ bytes.len() as u64,
+ &[DataField::new(
+ 0,
+ "id".into(),
+ DataType::Int(IntType::new())
+ )],
+ None,
+ None,
+ None
+ )
+ .await
+ .is_err());
+ }
+
+ #[tokio::test]
+ async fn malformed_footer_and_run_indexes_fail_before_returning_rows() {
+ let good = file([&[3], &[0], &[2], &[0], &[0]], b"abc");
+ assert_eq!(
+ VideoFileMeta::load(&BytesFileRead(good.clone()), good.len() as
u64)
+ .await
+ .unwrap()
+ .num_rows(),
+ 2
+ );
+ let mut bad = Vec::new();
+ bad.push(Bytes::from_static(b"short"));
+ let mut bytes = good.to_vec();
+ *bytes.last_mut().unwrap() = 2;
+ bad.push(bytes.into());
+ let mut bytes = good.to_vec();
+ let n = bytes.len();
+ bytes[n - 5] ^= 1;
+ bad.push(bytes.into());
+ let mut bytes = good.to_vec();
+ let n = bytes.len();
+ bytes[n - 25..n - 21].copy_from_slice(&(-1i32).to_le_bytes());
+ bad.push(bytes.into());
+ for indexes in [
+ [&[0][..], &[0], &[1], &[0], &[0]],
+ [&[4][..], &[0], &[1], &[0], &[0]],
+ [&[3][..], &[], &[1], &[0], &[0]],
+ [&[3][..], &[-1], &[1], &[0], &[0]],
+ [&[3][..], &[16 * 1024 * 1024 + 1], &[1], &[0], &[0]],
+ [&[3][..], &[0], &[0], &[0], &[0]],
+ [&[3][..], &[0], &[1], &[1], &[0]],
+ [&[3][..], &[0], &[1], &[-3], &[0]],
+ [&[3][..], &[0], &[1], &[0], &[-1]],
+ [&[3][..], &[0], &[2], &[0], &[i64::MAX]],
+ [&[3][..], &[0], &[i32::MAX as i64, 1], &[0, 0], &[0, 0]],
+ [&[3][..], &[0], &[1], &[0], &[]],
+ ] {
+ bad.push(file(indexes, b"abc"));
+ }
+ for bytes in bad {
+ assert!(
+ VideoFileMeta::load(&BytesFileRead(bytes.clone()), bytes.len()
as u64)
+ .await
+ .is_err()
+ );
+ }
+ let nulls = file([&[], &[], &[3], &[-1], &[0]], b"");
+ let meta = VideoFileMeta::load(&BytesFileRead(nulls.clone()),
nulls.len() as u64)
+ .await
+ .unwrap();
+ assert_eq!(meta.num_rows(), 3);
+ assert!(matches!(
+ meta.frame(2, "unused").unwrap(),
+ BlobReadValue::Null
+ ));
+ }
+}
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index ae04d111..0d33a760 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -182,6 +182,7 @@ pub(crate) const BLOB_FIELD_OPTION: &str = "blob-field";
pub(crate) const BLOB_DESCRIPTOR_FIELD_OPTION: &str = "blob-descriptor-field";
pub(crate) const BLOB_DESCRIPTOR_FIELD_FALLBACK: &str =
"blob.stored-descriptor-fields";
pub(crate) const BLOB_VIEW_FIELD_OPTION: &str = "blob-view-field";
+pub(crate) const VIDEO_FRAME_FIELD_OPTION: &str = "video-frame-field";
pub const BLOB_VIEW_RESOLVE_ENABLED_OPTION: &str = "blob-view.resolve.enabled";
const PK_VECTOR_INDEX_COLUMNS_OPTION: &str = "pk-vector.index.columns";
const PK_FULL_TEXT_INDEX_COLUMNS_OPTION: &str = "pk-full-text.index.columns";
@@ -1783,9 +1784,15 @@ impl<'a> CoreOptions<'a> {
let mut fields = self.parse_csv_set(BLOB_FIELD_OPTION);
fields.extend(self.blob_descriptor_fields());
fields.extend(self.blob_view_fields());
+ fields.extend(self.video_frame_fields());
fields
}
+ /// Scalar BLOB columns stored as logical frame runs in packed `.video`
files.
+ pub fn video_frame_fields(&self) -> HashSet<String> {
+ self.parse_csv_set(VIDEO_FRAME_FIELD_OPTION)
+ }
+
/// Comma-separated BLOB field names stored as serialized BlobDescriptor
/// bytes inline in normal data files (no .blob files for these fields).
pub fn blob_descriptor_fields(&self) -> HashSet<String> {
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 9c64f6fd..472fa7c2 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -25,6 +25,9 @@ pub use binary_row::*;
mod blob_descriptor;
pub use blob_descriptor::BlobDescriptor;
+mod video_frame_descriptor;
+pub use video_frame_descriptor::VideoFrameDescriptor;
+
mod blob_view_struct;
pub use blob_view_struct::BlobViewStruct;
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index c11ebdfa..b6f73e09 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -1648,6 +1648,36 @@ impl Schema {
primary_keys: &[String],
options: &HashMap<String, String>,
) -> crate::Result<()> {
+ let core_options = CoreOptions::new(options);
+ let video_fields = core_options.video_frame_fields();
+ if !video_fields.is_empty() && !primary_keys.is_empty() {
+ return Err(crate::Error::ConfigInvalid {
+ message: "'video-frame-field' only supports append-only
tables".into(),
+ });
+ }
+ for name in video_fields {
+ if !fields
+ .iter()
+ .any(|field| field.name() == name &&
field.data_type().is_blob_type())
+ {
+ return Err(crate::Error::ConfigInvalid { message:
format!("Field '{name}' in 'video-frame-field' must be a scalar BLOB field in
table schema") });
+ }
+ for (option, names) in [
+ (
+ "blob-descriptor-field",
+ core_options.blob_descriptor_fields(),
+ ),
+ ("blob-view-field", core_options.blob_view_fields()),
+ ] {
+ if names.contains(&name) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Field '{name}' in 'video-frame-field' can not
also be in '{option}'"
+ ),
+ });
+ }
+ }
+ }
let blob_field_names = Self::top_level_blob_field_names(fields);
for field in fields {
if !Self::is_top_level_blob_file_type(field.data_type())
@@ -1666,7 +1696,6 @@ impl Schema {
return Ok(());
}
- let core_options = CoreOptions::new(options);
let blob_descriptor_fields = core_options.blob_descriptor_fields();
let blob_view_fields = core_options.blob_view_fields();
let mut overlapping_fields = blob_view_fields
diff --git a/crates/paimon/src/spec/video_frame_descriptor.rs
b/crates/paimon/src/spec/video_frame_descriptor.rs
new file mode 100644
index 00000000..b592741c
--- /dev/null
+++ b/crates/paimon/src/spec/video_frame_descriptor.rs
@@ -0,0 +1,215 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Java VideoFrameDescriptor v1, including the persisted keyframe-index range.
+
+use super::BlobDescriptor;
+use crate::{Error, Result};
+
+const VERSION: u8 = 1;
+const MAGIC: i64 = 0x564944454F46524D;
+const FIXED_LENGTH: usize = 1 + 8 + 4 + 5 * 8;
+
+/// One logical frame in an encoded video, with an optional keyframe index.
+/// The payload is the complete encoded video; `frame_index` locates the frame.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct VideoFrameDescriptor {
+ payload: BlobDescriptor,
+ frame_index: i64,
+ keyframe_index_offset: i64,
+ keyframe_index_length: i64,
+}
+
+fn invalid(message: impl Into<String>) -> Error {
+ Error::DataInvalid {
+ message: format!("Invalid VideoFrameDescriptor data: {}",
message.into()),
+ source: None,
+ }
+}
+
+impl VideoFrameDescriptor {
+ /// Construct a descriptor using the same locator validation as Java.
+ pub fn new(
+ uri: String,
+ offset: i64,
+ length: i64,
+ frame_index: i64,
+ keyframe_index_offset: i64,
+ keyframe_index_length: i64,
+ ) -> Result<Self> {
+ if frame_index < 0 {
+ return Err(invalid("negative frame index"));
+ }
+ if !(keyframe_index_length == 0 && keyframe_index_offset == -1
+ || keyframe_index_length > 0 && keyframe_index_offset >= 0)
+ {
+ return Err(invalid("invalid video keyframe index range"));
+ }
+ Ok(Self {
+ payload: BlobDescriptor::new(uri, offset, length),
+ frame_index,
+ keyframe_index_offset,
+ keyframe_index_length,
+ })
+ }
+
+ /// Physical video identity, without the logical frame locator.
+ pub fn payload_descriptor(&self) -> &BlobDescriptor {
+ &self.payload
+ }
+ /// Zero-based frame ordinal within the physical video.
+ pub fn frame_index(&self) -> i64 {
+ self.frame_index
+ }
+ /// The optional index is stored in the same object as the physical video.
+ pub fn keyframe_index_descriptor(&self) -> Option<BlobDescriptor> {
+ (self.keyframe_index_length > 0).then(|| {
+ BlobDescriptor::new(
+ self.payload.uri().into(),
+ self.keyframe_index_offset,
+ self.keyframe_index_length,
+ )
+ })
+ }
+
+ /// Serialize to Java's exact little-endian wire layout.
+ pub fn serialize(&self) -> Vec<u8> {
+ let mut bytes = Vec::with_capacity(FIXED_LENGTH +
self.payload.uri().len());
+ bytes.push(VERSION);
+ bytes.extend_from_slice(&MAGIC.to_le_bytes());
+ bytes.extend_from_slice(&(self.payload.uri().len() as
i32).to_le_bytes());
+ bytes.extend_from_slice(self.payload.uri().as_bytes());
+ for value in [
+ self.payload.offset(),
+ self.payload.length(),
+ self.frame_index,
+ self.keyframe_index_offset,
+ self.keyframe_index_length,
+ ] {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+ bytes
+ }
+
+ /// Recognize the version and magic only, matching Java's detection helper.
+ pub fn is_video_frame_descriptor(bytes: &[u8]) -> bool {
+ bytes.len() >= 9 && bytes[0] == VERSION && bytes[1..9] ==
MAGIC.to_le_bytes()
+ }
+
+ /// Deserialize a complete frame reference; trailing bytes are invalid.
+ pub fn deserialize(bytes: &[u8]) -> Result<Self> {
+ if bytes.len() < FIXED_LENGTH {
+ return Err(invalid("too short"));
+ }
+ if bytes[0] != VERSION {
+ return Err(Error::Unsupported {
+ message: format!("Unsupported VideoFrameDescriptor version
{}", bytes[0]),
+ });
+ }
+ if bytes[1..9] != MAGIC.to_le_bytes() {
+ return Err(invalid("missing magic header"));
+ }
+ let uri_length = i32::from_le_bytes(bytes[9..13].try_into().unwrap());
+ let uri_length = usize::try_from(uri_length).map_err(|_|
invalid("negative URI length"))?;
+ if uri_length > bytes.len() - FIXED_LENGTH {
+ return Err(invalid("URI length exceeds data size"));
+ }
+ if uri_length != bytes.len() - FIXED_LENGTH {
+ return Err(invalid("trailing bytes"));
+ }
+ let end = 13 + uri_length;
+ // Java new String(bytes, UTF_8) replaces invalid UTF-8 sequences.
+ let uri = String::from_utf8_lossy(&bytes[13..end]).into_owned();
+ let values: Vec<_> = bytes[end..]
+ .as_chunks::<8>()
+ .0
+ .iter()
+ .map(|bytes| i64::from_le_bytes(*bytes))
+ .collect();
+ Self::new(uri, values[0], values[1], values[2], values[3], values[4])
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn descriptor_matches_current_java_fixture() {
+ let text =
include_str!("../../testdata/video/video-frame-descriptor-v1.hex");
+ let bytes = hex::decode(
+ text.lines()
+ .find(|line| !line.is_empty() && !line.starts_with('#'))
+ .unwrap(),
+ )
+ .unwrap();
+ let descriptor =
+ VideoFrameDescriptor::new("s3://bucket/视频.mp4".into(), 7, 99, 42,
106, 8).unwrap();
+ assert_eq!(descriptor.serialize(), bytes);
+ assert_eq!(
+ VideoFrameDescriptor::deserialize(&bytes).unwrap(),
+ descriptor
+ );
+ assert_eq!(
+ descriptor.keyframe_index_descriptor().unwrap(),
+ BlobDescriptor::new("s3://bucket/视频.mp4".into(), 106, 8)
+ );
+ assert!(!BlobDescriptor::is_blob_descriptor(&bytes));
+ }
+
+ #[test]
+ fn descriptors_without_indexes_and_unknown_payload_lengths_round_trip() {
+ let frame =
+ VideoFrameDescriptor::new("memory:/video".into(), 4, -1, i64::MAX,
-1, 0).unwrap();
+ assert!(frame.keyframe_index_descriptor().is_none());
+ assert_eq!(
+ frame,
+ VideoFrameDescriptor::deserialize(&frame.serialize()).unwrap()
+ );
+ }
+
+ #[test]
+ fn malformed_descriptors_are_rejected_without_overflow() {
+ let original = VideoFrameDescriptor::new("x".into(), 0, 1, 0, -1, 0)
+ .unwrap()
+ .serialize();
+ for length in 0..original.len() {
+
assert!(VideoFrameDescriptor::deserialize(&original[..length]).is_err());
+ }
+ for length in [-1i32, i32::MAX] {
+ let mut bytes = original.clone();
+ bytes[9..13].copy_from_slice(&length.to_le_bytes());
+ assert!(VideoFrameDescriptor::deserialize(&bytes).is_err());
+ }
+ let mut bytes = original.clone();
+ bytes.push(0);
+ assert!(VideoFrameDescriptor::deserialize(&bytes).is_err());
+ let mut bytes = original.clone();
+ bytes[1] ^= 1;
+ assert!(!VideoFrameDescriptor::is_video_frame_descriptor(&bytes));
+ assert!(VideoFrameDescriptor::deserialize(&bytes).is_err());
+ let mut bytes = original;
+ bytes[0] = 2;
+ assert!(matches!(
+ VideoFrameDescriptor::deserialize(&bytes),
+ Err(Error::Unsupported { .. })
+ ));
+ for (frame, offset, length) in [(-1, -1, 0), (0, 0, 0), (0, -1, 1),
(0, 0, -1)] {
+ assert!(VideoFrameDescriptor::new("x".into(), 0, 1, frame, offset,
length).is_err());
+ }
+ }
+}
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index 88b0bd46..d30d4020 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -34,7 +34,7 @@ use crate::spec::{
BlobDescriptor, BlobViewStruct, CoreOptions, DataField, DataFileMeta,
DataType, Predicate,
ROW_ID_FIELD_NAME,
};
-use crate::table::dedicated_format_file_writer::is_blob_file_name;
+use crate::table::dedicated_format_file_writer::is_blob_or_video_file_name;
use crate::table::schema_manager::SchemaManager;
use crate::table::source::any_range_overlaps_file;
use crate::table::stats_filter::group_by_overlapping_row_id;
@@ -280,7 +280,7 @@ impl DataEvolutionReader {
split
.data_files()
.iter()
- .any(|file| is_blob_file_name(&file.file_name))
+ .any(|file| is_blob_or_video_file_name(&file.file_name))
})
// A legacy raw file without first_row_id cannot be addressed by
// the preselection's global row ranges. Keep its existing read
@@ -2148,7 +2148,8 @@ impl PreparedMergeGroup {
let data_files: Vec<&DataFileMeta> = files
.iter()
.filter(|file| {
- !is_blob_file_name(&file.file_name) &&
!is_vector_store_file_name(&file.file_name)
+ !is_blob_or_video_file_name(&file.file_name)
+ && !is_vector_store_file_name(&file.file_name)
})
.collect();
if data_files.is_empty() {
@@ -2336,7 +2337,7 @@ fn build_source_plan_with_row_id_pushdown(
for (file_idx, file) in prepared_group.files.iter().enumerate() {
let info = &file_infos[file_idx];
- if is_blob_file_name(&file.file_name) {
+ if is_blob_or_video_file_name(&file.file_name) {
let field_id = resolve_blob_field_id(file, info)?;
let expected_row_count = expected_blob_row_count.ok_or_else(||
Error::DataInvalid {
message: format!(
@@ -2688,7 +2689,7 @@ impl BlobBunch {
self.logical_ranges.is_none(),
"Cannot add files to a finalized blob bunch"
);
- if !is_blob_file_name(&file.file_name) {
+ if !is_blob_or_video_file_name(&file.file_name) {
return Err(Error::DataInvalid {
message: "Only blob file can be added to a blob
bunch.".to_string(),
source: None,
@@ -3065,7 +3066,7 @@ fn normalize_merge_group(files: Vec<DataFileMeta>) ->
crate::Result<Vec<DataFile
let mut blob_files = Vec::new();
for file in files {
- if is_blob_file_name(&file.file_name) {
+ if is_blob_or_video_file_name(&file.file_name) {
blob_files.push(file);
} else if is_vector_store_file_name(&file.file_name) {
vector_files.push(file);
@@ -4008,7 +4009,7 @@ mod tests {
.files
.iter()
.map(|file| {
- if is_blob_file_name(&file.file_name) {
+ if is_blob_or_video_file_name(&file.file_name) {
resolved_info(vec![2])
} else {
resolved_info(vec![1])
diff --git a/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
b/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
index 3000aed9..a650ab20 100644
--- a/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
+++ b/crates/paimon/src/table/data_evolution_reader/blob_fallback.rs
@@ -24,6 +24,7 @@ use crate::arrow::format::blob::{
build_blob_array_batch, build_blob_batch, build_blob_map_batch,
BlobFieldKind, BlobReadValue,
IndexedBlobReader,
};
+use crate::arrow::format::video::IndexedVideoReader;
use crate::io::FileIO;
use crate::spec::{DataField, DataType};
use crate::table::{ArrowRecordBatchStream, RowRange};
@@ -42,7 +43,21 @@ struct LazyBlobFile {
path: String,
file_size: i64,
row_count: i64,
- reader: Option<IndexedBlobReader>,
+ reader: Option<IndexedDedicatedReader>,
+}
+
+enum IndexedDedicatedReader {
+ Blob(IndexedBlobReader),
+ Video(IndexedVideoReader),
+}
+
+impl IndexedDedicatedReader {
+ fn num_rows(&self) -> usize {
+ match self {
+ Self::Blob(reader) => reader.num_rows(),
+ Self::Video(reader) => reader.num_rows(),
+ }
+ }
}
impl LazyBlobFile {
@@ -64,14 +79,28 @@ impl LazyBlobFile {
})?;
let input = file_io.new_input(&self.path)?;
let reader = input.reader().await?;
- let reader = IndexedBlobReader::open_with_parallelism(
- Box::new(reader),
- file_size,
- self.path.clone(),
- blob_as_descriptor,
- blob_parallelism,
- )
- .await?;
+ let reader = if
self.file_name.to_ascii_lowercase().ends_with(".video") {
+ if !matches!(field_kind, BlobFieldKind::Scalar) {
+ return Err(Error::DataInvalid {
+ message: "Video files require scalar BLOB
fields".into(),
+ source: None,
+ });
+ }
+ IndexedDedicatedReader::Video(
+ IndexedVideoReader::open(&reader, file_size,
self.path.clone()).await?,
+ )
+ } else {
+ IndexedDedicatedReader::Blob(
+ IndexedBlobReader::open_with_parallelism(
+ Box::new(reader),
+ file_size,
+ self.path.clone(),
+ blob_as_descriptor,
+ blob_parallelism,
+ )
+ .await?,
+ )
+ };
let indexed_rows =
i64::try_from(reader.num_rows()).map_err(|e|
Error::DataInvalid {
message: format!(
@@ -97,10 +126,15 @@ impl LazyBlobFile {
.reader
.as_ref()
.expect("blob reader is initialized above");
- match field_kind {
- BlobFieldKind::Scalar => reader.read_positions(positions).await,
- BlobFieldKind::Array =>
reader.read_array_positions(positions).await,
- BlobFieldKind::Map(key_type) =>
reader.read_map_positions(positions, key_type).await,
+ match reader {
+ IndexedDedicatedReader::Video(reader) =>
reader.read_positions(positions),
+ IndexedDedicatedReader::Blob(reader) => match field_kind {
+ BlobFieldKind::Scalar =>
reader.read_positions(positions).await,
+ BlobFieldKind::Array =>
reader.read_array_positions(positions).await,
+ BlobFieldKind::Map(key_type) => {
+ reader.read_map_positions(positions, key_type).await
+ }
+ },
}
}
@@ -396,7 +430,7 @@ mod tests {
path: file_name.to_string(),
file_size: i64::try_from(file_size).unwrap(),
row_count: i64::try_from(values.len()).unwrap(),
- reader: Some(reader),
+ reader: Some(IndexedDedicatedReader::Blob(reader)),
},
ranges,
)
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 2dd6697f..0e82ade7 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -159,7 +159,9 @@ impl DataEvolutionWriter {
});
}
if let Some(field) = schema.fields().iter().find(|f| f.name() ==
top_level) {
- if field.data_type().is_blob_type() &&
!updatable_blob_fields.contains(top_level) {
+ if field.data_type().is_blob_file_field()
+ && !updatable_blob_fields.contains(top_level)
+ {
return Err(crate::Error::Unsupported {
message: format!(
"Cannot update raw-data BLOB column '{col}' in
MERGE INTO. \
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index df5cb99c..901b7b0c 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -24,8 +24,9 @@ use arrow_array::RecordBatch;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
-pub(crate) fn is_blob_file_name(file_name: &str) -> bool {
- file_name.to_ascii_lowercase().ends_with(".blob")
+pub(crate) fn is_blob_or_video_file_name(file_name: &str) -> bool {
+ let lower = file_name.to_ascii_lowercase();
+ lower.ends_with(".blob") || lower.ends_with(".video")
}
struct BlobFieldWriter {
@@ -99,7 +100,7 @@ impl AppendDedicatedFormatFileWriter {
let mut vector_field_names = Vec::new();
for (idx, field) in table_fields.iter().enumerate() {
- let is_blob = field.data_type().is_blob_type();
+ let is_blob = field.data_type().is_blob_file_field();
let is_inline = blob_inline_fields.contains(field.name());
let is_dedicated_vector =
vector_file_format.is_some() && matches!(field.data_type(),
DataType::Vector(_));
diff --git a/crates/paimon/src/table/managed_blob_writer.rs
b/crates/paimon/src/table/managed_blob_writer.rs
index 5a1e8d6a..ed951934 100644
--- a/crates/paimon/src/table/managed_blob_writer.rs
+++ b/crates/paimon/src/table/managed_blob_writer.rs
@@ -357,7 +357,7 @@ impl ManagedBlobWriter {
);
let output = self.file_io.new_output(&path)?;
let writer =
- Box::new(BlobFormatWriter::new(&output,
Some(self.file_io.clone())).await?);
+ Box::new(BlobFormatWriter::new(&output,
Some(self.file_io.clone()), None).await?);
self.uncommitted_paths.push(path.clone());
self.fields[field_index].current = Some(ManagedBlobPack { path,
writer });
}
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index ab501eeb..64be1888 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -30,7 +30,7 @@ fn is_vector_store_file_name(file_name: &str) -> bool {
}
pub(crate) fn is_data_evolution_normal_file(file: &DataFileMeta) -> bool {
-
!crate::table::dedicated_format_file_writer::is_blob_file_name(&file.file_name)
+
!crate::table::dedicated_format_file_writer::is_blob_or_video_file_name(&file.file_name)
&& !is_vector_store_file_name(&file.file_name)
}
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 17cc19ba..5ad2d51f 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -3477,7 +3477,7 @@ fn ranges_overlap(left_start: i64, left_end: i64,
right_start: i64, right_end: i
}
fn is_blob_data_file(file: &DataFileMeta) -> bool {
-
crate::table::dedicated_format_file_writer::is_blob_file_name(&file.file_name)
+
crate::table::dedicated_format_file_writer::is_blob_or_video_file_name(&file.file_name)
}
fn is_vector_store_file(file: &DataFileMeta) -> bool {
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 3b93a05e..f31d64fd 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -901,7 +901,7 @@ fn is_vector_store_file_name(file_name: &str) -> bool {
}
fn is_normal_data_file(file: &DataFileMeta) -> bool {
-
!crate::table::dedicated_format_file_writer::is_blob_file_name(&file.file_name)
+
!crate::table::dedicated_format_file_writer::is_blob_or_video_file_name(&file.file_name)
&& !is_vector_store_file_name(&file.file_name)
}
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 30384d9f..b38fed10 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -262,6 +262,11 @@ impl TableWrite {
let write_schema = build_target_arrow_schema(schema.fields())?;
let core_options = CoreOptions::new(schema.options());
let blob_descriptor_fields = core_options.blob_descriptor_fields();
+ if !core_options.video_frame_fields().is_empty() {
+ return Err(crate::Error::Unsupported {
+ message: "Writing packed video files is not supported
yet".into(),
+ });
+ }
let blob_view_fields = core_options.blob_view_fields();
let blob_inline_fields = core_options.blob_inline_fields();
@@ -457,7 +462,7 @@ impl TableWrite {
let has_blob_fields = schema
.fields()
.iter()
- .any(|f| f.data_type().is_blob_type() &&
!blob_inline_fields.contains(f.name()));
+ .any(|f| f.data_type().is_blob_file_field() &&
!blob_inline_fields.contains(f.name()));
let has_dedicated_vector_fields = vector_file_format.is_some()
&& schema
.fields()
diff --git a/crates/paimon/src/table/write_batch_normalize.rs
b/crates/paimon/src/table/write_batch_normalize.rs
index dcd0271a..38d44086 100644
--- a/crates/paimon/src/table/write_batch_normalize.rs
+++ b/crates/paimon/src/table/write_batch_normalize.rs
@@ -20,18 +20,35 @@
//! PyArrow calls list children `item`; Paimon's Arrow schema calls them
//! `element`. Parquet persists child names, so accepting the input schema
//! without rebuilding the arrays would produce a file that later projections
-//! cannot reliably read by the table schema. The buffers and values are
shared;
-//! only nested array wrappers are rebuilt.
+//! cannot reliably read by the table schema. Ordinary inputs share their
+//! payload buffers; hidden children of NULL parents may require compaction.
use crate::{Error, Result};
use arrow_array::{
Array, ArrayRef, BinaryArray, FixedSizeBinaryArray, ListArray, MapArray,
StructArray,
};
+use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::DataType;
use std::sync::Arc;
pub(super) fn normalize_write_array(array: &ArrayRef, expected: &DataType) ->
Result<ArrayRef> {
- if array.data_type() == expected {
+ normalize_visible_array(array, expected, None)
+}
+
+fn normalize_visible_array(
+ array: &ArrayRef,
+ expected: &DataType,
+ parent_nulls: Option<&NullBuffer>,
+) -> Result<ArrayRef> {
+ // Foreign Arrow arrays can declare non-nullable nested fields without
+ // satisfying them. Rebuild nested wrappers even when their schema matches
+ // so the Arrow constructors validate the actual children before writing.
+ if array.data_type() == expected
+ && !matches!(
+ expected,
+ DataType::List(_) | DataType::Struct(_) | DataType::Map(_, _)
+ )
+ {
return Ok(array.clone());
}
@@ -47,20 +64,19 @@ pub(super) fn normalize_write_array(array: &ArrayRef,
expected: &DataType) -> Re
}
(DataType::List(_), DataType::List(expected_child)) => {
let list = array.as_any().downcast_ref::<ListArray>().unwrap();
- let values = normalize_write_array(list.values(),
expected_child.data_type())?;
- let normalized = ListArray::try_new(
- expected_child.clone(),
- list.offsets().clone(),
- values,
- list.nulls().cloned(),
- )
- .map_err(normalization_error)?;
+ let nulls = NullBuffer::union(list.nulls(), parent_nulls);
+ let (values, offsets) =
+ visible_children(list.values(), list.offsets(),
nulls.as_ref())?;
+ let values = normalize_write_array(&values,
expected_child.data_type())?;
+ let normalized = ListArray::try_new(expected_child.clone(),
offsets, values, nulls)
+ .map_err(normalization_error)?;
Ok(Arc::new(normalized))
}
(DataType::Struct(actual_fields), DataType::Struct(expected_fields))
if actual_fields.len() == expected_fields.len() =>
{
let row = array.as_any().downcast_ref::<StructArray>().unwrap();
+ let nulls = NullBuffer::union(row.nulls(), parent_nulls);
let columns = actual_fields
.iter()
.zip(expected_fields)
@@ -76,16 +92,24 @@ pub(super) fn normalize_write_array(array: &ArrayRef,
expected: &DataType) -> Re
source: None,
});
}
- normalize_write_array(row.column(index),
expected.data_type())
+ normalize_visible_array(row.column(index),
expected.data_type(), nulls.as_ref())
})
.collect::<Result<Vec<_>>>()?;
- let normalized =
- StructArray::try_new(expected_fields.clone(), columns,
row.nulls().cloned())
- .map_err(normalization_error)?;
+ let normalized = StructArray::try_new_with_length(
+ expected_fields.clone(),
+ columns,
+ nulls,
+ row.len(),
+ )
+ .map_err(normalization_error)?;
Ok(Arc::new(normalized))
}
(DataType::Map(_, _), DataType::Map(expected_entries,
expected_ordered)) => {
let map = array.as_any().downcast_ref::<MapArray>().unwrap();
+ let nulls = NullBuffer::union(map.nulls(), parent_nulls);
+ let entries: ArrayRef = Arc::new(map.entries().clone());
+ let (entries, offsets) = visible_children(&entries, map.offsets(),
nulls.as_ref())?;
+ let entries =
entries.as_any().downcast_ref::<StructArray>().unwrap();
// MAP key/value roles are positional. Their Arrow field names are
// aliases, unlike user-defined ROW field names.
let DataType::Struct(fields) = expected_entries.data_type() else {
@@ -94,8 +118,9 @@ pub(super) fn normalize_write_array(array: &ArrayRef,
expected: &DataType) -> Re
source: None,
});
};
- let columns = [map.keys(), map.values()]
- .into_iter()
+ let columns = entries
+ .columns()
+ .iter()
.zip(fields)
.map(|(column, field)| normalize_write_array(column,
field.data_type()))
.collect::<Result<Vec<_>>>()?;
@@ -103,9 +128,9 @@ pub(super) fn normalize_write_array(array: &ArrayRef,
expected: &DataType) -> Re
StructArray::try_new(fields.clone(), columns,
None).map_err(normalization_error)?;
let normalized = MapArray::try_new(
expected_entries.clone(),
- map.offsets().clone(),
+ offsets,
entries,
- map.nulls().cloned(),
+ nulls,
*expected_ordered,
)
.map_err(normalization_error)?;
@@ -121,6 +146,70 @@ pub(super) fn normalize_write_array(array: &ArrayRef,
expected: &DataType) -> Re
}
}
+/// Keep only children referenced by visible parents. Ordinary inputs and
+/// slices share their payload buffers; only disjoint ranges across NULL
+/// parents need concatenation. Hidden children must not fail NOT NULL checks
+/// or resolve descriptors which Java would never visit.
+fn visible_children(
+ values: &ArrayRef,
+ offsets: &OffsetBuffer<i32>,
+ nulls: Option<&NullBuffer>,
+) -> Result<(ArrayRef, OffsetBuffer<i32>)> {
+ if nulls.is_none_or(|nulls| nulls.null_count() == 0) {
+ let start = offsets[0];
+ let end = offsets[offsets.len() - 1];
+ let normalized_offsets = if start == 0 {
+ offsets.clone()
+ } else {
+ OffsetBuffer::new(ScalarBuffer::from(
+ offsets
+ .iter()
+ .map(|offset| offset - start)
+ .collect::<Vec<_>>(),
+ ))
+ };
+ return Ok((
+ values.slice(start as usize, (end - start) as usize),
+ normalized_offsets,
+ ));
+ }
+ let mut ranges: Vec<std::ops::Range<usize>> = Vec::new();
+ let mut normalized_offsets = Vec::with_capacity(offsets.len());
+ normalized_offsets.push(0);
+ let mut length = 0i32;
+ for (row, pair) in offsets.windows(2).enumerate() {
+ if !nulls.is_some_and(|nulls| nulls.is_null(row)) {
+ let start = pair[0] as usize;
+ let end = pair[1] as usize;
+ if start != end {
+ if let Some(previous) = ranges.last_mut().filter(|range|
range.end == start) {
+ previous.end = end;
+ } else {
+ ranges.push(start..end);
+ }
+ length += pair[1] - pair[0];
+ }
+ }
+ normalized_offsets.push(length);
+ }
+ let children = match ranges.as_slice() {
+ [] => values.slice(0, 0),
+ [range] => values.slice(range.start, range.len()),
+ ranges => {
+ let slices: Vec<_> = ranges
+ .iter()
+ .map(|range| values.slice(range.start, range.len()))
+ .collect();
+ let arrays: Vec<_> = slices.iter().map(|array|
array.as_ref()).collect();
+ arrow_select::concat::concat(&arrays).map_err(normalization_error)?
+ }
+ };
+ Ok((
+ children,
+ OffsetBuffer::new(ScalarBuffer::from(normalized_offsets)),
+ ))
+}
+
fn normalization_error(error: arrow_schema::ArrowError) -> Error {
Error::DataInvalid {
message: format!("Invalid nested Arrow write array: {error}"),
@@ -131,7 +220,7 @@ fn normalization_error(error: arrow_schema::ArrowError) ->
Error {
#[cfg(test)]
mod tests {
use super::*;
- use arrow_array::{Int32Array, StringArray};
+ use arrow_array::{Int32Array, LargeBinaryArray, StringArray};
use arrow_buffer::{OffsetBuffer, ScalarBuffer};
use arrow_schema::Field;
@@ -259,4 +348,161 @@ mod tests {
.unwrap();
assert_eq!(lists.value(1).len(), 2);
}
+
+ #[test]
+ fn matching_schemas_cannot_bypass_nested_map_value_nullability() {
+ let fields = vec![
+ Field::new("key", DataType::Utf8, false),
+ Field::new("value", DataType::LargeBinary, false),
+ ]
+ .into();
+ // PyArrow can pass a MAP through FFI with a NOT NULL value field but
+ // actual null values. Simulate that input without changing its schema.
+ let entries = unsafe {
+ StructArray::new_unchecked(
+ fields,
+ vec![
+ Arc::new(StringArray::from(vec!["key"])),
+ Arc::new(LargeBinaryArray::from(vec![None::<&[u8]>])),
+ ],
+ None,
+ )
+ };
+ let map: ArrayRef = Arc::new(
+ MapArray::try_new(
+ Arc::new(Field::new("entries", entries.data_type().clone(),
false)),
+ OffsetBuffer::new(ScalarBuffer::from(vec![0, 1])),
+ entries,
+ None,
+ false,
+ )
+ .unwrap(),
+ );
+ let row: ArrayRef = Arc::new(StructArray::new(
+ vec![Field::new("payload", map.data_type().clone(), true)].into(),
+ vec![map.clone()],
+ None,
+ ));
+ let list: ArrayRef = Arc::new(ListArray::new(
+ Arc::new(Field::new("element", map.data_type().clone(), true)),
+ OffsetBuffer::new(ScalarBuffer::from(vec![0, 1])),
+ map.clone(),
+ None,
+ ));
+ for input in [map, row, list] {
+ let error = normalize_write_array(&input,
input.data_type()).unwrap_err();
+ assert!(error.to_string().contains("non-nullable"));
+ }
+ }
+
+ #[test]
+ fn matching_empty_row_schema_keeps_its_length_and_nulls() {
+ let input: ArrayRef = Arc::new(StructArray::new_empty_fields(
+ 3,
+ Some(arrow_buffer::NullBuffer::from(vec![true, false, true])),
+ ));
+ let output = normalize_write_array(&input, input.data_type()).unwrap();
+ assert_eq!(output.len(), 3);
+ assert_eq!(output.nulls(), input.nulls());
+ }
+
+ #[test]
+ fn hidden_null_children_do_not_violate_visible_collection_constraints() {
+ let values: ArrayRef = Arc::new(LargeBinaryArray::from(vec![
+ None,
+ Some(b"one".as_slice()),
+ None,
+ Some(b"two".as_slice()),
+ ]));
+ let offsets = OffsetBuffer::new(ScalarBuffer::from(vec![0, 1, 2, 3,
4]));
+ let nulls = Some(NullBuffer::from(vec![false, true, false, true]));
+ let list: ArrayRef = Arc::new(ListArray::new(
+ Arc::new(Field::new("item", DataType::LargeBinary, true)),
+ offsets.clone(),
+ values.clone(),
+ nulls.clone(),
+ ));
+ let expected_list = DataType::List(Arc::new(Field::new(
+ "element",
+ DataType::LargeBinary,
+ false,
+ )));
+ let entries = StructArray::new(
+ vec![
+ Field::new("source_key", DataType::Utf8, false),
+ Field::new("source_value", DataType::LargeBinary, true),
+ ]
+ .into(),
+ vec![
+ Arc::new(StringArray::from(vec!["hidden", "first", "hidden",
"last"])),
+ values,
+ ],
+ None,
+ );
+ let map: ArrayRef = Arc::new(MapArray::new(
+ Arc::new(Field::new(
+ "source_entries",
+ entries.data_type().clone(),
+ false,
+ )),
+ offsets,
+ entries,
+ nulls,
+ false,
+ ));
+ let expected_map = DataType::Map(
+ Arc::new(Field::new(
+ "entries",
+ DataType::Struct(
+ vec![
+ Field::new("key", DataType::Utf8, false),
+ Field::new("value", DataType::LargeBinary, false),
+ ]
+ .into(),
+ ),
+ false,
+ )),
+ false,
+ );
+ for (input, expected) in [(list, expected_list), (map, expected_map)] {
+ for input in [input.clone(), input.slice(1, 1)] {
+ let output = normalize_write_array(&input, &expected).unwrap();
+ assert_eq!(output.data_type(), &expected);
+ assert_eq!(output.len(), input.len());
+ assert_eq!(output.nulls(), input.nulls());
+ let data = output.to_data();
+ data.validate_full().unwrap();
+ }
+ }
+ }
+
+ #[test]
+ fn ancestor_row_nulls_mask_nested_non_nullable_fields() {
+ let inner: ArrayRef = Arc::new(StructArray::new(
+ vec![Field::new("value", DataType::LargeBinary, true)].into(),
+ vec![Arc::new(LargeBinaryArray::from(vec![
+ None,
+ Some(b"ok".as_slice()),
+ ]))],
+ None,
+ ));
+ let input: ArrayRef = Arc::new(StructArray::new(
+ vec![Field::new("inner", inner.data_type().clone(), false)].into(),
+ vec![inner],
+ Some(NullBuffer::from(vec![false, true])),
+ ));
+ let expected = DataType::Struct(
+ vec![Field::new(
+ "inner",
+ DataType::Struct(vec![Field::new("value",
DataType::LargeBinary, false)].into()),
+ false,
+ )]
+ .into(),
+ );
+ let output = normalize_write_array(&input, &expected).unwrap();
+ output.to_data().validate_full().unwrap();
+ assert_eq!(output.len(), 2);
+ assert!(output.is_null(0));
+ assert!(output.is_valid(1));
+ }
}
diff --git a/crates/paimon/testdata/video/video-frame-descriptor-v1.hex
b/crates/paimon/testdata/video/video-frame-descriptor-v1.hex
new file mode 100644
index 00000000..db91bd4e
--- /dev/null
+++ b/crates/paimon/testdata/video/video-frame-descriptor-v1.hex
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+014d52464f454449561600000073333a2f2f6275636b65742fe8a786e9a2912e6d7034070000000000000063000000000000002a000000000000006a000000000000000800000000000000
diff --git a/crates/paimon/testdata/video/video-v1.hex
b/crates/paimon/testdata/video/video-v1.hex
new file mode 100644
index 00000000..93f04753
--- /dev/null
+++ b/crates/paimon/testdata/video/video-v1.hex
@@ -0,0 +1,18 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+6162635758595a0149464b4f45444956010000000200000000000000000000000100000000000000789c6360c00e78a0f4821e08cd04e503001394013b06026c6b040100020100010106010403000e0602000000020000000500000005000000050000004944454f01
diff --git a/crates/paimon/tests/blob_collection_write_test.rs
b/crates/paimon/tests/blob_collection_write_test.rs
new file mode 100644
index 00000000..0455d9a9
--- /dev/null
+++ b/crates/paimon/tests/blob_collection_write_test.rs
@@ -0,0 +1,270 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::builder::{LargeBinaryBuilder, ListBuilder, MapBuilder,
StringBuilder};
+use arrow_array::{
+ Array, ArrayRef, Int32Array, LargeBinaryArray, ListArray, MapArray,
RecordBatch,
+};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+use futures::TryStreamExt;
+use paimon::io::FileRead;
+use paimon::spec::{
+ ArrayType, BlobType, DataType, IntType, MapType, Schema, TableSchema,
VarCharType,
+};
+use paimon::table::Table;
+use std::sync::Arc;
+
+async fn collection_table(options: &[(&str, &str)]) -> Table {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "items",
+ DataType::Array(ArrayType::new(DataType::Blob(BlobType::new()))),
+ )
+ .column(
+ "attrs",
+ DataType::Map(MapType::new(
+ DataType::VarChar(VarCharType::new(100).unwrap()),
+ DataType::Blob(BlobType::new()),
+ )),
+ )
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true");
+ for (key, value) in options {
+ schema = schema.option(*key, *value);
+ }
+ let path = "memory:/collections";
+ let (io, table) = memory_table(path, TableSchema::new(0,
&schema.build().unwrap()));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ table
+}
+
+fn collection_batch(value: &[u8]) -> RecordBatch {
+ let mut list = ListBuilder::new(LargeBinaryBuilder::new());
+ list.values().append_value(b"ignored-before-slice");
+ list.append(true);
+ list.values().append_value(value);
+ list.values().append_null();
+ list.values().append_value(b"");
+ list.append(true);
+ list.append(false);
+ list.append(true);
+ let mut map = MapBuilder::new(None, StringBuilder::new(),
LargeBinaryBuilder::new());
+ map.keys().append_value("ignored");
+ map.values().append_value(b"before-slice");
+ map.append(true).unwrap();
+ for (key, item) in [
+ ("first", Some(value)),
+ ("", Some(b"".as_slice())),
+ ("null", None),
+ ] {
+ map.keys().append_value(key);
+ map.values().append_option(item);
+ }
+ map.append(true).unwrap();
+ map.append(false).unwrap();
+ map.append(true).unwrap();
+ RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![0, 1, 2, 3])) as ArrayRef,
+ ),
+ ("items", Arc::new(list.finish()) as ArrayRef),
+ ("attrs", Arc::new(map.finish()) as ArrayRef),
+ ])
+ .unwrap()
+ .slice(1, 3)
+}
+
+async fn read_batches(table: &Table, descriptor: bool) -> Vec<RecordBatch> {
+ let table = table.copy_with_options(
+ [("blob-as-descriptor".into(), descriptor.to_string())]
+ .into_iter()
+ .collect(),
+ );
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap()
+}
+
+#[tokio::test]
+async fn
collections_use_java_blob_files_and_preserve_null_empty_and_sliced_values() {
+ for descriptors in [false, true] {
+ for optimize in [false, true] {
+ let table = collection_table(&[(
+ "data-evolution.write-cols-optimization.enabled",
+ if optimize { "true" } else { "false" },
+ )])
+ .await;
+ let payload = b"payload";
+ let source = "memory:/source";
+ table
+ .file_io()
+ .new_output(source)
+ .unwrap()
+ .write(payload.to_vec().into())
+ .await
+ .unwrap();
+ let descriptor = paimon::spec::BlobDescriptor::new(source.into(),
0, -1).serialize();
+ let input = collection_batch(if descriptors { &descriptor } else {
payload });
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&input).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let files = &messages[0].new_files;
+ assert_eq!(files.len(), 3);
+ assert!(files[0].file_name.ends_with(".parquet"));
+ assert_eq!(
+ files[0].write_cols,
+ if optimize {
+ None
+ } else {
+ Some(vec!["id".into()])
+ }
+ );
+ for (file, name) in files[1..].iter().zip(["items", "attrs"]) {
+ assert!(file.file_name.ends_with(".blob"), "{}",
file.file_name);
+ assert_eq!(file.write_cols, Some(vec![name.into()]));
+ assert_eq!(file.row_count, 3);
+ }
+ builder.new_commit().commit(messages).await.unwrap();
+ for descriptor_mode in [false, true] {
+ let batches = read_batches(&table, descriptor_mode).await;
+ let batch =
+ arrow_select::concat::concat_batches(&batches[0].schema(),
&batches).unwrap();
+ assert_eq!(batch.num_rows(), 3);
+ let list = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<ListArray>()
+ .unwrap();
+ assert!(list.is_null(1));
+ assert_eq!(list.value_length(2), 0);
+ let items = list.value(0);
+ let values =
items.as_any().downcast_ref::<LargeBinaryArray>().unwrap();
+ assert!(values.is_null(1));
+ let map =
batch.column(2).as_any().downcast_ref::<MapArray>().unwrap();
+ assert!(map.is_null(1));
+ assert_eq!(map.value_length(2), 0);
+ let values_map = map
+ .values()
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap();
+ assert!(values_map.is_null(2));
+ if descriptor_mode {
+ for (bytes, expected) in [
+ (values.value(0), payload.as_slice()),
+ (values.value(2), b"".as_slice()),
+ (values_map.value(0), payload.as_slice()),
+ (values_map.value(1), b"".as_slice()),
+ ] {
+ let desc =
paimon::spec::BlobDescriptor::deserialize(bytes).unwrap();
+ assert!(desc.uri().ends_with(".blob"));
+ assert_eq!(desc.length(), expected.len() as i64);
+ let data = table
+ .file_io()
+ .new_input(desc.uri())
+ .unwrap()
+ .reader()
+ .await
+ .unwrap()
+ .read(desc.offset() as u64..(desc.offset() +
desc.length()) as u64)
+ .await
+ .unwrap();
+ assert_eq!(data.as_ref(), expected);
+ }
+ } else {
+ assert_eq!(values.value(0), payload);
+ assert_eq!(values.value(2), b"");
+ assert_eq!(values_map.value(0), payload);
+ assert_eq!(values_map.value(1), b"");
+ }
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn raw_blob_collection_updates_are_rejected_like_java() {
+ let table = collection_table(&[]).await;
+ for column in ["items", "attrs"] {
+ assert!(
+ table
+ .new_write_builder()
+ .new_data_evolution_writer(vec![column.into()])
+ .is_err(),
+ "raw Blob collection {column} must not be updated"
+ );
+ }
+}
+
+#[test]
+fn video_configuration_matches_java_and_does_not_fall_through_to_blob_writes()
{
+ fn schema(
+ video_type: DataType,
+ options: &[(&str, &str)],
+ primary: bool,
+ ) -> paimon::Result<Schema> {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("video", video_type)
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .option("video-frame-field", "video");
+ if primary {
+ builder = builder.primary_key(vec!["id"]);
+ }
+ for (key, value) in options {
+ builder = builder.option(*key, *value);
+ }
+ builder.build()
+ }
+ let scalar = DataType::Blob(BlobType::new());
+ let valid = schema(scalar.clone(), &[], false).unwrap();
+ let (_, table) = memory_table("memory:/video", TableSchema::new(0,
&valid));
+ assert!(
+ table.new_write_builder().new_write().is_err(),
+ "native video writes must not silently become ordinary .blob files"
+ );
+ assert!(schema(scalar.clone(), &[("blob-descriptor-field", "video")],
false).is_err());
+ assert!(schema(scalar.clone(), &[("blob-view-field", "video")],
false).is_err());
+ assert!(schema(scalar.clone(), &[], true).is_err());
+ assert!(schema(DataType::Array(ArrayType::new(scalar)), &[],
false).is_err());
+ assert!(schema(DataType::Int(IntType::new()), &[], false).is_err());
+ let options = [("video-frame-field".into(), " video, video ,other
".into())]
+ .into_iter()
+ .collect();
+ let options = paimon::spec::CoreOptions::new(&options);
+ assert_eq!(
+ options.video_frame_fields(),
+ ["video".to_string(), "other".to_string()]
+ .into_iter()
+ .collect()
+ );
+ assert_eq!(options.blob_fields(), options.video_frame_fields());
+}