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 066f8078 [core] Support Java Data Evolution ROW sidecars (#1047)
066f8078 is described below
commit 066f8078e35b6c624168670a075f3f3be599baa8
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 6 21:11:49 2026 +0800
[core] Support Java Data Evolution ROW sidecars (#1047)
---
crates/paimon/src/arrow/format/row.rs | 219 +++++-
crates/paimon/src/spec/core_options.rs | 53 ++
crates/paimon/src/table/data_evolution_reader.rs | 54 +-
crates/paimon/src/table/data_evolution_writer.rs | 9 +
crates/paimon/src/table/data_file_reader.rs | 99 ++-
crates/paimon/src/table/data_file_writer.rs | 226 +++++-
.../src/table/dedicated_format_file_writer.rs | 7 +
crates/paimon/src/table/mod.rs | 1 +
crates/paimon/src/table/row_sidecar.rs | 254 +++++++
.../tests/data_evolution_row_sidecar_test.rs | 815 +++++++++++++++++++++
10 files changed, 1675 insertions(+), 62 deletions(-)
diff --git a/crates/paimon/src/arrow/format/row.rs
b/crates/paimon/src/arrow/format/row.rs
index 22e37aed..ff53b619 100644
--- a/crates/paimon/src/arrow/format/row.rs
+++ b/crates/paimon/src/arrow/format/row.rs
@@ -36,11 +36,11 @@ use arrow_array::builder::{
TimestampMillisecondBuilder, TimestampNanosecondBuilder,
TimestampSecondBuilder,
};
use arrow_array::{
- Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
Float32Array,
- Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
LargeBinaryArray, ListArray,
- MapArray, RecordBatch, RecordBatchOptions, StringArray, StructArray,
Time32MillisecondArray,
- TimestampMicrosecondArray, TimestampMillisecondArray,
TimestampNanosecondArray,
- TimestampSecondArray,
+ Array, ArrayRef, BinaryArray, BooleanArray, Date32Array, Decimal128Array,
FixedSizeListArray,
+ Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
LargeBinaryArray,
+ ListArray, MapArray, RecordBatch, RecordBatchOptions, StringArray,
StructArray,
+ Time32MillisecondArray, TimestampMicrosecondArray,
TimestampMillisecondArray,
+ TimestampNanosecondArray, TimestampSecondArray,
};
use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer};
use arrow_schema::{DataType as ArrowDataType, Field, Fields, SchemaRef,
TimeUnit};
@@ -169,6 +169,10 @@ impl FormatFileWriter for RowFormatWriter {
self.block_writer.estimated_size()
}
+ fn pending_rows(&self) -> Option<usize> {
+ Some(self.block_writer.row_count())
+ }
+
async fn flush(&mut self) -> crate::Result<()> {
self.flush_block().await
}
@@ -441,6 +445,10 @@ fn validate_arrow_type_for_row_field(
validate_arrow_type_for_row_field(field_name, child.data_type(),
a.element_type())?;
true
}
+ (ArrowDataType::FixedSizeList(child, size), DataType::Vector(v)) => {
+ validate_arrow_type_for_row_field(field_name, child.data_type(),
v.element_type())?;
+ *size == v.length() as i32
+ }
(ArrowDataType::Map(entries, _), DataType::Map(m)) => {
validate_arrow_map_entries(
field_name,
@@ -565,9 +573,7 @@ fn validate_supported_type(data_type: &DataType) ->
crate::Result<()> {
}
Ok(())
}
- DataType::Vector(_) => Err(Error::Unsupported {
- message: "VectorType is not supported in the .row
format".to_string(),
- }),
+ DataType::Vector(v) => validate_supported_type(v.element_type()),
}
}
@@ -738,10 +744,20 @@ fn write_field_value(
let row = downcast::<StructArray>(array, data_type)?;
write_struct_row(out, row, row_idx, r.fields())?;
}
- DataType::Vector(_) => {
- return Err(Error::Unsupported {
- message: "VectorType is not supported in .row field
serialization".to_string(),
- });
+ DataType::Vector(v) => {
+ let vector = downcast::<FixedSizeListArray>(array, data_type)?;
+ let values = vector.value(row_idx);
+ if values.null_count() != 0 {
+ return Err(Error::DataInvalid {
+ message: ".row VECTOR elements must not be null".into(),
+ source: None,
+ });
+ }
+ // Unlike ARRAY, Java writes no element null bitmap for VECTOR.
+ write_var_u32(out, v.length());
+ for index in 0..values.len() {
+ write_field_value(out, &values, index, v.element_type())?;
+ }
}
}
Ok(())
@@ -1207,6 +1223,12 @@ enum ColumnBuilder {
validities: Vec<bool>,
values: Box<ColumnBuilder>,
},
+ Vector {
+ field: Arc<Field>,
+ size: i32,
+ validities: Vec<bool>,
+ values: Box<ColumnBuilder>,
+ },
Map {
entries_field: Arc<Field>,
offsets: Vec<i32>,
@@ -1315,11 +1337,16 @@ impl ColumnBuilder {
validities: Vec::with_capacity(capacity),
len: 0,
},
- DataType::Vector(_) => {
- return Err(Error::Unsupported {
- message: "VectorType is not supported in .row
ColumnBuilder".to_string(),
- });
- }
+ DataType::Vector(v) => Self::Vector {
+ field: Arc::new(Field::new(
+ "element",
+ paimon_type_to_arrow(v.element_type())?,
+ v.element_type().is_nullable(),
+ )),
+ size: v.length() as i32,
+ validities: Vec::with_capacity(capacity),
+ values: Box::new(ColumnBuilder::new(v.element_type(),
capacity)?),
+ },
})
}
@@ -1342,6 +1369,17 @@ impl ColumnBuilder {
Self::TimestampUs(b) => b.append_null(),
Self::TimestampNs(b) => b.append_null(),
Self::Decimal(b) => b.append_null(),
+ Self::Vector {
+ size,
+ validities,
+ values,
+ ..
+ } => {
+ for _ in 0..*size {
+ values.append_null();
+ }
+ validities.push(false);
+ }
Self::Array {
offsets,
validities,
@@ -1430,6 +1468,29 @@ impl ColumnBuilder {
push_nested_offset(offsets, size)?;
validities.push(true);
}
+ (
+ Self::Vector {
+ size,
+ validities,
+ values,
+ ..
+ },
+ DataType::Vector(v),
+ ) => {
+ let stored_size = input.read_var_u32()?;
+ if stored_size != *size as u32 {
+ return Err(Error::DataInvalid {
+ message: format!(
+ ".row VECTOR length {stored_size} differs from
declared length {size}"
+ ),
+ source: None,
+ });
+ }
+ for _ in 0..stored_size {
+ values.read_append(input, v.element_type())?;
+ }
+ validities.push(true);
+ }
(
Self::Map {
offsets,
@@ -1549,6 +1610,23 @@ impl ColumnBuilder {
source: Some(Box::new(e)),
})?,
),
+ Self::Vector {
+ field,
+ size,
+ validities,
+ values,
+ } => Arc::new(
+ FixedSizeListArray::try_new(
+ field,
+ size,
+ values.finish()?,
+ Some(null_buffer(validities)),
+ )
+ .map_err(|error| Error::DataInvalid {
+ message: format!("Failed to build .row VECTOR: {error}"),
+ source: Some(Box::new(error)),
+ })?,
+ ),
Self::Map {
entries_field,
offsets,
@@ -3396,6 +3474,113 @@ mod tests {
assert_eq!(names.value(2), "nine");
}
+ #[test]
+ fn blob_data_block_matches_java_materialized_descriptor() {
+ use crate::spec::BlobType;
+ // Java RowBlockWriter wrote Blob.fromFile(source, offset=6, length=7)
+ // where source contained "prefixpayloadsuffix".
+ let java_block = decode_hex("00077061796c6f61640000000001000000");
+ let fields = vec![DataField::new(
+ 0,
+ "payload".into(),
+ DataType::Blob(BlobType::new()),
+ )];
+ let schema = build_target_arrow_schema(&fields).unwrap();
+ let batch = RecordBatch::try_new(
+ schema.clone(),
+ vec![Arc::new(LargeBinaryArray::from_iter([Some(
+ b"payload".as_slice(),
+ )]))],
+ )
+ .unwrap();
+ let mut writer = RowBlockWriter::new();
+ writer.write_row(&batch, 0, &fields).unwrap();
+ assert_eq!(writer.finish(), java_block);
+ let decoded = decode_row_block(&java_block, &fields, schema,
&[0]).unwrap();
+ assert_eq!(
+ decoded
+ .column(0)
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap()
+ .value(0),
+ b"payload"
+ );
+ }
+
+ #[test]
+ fn vector_blocks_match_java_and_decode_parent_nulls() {
+ use crate::spec::{FloatType, VectorType};
+ // Generated by Java RowBlockWriter with VECTOR<2,FLOAT NOT NULL>:
+ // GenericRow.of([1.25f, -2.5f]), GenericRow.of(null).
+ let java_block =
decode_hex("00020000a03f000020c001000000000a00000002000000");
+ let fields = vec![DataField::new(
+ 0,
+ "v".into(),
+ DataType::Vector(
+ VectorType::new(2,
DataType::Float(FloatType::with_nullable(false))).unwrap(),
+ ),
+ )];
+ let schema = build_target_arrow_schema(&fields).unwrap();
+ let mut vectors =
+
arrow_array::builder::FixedSizeListBuilder::new(Float32Builder::new(),
2).with_field(
+ Arc::new(Field::new("element", ArrowDataType::Float32, false)),
+ );
+ vectors.values().append_slice(&[1.25, -2.5]);
+ vectors.append(true);
+ vectors.values().append_slice(&[0.0, 0.0]);
+ vectors.append(false);
+ let batch = RecordBatch::try_new(schema.clone(),
vec![Arc::new(vectors.finish())]).unwrap();
+ let mut writer = RowBlockWriter::new();
+ writer.write_row(&batch, 0, &fields).unwrap();
+ writer.write_row(&batch, 1, &fields).unwrap();
+ assert_eq!(writer.finish(), java_block);
+ let decoded = decode_row_block(&java_block, &fields, schema, &[0,
1]).unwrap();
+ let actual = decoded
+ .column(0)
+ .as_any()
+ .downcast_ref::<FixedSizeListArray>()
+ .unwrap();
+ assert!(actual.is_null(1));
+ let first = actual.value(0);
+ assert_eq!(
+ first
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .values()
+ .as_ref(),
+ &[1.25, -2.5]
+ );
+ // A malformed vector length cannot shift the following fields.
+ let mut malformed = java_block.clone();
+ malformed[1] = 3;
+ assert!(decode_row_block(&malformed, &fields, decoded.schema(),
&[0]).is_err());
+ }
+
+ #[test]
+ fn vector_writes_reject_null_elements_and_wrong_dimensions() {
+ use crate::spec::{FloatType, VectorType};
+ let data_type =
+ DataType::Vector(VectorType::new(2,
DataType::Float(FloatType::new())).unwrap());
+ let mut vectors =
arrow_array::builder::FixedSizeListBuilder::new(Float32Builder::new(), 2);
+ vectors.values().append_value(1.0);
+ vectors.values().append_null();
+ vectors.append(true);
+ let array = Arc::new(vectors.finish()) as ArrayRef;
+ let error = write_field_value(&mut Vec::new(), &array, 0,
&data_type).unwrap_err();
+ assert!(error.to_string().contains("elements must not be null"));
+ assert!(validate_arrow_type_for_row_field(
+ "v",
+ &ArrowDataType::FixedSizeList(
+ Arc::new(Field::new("element", ArrowDataType::Float32, true)),
+ 3,
+ ),
+ &data_type
+ )
+ .is_err());
+ }
+
#[tokio::test]
async fn row_reader_reads_java_generated_fixture() {
let fields = java_fixture_fields();
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 0d33a760..f67a5e37 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -773,6 +773,59 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(false)
}
+ /// Whether a missing optional read target may fall back to the primary
file.
+ pub fn scan_ignore_lost_file(&self) -> bool {
+ self.options
+ .get("scan.ignore-lost-files")
+ .is_some_and(|value| value.eq_ignore_ascii_case("true"))
+ }
+
+ /// Whether normal Data Evolution files also write an aligned ROW sidecar.
+ pub fn data_evolution_row_sidecar_enabled(&self) -> crate::Result<bool> {
+ match self.options.get("data-evolution.row-sidecar.enabled") {
+ None => Ok(false),
+ Some(value) if value.eq_ignore_ascii_case("true") => Ok(true),
+ Some(value) if value.eq_ignore_ascii_case("false") => Ok(false),
+ Some(value) => Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "data-evolution.row-sidecar.enabled must be true or false,
got {value}"
+ ),
+ }),
+ }
+ }
+
+ /// Largest sparse selection served by a ROW sidecar. Java defaults to
4096.
+ pub fn data_evolution_row_sidecar_max_selected_rows(&self) ->
crate::Result<i64> {
+ let option = "data-evolution.row-sidecar.max-selected-rows";
+ let rows = self.parse_i64_option(option)?.unwrap_or(4096);
+ if rows <= 0 {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!("{option} must be positive, got {rows}"),
+ });
+ }
+ Ok(rows)
+ }
+
+ /// Largest fraction of a file served by a ROW sidecar. Java defaults to
0.05.
+ pub fn data_evolution_row_sidecar_max_selection_ratio(&self) ->
crate::Result<f64> {
+ let option = "data-evolution.row-sidecar.max-selection-ratio";
+ let ratio = match self.options.get(option) {
+ Some(raw) => raw
+ .trim()
+ .parse::<f64>()
+ .map_err(|_| crate::Error::ConfigInvalid {
+ message: format!("Invalid {option}: {raw}"),
+ })?,
+ None => 0.05,
+ };
+ if !(ratio > 0.0 && ratio <= 1.0) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!("{option} must be in (0, 1], got {ratio}"),
+ });
+ }
+ Ok(ratio)
+ }
+
pub fn data_evolution_write_cols_optimization_enabled(&self) -> bool {
self.options
.get("data-evolution.write-cols-optimization.enabled")
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index d30d4020..123a138f 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -451,7 +451,8 @@ impl DataEvolutionReader {
// LIMIT reads resolve views only after the surviving batch has
// been selected. Eagerly scanning every split here can fail on a
// reference beyond the quota and load unrelated upstream blobs.
- let mut blob_view_lookup = if self.limit.is_some() &&
resolve_blob_views {
+ let mut blob_view_lookup = if resolve_blob_views &&
(self.limit.is_some() || splits.iter().any(|split|
+ split.data_files().iter().any(|file|
file.extra_files.iter().any(|name| name.ends_with(".row"))))) {
Some(BlobViewLookup::default())
} else {
self.preload_blob_view_lookup(&splits,
filter_before_blob_resolution)
@@ -533,7 +534,8 @@ impl DataEvolutionReader {
.await?;
let has_row_id = file_meta.first_row_id.is_some();
- let mut effective_row_ranges = if has_row_id {
row_ranges.clone() } else { None };
+ let requested_row_ranges = if has_row_id {
row_ranges.clone() } else { None };
+ let mut effective_row_ranges =
requested_row_ranges.clone();
if read_raw_file_index {
// Independent files can apply a physical row
selection
// before decoding. Column-merge groups
cannot: an older
@@ -615,12 +617,13 @@ impl DataEvolutionReader {
.data_evolution_nested_field_enabled()
|| file_schema_nested_enabled,
)
- .read_single_file_stream(
+ .read_single_file_stream_with_requested_ranges(
&split,
file_meta,
data_fields,
deletion_vector,
effective_row_ranges,
+ requested_row_ranges,
)?;
while remaining != Some(0) {
let Some(batch) = stream.next().await else {
break };
@@ -703,6 +706,7 @@ impl DataEvolutionReader {
&split,
&prepared_group,
effective_row_ranges,
+ row_ranges.clone(),
expected_output_rows,
anchor_deletion_vector,
)?;
@@ -772,7 +776,13 @@ impl DataEvolutionReader {
/// trailing.
fn project_output(&self, filtered: RecordBatch) ->
crate::Result<RecordBatch> {
let final_width = self.output_schema.fields().len();
- if filtered.num_columns() == final_width {
+ if filtered.num_columns() == final_width
+ && !filtered
+ .schema()
+ .fields()
+ .iter()
+ .any(|field| super::row_sidecar::is_blob_data(field))
+ {
return Ok(filtered);
}
let columns = filtered.columns()[..final_width].to_vec();
@@ -811,14 +821,12 @@ impl DataEvolutionReader {
return self.project_output(batch);
}
- if self.limit.is_some() {
- if let (Some(lookup), Some(rest_env)) =
- (blob_view_lookup.as_mut(), self.blob_view_rest_env.clone())
- {
- lookup
- .load_missing(rest_env, &batch, &self.blob_view_fields)
- .await?;
- }
+ if let (Some(lookup), Some(rest_env)) =
+ (blob_view_lookup.as_mut(), self.blob_view_rest_env.clone())
+ {
+ lookup
+ .load_missing(rest_env, &batch, &self.blob_view_fields)
+ .await?;
}
batch = self.resolve_blob_view_columns(batch,
blob_view_lookup.as_ref())?;
let mut batch = if !self.blob_as_descriptor &&
!descriptor_fields.is_empty() {
@@ -950,6 +958,7 @@ impl DataEvolutionReader {
split: &DataSplit,
prepared_group: &PreparedMergeGroup,
row_ranges: Option<Vec<RowRange>>,
+ requested_row_ranges: Option<Vec<RowRange>>,
expected_output_rows: usize,
anchor_deletion_vector: Option<DeletionVectorContext>,
) -> crate::Result<ArrowRecordBatchStream> {
@@ -1065,6 +1074,7 @@ impl DataEvolutionReader {
&split,
source,
row_ranges.clone(),
+ requested_row_ranges.clone(),
file_io.clone(),
schema_manager.clone(),
table_schema_id,
@@ -1192,8 +1202,15 @@ impl DataEvolutionReader {
}
emitted_rows += rows_to_emit;
+ let blob_data_fields =
source_plan.column_plan.iter().enumerate().filter_map(|(idx, provider)| {
+ let (source_idx, field_offset) = provider.as_ref()?;
+ let (batch, _) = source_cursors[*source_idx].as_ref()?;
+
super::row_sidecar::is_blob_data(batch.schema().field(*field_offset))
+ .then(|| target_schema.field(idx).name().clone())
+ }).collect();
+ let merged_schema =
super::row_sidecar::mark_blob_data(&target_schema, &blob_data_fields);
let merged =
- RecordBatch::try_new(target_schema.clone(),
columns).map_err(|e| {
+ RecordBatch::try_new(merged_schema, columns).map_err(|e| {
Error::UnexpectedError {
message: format!("Failed to build merged
RecordBatch: {e}"),
source: Some(Box::new(e)),
@@ -1271,7 +1288,8 @@ where
let mut descriptor_columns = Vec::new();
for (idx, field) in schema.fields().iter().enumerate() {
- if blob_descriptor_fields.contains(field.name()) {
+ if blob_descriptor_fields.contains(field.name()) &&
!super::row_sidecar::is_blob_data(field)
+ {
if let Some(bin_col) = batch
.column(idx)
.as_any()
@@ -1347,7 +1365,7 @@ fn collect_blob_view_structs(
view_structs: &mut HashSet<BlobViewStruct>,
) -> crate::Result<()> {
for (idx, field) in batch.schema().fields().iter().enumerate() {
- if !blob_view_fields.contains(field.name()) {
+ if !blob_view_fields.contains(field.name()) ||
super::row_sidecar::is_blob_data(field) {
continue;
}
let col = large_binary_column(batch, idx, field.name())?;
@@ -1381,7 +1399,7 @@ fn replace_blob_view_columns(
let mut changed = false;
for (idx, field) in schema.fields().iter().enumerate() {
- if !blob_view_fields.contains(field.name()) {
+ if !blob_view_fields.contains(field.name()) ||
super::row_sidecar::is_blob_data(field) {
columns.push(batch.column(idx).clone());
continue;
}
@@ -1627,6 +1645,7 @@ fn open_source_stream(
split: &DataSplit,
source: &FieldSource,
row_ranges: Option<Vec<RowRange>>,
+ requested_row_ranges: Option<Vec<RowRange>>,
file_io: FileIO,
schema_manager: SchemaManager,
table_schema_id: i64,
@@ -1717,12 +1736,13 @@ fn open_source_stream(
file, data_fields, ..
} => {
let deletion_vector = shifted_deletion_vector_for_file(file,
anchor_deletion_vector)?;
- file_reader.read_single_file_stream(
+ file_reader.read_single_file_stream_with_requested_ranges(
split,
file.as_ref().clone(),
data_fields.clone(),
deletion_vector,
row_ranges,
+ requested_row_ranges,
)
}
FieldSource::BlobBunch { bunch, .. } => {
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 2647bff2..74c0c233 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -1572,6 +1572,15 @@ impl DataEvolutionPartialWriter {
(!write_set.omit_write_cols).then(||
write_set.write_columns.clone()),
)?
.with_file_index(write_set.file_index_options.clone());
+ let writer = if self
+ .write_sets
+ .iter()
+ .any(|set| set.kind == PartialFileKind::Vector)
+ {
+ writer.without_row_sidecar()
+ } else {
+ writer
+ };
self.writers.insert(key.clone(), writer);
}
diff --git a/crates/paimon/src/table/data_file_reader.rs
b/crates/paimon/src/table/data_file_reader.rs
index 82092a58..808f795b 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -330,6 +330,7 @@ impl DataFileReader {
)
});
split_file_offset += file_meta.row_count;
+ let sidecar_ranges = split_ranges.clone();
let selected_ranges = match file_index_result {
FileIndexResult::Remain => split_ranges,
FileIndexResult::Skip => Some(Vec::new()),
@@ -360,6 +361,7 @@ impl DataFileReader {
data_fields,
None,
row_selection,
+ sidecar_ranges,
)?;
while let Some(batch) = stream.next().await {
yield batch?;
@@ -436,6 +438,27 @@ impl DataFileReader {
data_fields: Option<Vec<DataField>>,
dv: Option<Arc<DeletionVector>>,
row_ranges: Option<Vec<RowRange>>,
+ ) -> crate::Result<ArrowRecordBatchStream> {
+ self.read_single_file_stream_with_requested_ranges(
+ split,
+ file_meta,
+ data_fields,
+ dv,
+ row_ranges.clone(),
+ row_ranges,
+ )
+ }
+
+ /// Physical selection may be narrowed by file indexes or a LIMIT, while
+ /// Java selects the ROW sidecar from the caller's original row ranges.
+ pub(super) fn read_single_file_stream_with_requested_ranges(
+ &self,
+ split: &DataSplit,
+ file_meta: DataFileMeta,
+ data_fields: Option<Vec<DataField>>,
+ dv: Option<Arc<DeletionVector>>,
+ row_ranges: Option<Vec<RowRange>>,
+ requested_ranges: Option<Vec<RowRange>>,
) -> crate::Result<ArrowRecordBatchStream> {
let local_ranges = row_ranges.as_ref().map(|ranges| {
to_local_row_ranges(
@@ -446,12 +469,20 @@ impl DataFileReader {
});
let row_selection =
merge_row_selection(file_meta.row_count, dv.as_deref(),
local_ranges.as_deref());
+ let sidecar_ranges = requested_ranges.as_ref().map(|ranges| {
+ to_local_row_ranges(
+ ranges,
+ file_meta.first_row_id.unwrap_or(0),
+ file_meta.row_count,
+ )
+ });
self.read_single_file_stream_with_selection(
split,
file_meta,
data_fields,
None,
row_selection,
+ sidecar_ranges,
)
}
@@ -482,6 +513,7 @@ impl DataFileReader {
data_fields,
Some(data_schema_fields),
row_selection,
+ local_ranges,
)
}
@@ -492,6 +524,7 @@ impl DataFileReader {
data_fields: Option<Vec<DataField>>,
data_schema_fields: Option<Vec<DataField>>,
row_selection: Option<Vec<RowRange>>,
+ sidecar_ranges: Option<Vec<RowRange>>,
) -> crate::Result<ArrowRecordBatchStream> {
if row_selection.as_ref().is_some_and(Vec::is_empty) {
return Ok(futures::stream::empty().boxed());
@@ -582,23 +615,6 @@ impl DataFileReader {
blob_parallelism,
mosaic_prefetch,
)?;
- let format_read_fields = configured_reader.read_fields;
- let format_reader = configured_reader.reader;
- // The decoded batch is described by `format_read_fields`, so map
- // `read_type` onto *that* list: its entries carry the types the
columns
- // actually come back as, which is what reconciling them needs.
- let (index_mapping, source_fields) = if data_fields.is_some()
- || file_meta.write_cols.is_some()
- || read_type.iter().any(contains_selected_map)
- {
- (
- create_index_mapping(&read_type, &format_read_fields),
- Some(format_read_fields.clone()),
- )
- } else {
- (None, None)
- };
-
// Remap predicates from table-level to file-level indices.
let file_predicates = if row_id_residual {
None
@@ -621,7 +637,40 @@ impl DataFileReader {
Ok(try_stream! {
let schema_open_start = read_timing.as_ref().map(|_|
Instant::now());
- let input_file = file_io.new_input(&path_to_read)?;
+ let options = crate::spec::CoreOptions::new(&table_options);
+ let sidecar_ranges = super::row_sidecar::supports_read_type(
+ &read_type, blob_as_descriptor || options.blob_as_descriptor(),
+ ).then_some(sidecar_ranges.as_deref()).flatten();
+ let (read_path, read_size) = super::row_sidecar::read_target(
+ &file_io, &file_meta, split.bucket_path(), sidecar_ranges,
&options,
+ ).await?;
+ let configured_reader = if read_path == path_to_read {
+ configured_reader
+ } else {
+ create_format_reader_with_budget(
+ &read_path, blob_as_descriptor,
+ FormatReadFields { data_schema: &data_schema_fields,
projected: &projected_read_fields },
+ &table_options, None, blob_parallelism, mosaic_prefetch,
+ )?
+ };
+ let format_read_fields = configured_reader.read_fields;
+ let format_reader = configured_reader.reader;
+ // The decoded batch is described by `format_read_fields`, so map
+ // `read_type` onto *that* list: its entries carry the types the
columns
+ // actually come back as, which is what reconciling them needs.
+ let (index_mapping, source_fields) = if data_fields.is_some()
+ || file_meta.write_cols.is_some()
+ || read_type.iter().any(contains_selected_map)
+ {
+ (
+ create_index_mapping(&read_type, &format_read_fields),
+ Some(format_read_fields.clone()),
+ )
+ } else {
+ (None, None)
+ };
+
+ let input_file = file_io.new_input(&read_path)?;
let open_start = read_timing.as_ref().map(|_| Instant::now());
let file_reader = input_file.reader().await?;
if let (Some(timing), Some(start)) = (read_timing.as_ref(),
open_start) {
@@ -634,7 +683,14 @@ impl DataFileReader {
}),
None => Box::new(file_reader),
};
- let is_parquet =
path_to_read.to_ascii_lowercase().ends_with(".parquet");
+ let is_parquet =
read_path.to_ascii_lowercase().ends_with(".parquet");
+ let target_schema = if read_path != path_to_read &&
read_path.ends_with(".row") {
+ let names = read_type.iter().filter(|field|
matches!(field.data_type(), DataType::Blob(_)))
+ .map(|field| field.name().to_string()).collect();
+ super::row_sidecar::mark_blob_data(&target_schema, &names)
+ } else {
+ target_schema
+ };
let selected_row_ids = selected_row_ids_for_read(
projects_row_id,
file_meta.first_row_id,
@@ -645,7 +701,7 @@ impl DataFileReader {
let mut batch_stream = format_reader.read_batch_stream(
file_reader,
- file_meta.file_size as u64,
+ read_size,
&format_read_fields,
file_predicates.as_ref(),
batch_size,
@@ -1477,7 +1533,8 @@ pub(super) fn insert_column_at(
if insert_index >= batch.num_columns() {
columns.push(column);
}
- RecordBatch::try_new(output_schema.clone(), columns).map_err(|e|
Error::UnexpectedError {
+ let schema = super::row_sidecar::propagate_blob_data(output_schema,
&batch);
+ RecordBatch::try_new(schema, columns).map_err(|e| Error::UnexpectedError {
message: format!("Failed to insert column into RecordBatch: {e}"),
source: Some(Box::new(e)),
})
diff --git a/crates/paimon/src/table/data_file_writer.rs
b/crates/paimon/src/table/data_file_writer.rs
index 20798816..17946951 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -24,6 +24,7 @@
use super::data_file_index_writer::{DataFileIndexWriter, FileIndexOptions};
use super::data_file_path_factory::{DataFilePath, DataFilePathFactory};
+use super::row_sidecar::RowSidecarWriter;
use crate::arrow::format::{
create_format_writer_factory, with_write_resources, FormatFileWriter,
FormatValueStats,
FormatWriterFactory,
@@ -75,6 +76,8 @@ pub(crate) struct DataFileWriter {
current_row_count: i64,
index_options: Option<Arc<FileIndexOptions>>,
current_index: Option<DataFileIndexWriter>,
+ row_sidecar_enabled: bool,
+ current_row_sidecar: Option<RowSidecarWriter>,
resources: Option<ResourceContext>,
/// Paths owned by this write until prepare_commit hands them to the
caller.
created_paths: Vec<String>,
@@ -108,7 +111,14 @@ impl DataFileWriter {
let data_file_prefix = CoreOptions::new(&format_options)
.data_file_prefix()
.to_string();
+ let options = CoreOptions::new(&format_options);
+ let row_sidecar_enabled = options.data_evolution_enabled()
+ && options.data_evolution_row_sidecar_enabled()?
+ && !file_format.eq_ignore_ascii_case("blob")
+ && !file_format.starts_with("vector.");
Ok(Self {
+ row_sidecar_enabled,
+ current_row_sidecar: None,
format_writer_factory: None,
file_io,
paths,
@@ -149,6 +159,13 @@ impl DataFileWriter {
self
}
+ /// Java's dedicated-format writer does not create auxiliary ROW files,
+ /// including for its normal columns.
+ pub(super) fn without_row_sidecar(mut self) -> Self {
+ self.row_sidecar_enabled = false;
+ self
+ }
+
pub(crate) fn with_target_file_row_num(mut self, rows: i64) -> Self {
debug_assert!(rows > 0);
self.target_file_row_num = rows;
@@ -167,7 +184,7 @@ impl DataFileWriter {
/// Write a RecordBatch. Rolls when either target size or row count is
reached.
pub(crate) async fn write(&mut self, batch: &RecordBatch) -> Result<()> {
let result = self.write_batch(batch).await;
- if self.index_options.is_some() && result.is_err() {
+ if (self.index_options.is_some() || self.row_sidecar_enabled) &&
result.is_err() {
self.abort().await;
}
result
@@ -184,6 +201,9 @@ impl DataFileWriter {
}
self.current_writer.as_mut().unwrap().write(batch).await?;
+ if let Some(sidecar) = self.current_row_sidecar.as_mut() {
+ sidecar.write(batch).await?;
+ }
if let Some(index) = &mut self.current_index {
index.write(batch)?;
}
@@ -246,7 +266,7 @@ impl DataFileWriter {
None => {
let factory = create_format_writer_factory(
&self.file_format,
- schema,
+ schema.clone(),
self.file_compression_zstd_level,
Some(self.file_io.clone()),
Some(&self.write_fields),
@@ -262,9 +282,36 @@ impl DataFileWriter {
.await?;
self.current_writer = Some(with_write_resources(writer,
self.resources.as_ref()));
self.current_index = index;
- self.current_file_name = Some(file_name);
+ self.current_file_name = Some(file_name.clone());
self.current_file_path = Some(location);
self.current_row_count = 0;
+ self.open_row_sidecar(&file_path, &file_name, schema)
+ .await?;
+ Ok(())
+ }
+
+ async fn open_row_sidecar(
+ &mut self,
+ path: &str,
+ file_name: &str,
+ schema: arrow_schema::SchemaRef,
+ ) -> Result<()> {
+ if self.row_sidecar_enabled {
+ let path = format!("{path}.row");
+ self.created_paths.push(path.clone());
+ self.current_row_sidecar = Some(
+ RowSidecarWriter::new(
+ &self.file_io,
+ &path,
+ file_name,
+ schema,
+ &self.write_fields,
+ &self.format_options,
+ self.resources.as_ref(),
+ )
+ .await?,
+ );
+ }
Ok(())
}
@@ -293,6 +340,7 @@ impl DataFileWriter {
) -> Option<impl std::future::Future<Output = Result<DataFileMeta>> + Send
+ 'static> {
let writer = self.current_writer.take()?;
let index = self.current_index.take();
+ let row_sidecar = self.current_row_sidecar.take();
let file_io = self.file_io.clone();
let location = self.current_file_path.take().unwrap();
let bucket_dir = location.parent().to_string();
@@ -316,7 +364,19 @@ impl DataFileWriter {
};
Some(async move {
- let write_result = writer.close().await?;
+ // Close both outputs even if an auxiliary close fails. All paths
+ // remain producer-owned until the whole prepare succeeds.
+ let sidecar_result = match row_sidecar {
+ Some(sidecar) => sidecar
+ .writer
+ .close()
+ .await
+ .map(|_| Some(sidecar.file_name)),
+ None => Ok(None),
+ };
+ let write_result = writer.close().await;
+ let sidecar_name = sidecar_result?;
+ let write_result = write_result?;
let mut meta = Self::build_meta(
file_name,
write_result.file_size as i64,
@@ -342,6 +402,9 @@ impl DataFileWriter {
meta.embedded_index = Some(bytes.to_vec());
}
}
+ if let Some(name) = sidecar_name {
+ meta.extra_files.push(name);
+ }
Ok(meta)
})
}
@@ -349,7 +412,7 @@ impl DataFileWriter {
/// Close the current writer and return all written file metadata.
pub(crate) async fn prepare_commit(&mut self) -> Result<Vec<DataFileMeta>>
{
let result = self.finish().await;
- if result.is_err() && self.index_options.is_some() {
+ if result.is_err() && (self.index_options.is_some() ||
self.row_sidecar_enabled) {
self.abort().await;
}
result
@@ -429,6 +492,9 @@ impl DataFileWriter {
}
pub(super) async fn abort(&mut self) {
+ if let Some(sidecar) = self.current_row_sidecar.take() {
+ let _ = sidecar.writer.close().await;
+ }
if let Some(writer) = self.current_writer.take() {
let _ = writer.close().await;
}
@@ -788,9 +854,154 @@ mod tests {
}
}
+ struct FailingSidecar {
+ inner: Box<dyn FormatFileWriter>,
+ fail_write: bool,
+ }
+
+ #[async_trait::async_trait]
+ impl FormatFileWriter for FailingSidecar {
+ async fn write(&mut self, batch: &RecordBatch) -> Result<()> {
+ if self.fail_write {
+ return Err(crate::Error::DataInvalid {
+ message: "injected sidecar write failure".into(),
+ source: None,
+ });
+ }
+ self.inner.write(batch).await
+ }
+ fn num_bytes(&self) -> usize {
+ self.inner.num_bytes()
+ }
+ fn in_progress_size(&self) -> usize {
+ self.inner.in_progress_size()
+ }
+ async fn flush(&mut self) -> Result<()> {
+ self.inner.flush().await
+ }
+ async fn close(self: Box<Self>) ->
Result<crate::arrow::format::FormatWriteResult> {
+ self.inner.close().await?;
+ Err(crate::Error::DataInvalid {
+ message: "injected sidecar close failure".into(),
+ source: None,
+ })
+ }
+ }
+
+ #[derive(Debug)]
+ struct SidecarFailureProvider {
+ operator: Operator,
+ fail_create: std::sync::atomic::AtomicBool,
+ }
+
+ #[async_trait::async_trait]
+ impl FileIOProvider for SidecarFailureProvider {
+ async fn create(&self, path: &str) -> Result<(Operator, String)> {
+ if path.ends_with(".row") &&
self.fail_create.load(std::sync::atomic::Ordering::Relaxed)
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "injected sidecar create failure".into(),
+ source: None,
+ });
+ }
+ Ok((
+ self.operator.clone(),
+ path.trim_start_matches("memory:")
+ .trim_start_matches('/')
+ .into(),
+ ))
+ }
+ }
+
+ #[tokio::test]
+ async fn sidecar_failures_only_remove_current_producer_outputs() {
+ use std::sync::atomic::{AtomicBool, Ordering};
+ for failure in ["create", "write", "close"] {
+ let provider = Arc::new(SidecarFailureProvider {
+ operator:
Operator::from_config(opendal::services::MemoryConfig::default())
+ .unwrap(),
+ fail_create: AtomicBool::new(false),
+ });
+ let io = FileIOBuilder::new("unused")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let fields = vec![DataField::new(
+ 0,
+ "id".into(),
+ DataType::Int(IntType::new()),
+ )];
+ let mut writer = DataFileWriter::new(
+ io.clone(),
+ "memory:/sidecar-failure".into(),
+ String::new(),
+ 0,
+ 0,
+ i64::MAX,
+ "none".into(),
+ 0,
+ i64::MAX,
+ "parquet".into(),
+ fields,
+ HashMap::from([
+ ("data-evolution.enabled".into(), "true".into()),
+ ("data-evolution.row-sidecar.enabled".into(),
"true".into()),
+ ]),
+ Some(0),
+ None,
+ None,
+ )
+ .unwrap();
+ let batch = RecordBatch::try_from_iter([(
+ "id",
+ Arc::new(Int32Array::from(vec![1])) as arrow_array::ArrayRef,
+ )])
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ let prepared = writer.prepare_commit().await.unwrap();
+ let prior_paths = prepared[0].collect_files(writer.bucket_dir());
+ assert_eq!(prior_paths.len(), 2);
+ let error = if failure == "create" {
+ provider.fail_create.store(true, Ordering::Relaxed);
+ let error = writer.write(&batch).await.unwrap_err();
+ provider.fail_create.store(false, Ordering::Relaxed);
+ error
+ } else {
+ writer.write(&batch).await.unwrap();
+ let sidecar = writer.current_row_sidecar.take().unwrap();
+ let mut sidecar = sidecar;
+ sidecar.writer = Box::new(FailingSidecar {
+ inner: sidecar.writer,
+ fail_write: failure == "write",
+ });
+ writer.current_row_sidecar = Some(sidecar);
+ if failure == "write" {
+ writer.write(&batch).await.unwrap_err()
+ } else {
+ writer.prepare_commit().await.unwrap_err()
+ }
+ };
+ assert!(error
+ .to_string()
+ .contains(&format!("sidecar {failure} failure")));
+ writer.abort().await;
+ let mut actual: Vec<_> = io
+ .list_status_recursive(writer.bucket_dir())
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|status| status.path)
+ .collect();
+ actual.sort();
+ let mut expected = prior_paths;
+ expected.sort();
+ assert_eq!(actual, expected, "{failure}");
+ }
+ }
+
#[tokio::test]
async fn grouped_prepare_failure_removes_successful_and_failed_outputs() {
- for external in [false, true] {
+ for (external, sidecar) in [(false, false), (true, false), (false,
true), (true, true)] {
let file_io = FileIOBuilder::new("memory").build().unwrap();
let mut writers = Vec::new();
for first_row_id in [0, 1] {
@@ -829,6 +1040,7 @@ mod tests {
None,
)
.unwrap();
+ writer.row_sidecar_enabled = sidecar;
let batch = RecordBatch::try_from_iter([(
"id",
Arc::new(Int32Array::from(vec![1])) as
arrow_array::ArrayRef,
@@ -854,7 +1066,7 @@ mod tests {
.await
.unwrap()
.iter()
- .all(|entry| !entry.path.ends_with(".parquet")));
+ .all(|entry| !entry.path.ends_with(".parquet") &&
!entry.path.ends_with(".row")));
}
}
}
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index 901b7b0c..d02c4618 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -211,6 +211,13 @@ impl AppendDedicatedFormatFileWriter {
.with_path_factory(paths.clone())
.with_file_index(normal_index)
.with_target_file_row_num(target_file_row_num);
+ // A view-only schema also uses this adapter to translate references,
+ // but Java treats its physical writer as an ordinary inline writer.
+ let normal_writer = if !blob_writers.is_empty() ||
vector_writer.is_some() {
+ normal_writer.without_row_sidecar()
+ } else {
+ normal_writer
+ };
Ok(Self {
written_files: Vec::new(),
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 36e75d58..58134550 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -124,6 +124,7 @@ pub(crate) mod rest_env;
pub(crate) mod row_id_predicate;
mod row_kind_generator;
mod row_position_selection;
+mod row_sidecar;
mod scan_trace;
pub(crate) mod schema_manager;
pub(crate) mod snapshot_commit;
diff --git a/crates/paimon/src/table/row_sidecar.rs
b/crates/paimon/src/table/row_sidecar.rs
new file mode 100644
index 00000000..5cbdc953
--- /dev/null
+++ b/crates/paimon/src/table/row_sidecar.rs
@@ -0,0 +1,254 @@
+// 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.
+
+//! Aligned ROW files for normal Data Evolution data files, matching Java's
+//! RowSidecarAuxiliaryWriter and DataEvolutionSplitRead.readTarget.
+
+use super::blob_resolver::{resolve_descriptor_column, BlobReadLimiter};
+use crate::arrow::format::{create_format_writer_factory, with_write_resources,
FormatFileWriter};
+use crate::io::FileIO;
+use crate::resource::ResourceContext;
+use crate::spec::{CoreOptions, DataField, DataFileMeta};
+use crate::table::RowRange;
+use crate::Result;
+use arrow_array::{Array, LargeBinaryArray, RecordBatch};
+use arrow_schema::{Field, Schema, SchemaRef};
+use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+
+pub(super) struct RowSidecarWriter {
+ pub file_name: String,
+ pub writer: Box<dyn FormatFileWriter>,
+ io: FileIO,
+ descriptor_fields: HashSet<String>,
+ view_fields: HashSet<String>,
+}
+
+impl RowSidecarWriter {
+ pub async fn new(
+ io: &FileIO,
+ path: &str,
+ file_name: &str,
+ schema: SchemaRef,
+ fields: &[DataField],
+ options: &HashMap<String, String>,
+ resources: Option<&ResourceContext>,
+ ) -> Result<Self> {
+ // ROW stores logical values. Parquet's MAP / VARIANT storage layouts
+ // must not change the auxiliary file's schema or encoding.
+ let options = CoreOptions::new(options);
+ let factory = create_format_writer_factory(
+ "row",
+ schema,
+ options.file_compression_zstd_level(),
+ None,
+ Some(fields),
+ None,
+ None,
+ )?;
+ let writer = factory.create_writer(&io.new_output(path)?,
"zstd").await?;
+ Ok(Self {
+ file_name: format!("{file_name}.row"),
+ writer: with_write_resources(writer, resources),
+ io: io.clone(),
+ descriptor_fields: options.blob_descriptor_fields(),
+ view_fields: options.blob_view_fields(),
+ })
+ }
+
+ pub async fn write(&mut self, batch: &RecordBatch) -> Result<()> {
+ let mut descriptors = Vec::new();
+ for (index, field) in batch.schema().fields().iter().enumerate() {
+ if self.descriptor_fields.contains(field.name()) {
+ descriptors.push(index);
+ }
+ if self.view_fields.contains(field.name()) {
+ let values = blob_column(batch, index)?;
+ // Java BlobView.toData() rejects an unresolved view. The Arrow
+ // API supplies serialized references, without a resolved Blob.
+ if values.null_count() != values.len() {
+ return Err(crate::Error::DataInvalid {
+ message:
+ "ROW sidecars require resolved BLOB views;
BlobView is not resolved"
+ .into(),
+ source: None,
+ });
+ }
+ }
+ }
+ if descriptors.is_empty() {
+ return self.writer.write(batch).await;
+ }
+ let limiter = BlobReadLimiter::new();
+ // Like Java Blob.toData(), materialize one row at a time. A batch of
+ // small references must not retain all referenced payloads in memory.
+ for row in 0..batch.num_rows() {
+ let indices = arrow_array::UInt64Array::from(vec![row as u64]);
+ let data = arrow_select::take::take_record_batch(batch,
&indices).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!("Failed to select ROW sidecar BLOB row:
{error}"),
+ source: Some(Box::new(error)),
+ }
+ })?;
+ let mut columns = data.columns().to_vec();
+ for &index in &descriptors {
+ // ROW stores the actual payload; Parquet keeps the reference.
+ columns[index] = Arc::new(
+ resolve_descriptor_column(
+ blob_column(&data, index)?,
+ &self.io,
+ limiter.clone(),
+ )
+ .await?,
+ );
+ }
+ let data = RecordBatch::try_new(data.schema(),
columns).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!("Failed to build ROW sidecar BLOB row:
{error}"),
+ source: Some(Box::new(error)),
+ }
+ })?;
+ self.writer.write(&data).await?;
+ }
+ Ok(())
+ }
+}
+
+fn blob_column(batch: &RecordBatch, index: usize) -> Result<&LargeBinaryArray>
{
+ batch
+ .column(index)
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "ROW sidecar BLOB field '{}' requires LargeBinary",
+ batch.schema().field(index).name()
+ ),
+ source: None,
+ })
+}
+
+// Arrow represents both BlobData and BlobRef as LargeBinary. Preserve the
+// distinction only inside the Data Evolution pipeline; project_output removes
+// this marker before exposing batches. Raw bytes can themselves look exactly
+// like a descriptor, so magic-number detection cannot supply the distinction.
+const BLOB_DATA: &str = "paimon.row-sidecar.blob-data";
+
+pub(super) fn is_blob_data(field: &Field) -> bool {
+ field.metadata().contains_key(BLOB_DATA)
+}
+
+pub(super) fn mark_blob_data(schema: &SchemaRef, names: &HashSet<String>) ->
SchemaRef {
+ if names.is_empty() {
+ return schema.clone();
+ }
+ let fields: Vec<_> = schema
+ .fields()
+ .iter()
+ .map(|field| {
+ if !names.contains(field.name()) {
+ return field.clone();
+ }
+ let mut metadata = field.metadata().clone();
+ metadata.insert(BLOB_DATA.into(), "true".into());
+ Arc::new(field.as_ref().clone().with_metadata(metadata))
+ })
+ .collect();
+ Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone()))
+}
+
+pub(super) fn propagate_blob_data(schema: &SchemaRef, batch: &RecordBatch) ->
SchemaRef {
+ let names = batch
+ .schema()
+ .fields()
+ .iter()
+ .filter(|field| is_blob_data(field))
+ .map(|field| field.name().clone())
+ .collect();
+ mark_blob_data(schema, &names)
+}
+
+/// Exactly one ROW extra file is required, as in Java. Do not guess between
+/// multiple sidecars or enable one merely because deletion vectors prune rows.
+fn selected_sidecar<'a>(
+ file: &'a DataFileMeta,
+ local_ranges: Option<&[RowRange]>,
+ options: &CoreOptions<'_>,
+) -> Result<Option<&'a str>> {
+ if !options.data_evolution_enabled() {
+ return Ok(None);
+ }
+ let mut names = file
+ .extra_files
+ .iter()
+ .filter(|name| name.ends_with(".row"));
+ let Some(name) = names.next() else {
+ return Ok(None);
+ };
+ if names.next().is_some() {
+ return Ok(None);
+ }
+ let max_rows = options.data_evolution_row_sidecar_max_selected_rows()?;
+ let max_ratio = options.data_evolution_row_sidecar_max_selection_ratio()?;
+ let Some(ranges) = local_ranges else {
+ return Ok(None);
+ };
+ if file.row_count <= 0
+ || file.file_name.ends_with(".blob")
+ || file.file_name.contains(".vector.")
+ {
+ return Ok(None);
+ }
+ // The caller clips, sorts and merges the requested ranges into local
+ // positions before intersecting them with indexes and deletion vectors.
+ let selected: i64 = ranges.iter().map(RowRange::count).sum();
+ Ok((selected > 0
+ && selected < file.row_count
+ && selected <= max_rows
+ && selected as f64 / file.row_count as f64 <= max_ratio)
+ .then_some(name.as_str()))
+}
+
+pub(super) fn supports_read_type(read_type: &[DataField], blob_as_descriptor:
bool) -> bool {
+ // BlobData in ROW has no URI. A caller requesting descriptors must keep
+ // the primary file, rather than receive payload bytes in place of refs.
+ !blob_as_descriptor
+ || !read_type
+ .iter()
+ .any(|field| matches!(field.data_type(),
crate::spec::DataType::Blob(_)))
+}
+
+pub(super) async fn read_target(
+ io: &FileIO,
+ file: &DataFileMeta,
+ bucket_path: &str,
+ local_ranges: Option<&[RowRange]>,
+ options: &CoreOptions<'_>,
+) -> Result<(String, u64)> {
+ if let Some(name) = selected_sidecar(file, local_ranges, options)? {
+ let path = file.aligned_file_path(bucket_path, name);
+ match io.get_status(&path).await {
+ Ok(status) => return Ok((path, status.size)),
+ Err(error) => {
+ if !options.scan_ignore_lost_file() {
+ return Err(error);
+ }
+ }
+ }
+ }
+ Ok((file.data_file_path(bucket_path), file.file_size as u64))
+}
diff --git a/crates/paimon/tests/data_evolution_row_sidecar_test.rs
b/crates/paimon/tests/data_evolution_row_sidecar_test.rs
new file mode 100644
index 00000000..c8814e3b
--- /dev/null
+++ b/crates/paimon/tests/data_evolution_row_sidecar_test.rs
@@ -0,0 +1,815 @@
+// 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::{ArrayRef, Int32Array, Int64Array, RecordBatch, StringArray};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+use futures::TryStreamExt;
+use paimon::io::FileIO;
+use paimon::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
+use paimon::table::{CommitMessage, RowRange, Table};
+use std::collections::HashMap;
+use std::sync::Arc;
+
+#[tokio::test]
+async fn append_registers_aligned_row_sidecars_in_data_file_metadata() {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::VarChar(VarCharType::string_type()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("data-evolution.row-sidecar.enabled", "true")
+ .option("target-file-row-num", "2")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table("memory:/de_row_sidecar",
TableSchema::new(0, &schema));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ for ids in [vec![1, 2], vec![3]] {
+ let values: Vec<String> = ids.iter().map(|id|
format!("value-{id}")).collect();
+ writer
+ .write_arrow_batch(
+ &RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from(ids)) as
arrow_array::ArrayRef,
+ ),
+ (
+ "value",
+ Arc::new(StringArray::from(values)) as
arrow_array::ArrayRef,
+ ),
+ ])
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ }
+ let messages = writer.prepare_commit().await.unwrap();
+ let files: Vec<_> = messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .collect();
+ assert_eq!(files.len(), 2);
+ for file in &files {
+ assert_eq!(file.extra_files, vec![format!("{}.row", file.file_name)]);
+ for path in file.collect_files(&format!("{}/bucket-0",
table.location())) {
+ assert!(io.exists(&path).await.unwrap(), "{path}");
+ }
+ }
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .next_row_id(),
+ Some(3)
+ );
+}
+
+async fn table(options: &[(&str, &str)]) -> Table {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::VarChar(VarCharType::string_type()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("deletion-vectors.enabled", "true")
+ .option("data-evolution.row-sidecar.enabled", "true");
+ for (name, value) in options {
+ schema = schema.option(*name, *value);
+ }
+ let schema = TableSchema::new(0, &schema.build().unwrap());
+ let (io, table) = memory_table("memory:/row_sidecar", schema);
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ table
+}
+
+fn batch(start: i32, count: i32) -> RecordBatch {
+ RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from_iter_values(start..start + count)) as
ArrayRef,
+ ),
+ (
+ "value",
+ Arc::new(StringArray::from_iter_values(
+ (start..start + count).map(|id| format!("v{id}")),
+ )) as ArrayRef,
+ ),
+ ])
+ .unwrap()
+}
+
+async fn append(table: &Table, start: i32, count: i32) -> Vec<CommitMessage> {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&batch(start, count))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ // Closing a producer after prepare must retain both handed-off outputs.
+ writer.close().await;
+ builder.new_commit().commit(messages.clone()).await.unwrap();
+ messages
+}
+
+async fn read(table: &Table, ranges: Option<Vec<RowRange>>) ->
paimon::Result<Vec<RecordBatch>> {
+ let mut builder = table.new_read_builder();
+ builder.with_projection(&["id", "value", "_ROW_ID"])?;
+ if let Some(ranges) = ranges {
+ builder.with_row_ranges(ranges);
+ }
+ let plan = builder.new_scan().plan().await?;
+ builder
+ .new_read()?
+ .to_arrow(plan.splits())?
+ .try_collect()
+ .await
+}
+
+fn rows(batches: &[RecordBatch]) -> Vec<(i32, String, i64)> {
+ batches
+ .iter()
+ .flat_map(|batch| {
+ let ids = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let values = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let row_ids = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ (0..batch.num_rows())
+ .map(|row| (ids.value(row), values.value(row).into(),
row_ids.value(row)))
+ .collect::<Vec<_>>()
+ })
+ .collect()
+}
+
+async fn remove_primary(io: &FileIO, table: &Table, messages:
&[CommitMessage]) {
+ for file in messages.iter().flat_map(|message| &message.new_files) {
+ io.delete_file(&file.data_file_path(&format!("{}/bucket-0",
table.location())))
+ .await
+ .unwrap();
+ }
+}
+
+#[tokio::test]
+async fn
sparse_ranges_use_sidecar_with_nonzero_ids_external_paths_and_read_option_disabled()
{
+ for external in [false, true] {
+ let options = if external {
+ vec![("data-file.external-paths", "memory:/external%20sidecars")]
+ } else {
+ vec![]
+ };
+ let table = table(&options).await;
+ append(&table, -20, 20).await;
+ let messages = append(&table, 0, 100).await;
+ remove_primary(table.file_io(), &table, &messages).await;
+ let read_table = table.copy_with_options(HashMap::from([(
+ "data-evolution.row-sidecar.enabled".into(),
+ "false".into(),
+ )]));
+ let actual = read(
+ &read_table,
+ Some(vec![
+ RowRange::new(25, 26),
+ RowRange::new(26, 27),
+ RowRange::new(150, 200),
+ ]),
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ rows(&actual),
+ vec![
+ (5, "v5".into(), 25),
+ (6, "v6".into(), 26),
+ (7, "v7".into(), 27)
+ ]
+ );
+ }
+}
+
+#[tokio::test]
+async fn selection_thresholds_are_inclusive_but_full_reads_keep_primary() {
+ for (count, max_rows, max_ratio, expected_sidecar) in [
+ (5, "5", "0.05", true),
+ (6, "4096", "0.05", false),
+ (5, "4", "0.05", false),
+ (6, "6", "0.06", true),
+ (100, "4096", "1", false),
+ ] {
+ let table = table(&[
+ ("data-evolution.row-sidecar.max-selected-rows", max_rows),
+ ("data-evolution.row-sidecar.max-selection-ratio", max_ratio),
+ ])
+ .await;
+ let messages = append(&table, 0, 100).await;
+ remove_primary(table.file_io(), &table, &messages).await;
+ let result = read(&table, Some(vec![RowRange::new(0, count -
1)])).await;
+ if expected_sidecar {
+ assert_eq!(rows(&result.unwrap()).len(), count as usize);
+ } else {
+ assert!(
+ result.is_err(),
+ "count={count}, max rows={max_rows}, max ratio={max_ratio}"
+ );
+ }
+ assert!(read(&table, None).await.is_err());
+ }
+}
+
+#[tokio::test]
+async fn missing_sidecar_only_falls_back_when_ignore_lost_file_is_enabled() {
+ let table = table(&[]).await;
+ let messages = append(&table, 0, 100).await;
+ let file = &messages[0].new_files[0];
+ let sidecar = file.aligned_file_path(
+ &format!("{}/bucket-0", table.location()),
+ &file.extra_files[0],
+ );
+ table.file_io().delete_file(&sidecar).await.unwrap();
+ let strict = table.copy_with_options(HashMap::from([(
+ "scan.ignore-lost-files".into(),
+ "false".into(),
+ )]));
+ for reader_table in [&table, &strict] {
+ let error = read(reader_table, Some(vec![RowRange::new(5, 5)]))
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains(".row"), "{error}");
+ }
+ let tolerant = table.copy_with_options(HashMap::from([(
+ "scan.ignore-lost-files".into(),
+ "true".into(),
+ )]));
+ assert_eq!(
+ rows(
+ &read(&tolerant, Some(vec![RowRange::new(5, 5)]))
+ .await
+ .unwrap()
+ ),
+ vec![(5, "v5".into(), 5)]
+ );
+ // Whole-file reads never require the auxiliary file.
+ assert_eq!(rows(&read(&table, None).await.unwrap()).len(), 100);
+}
+
+#[tokio::test]
+async fn corrupt_sidecar_is_not_hidden_by_ignore_lost_file() {
+ let table = table(&[("scan.ignore-lost-files", "true")]).await;
+ let messages = append(&table, 0, 100).await;
+ let file = &messages[0].new_files[0];
+ let path = file.aligned_file_path(
+ &format!("{}/bucket-0", table.location()),
+ &file.extra_files[0],
+ );
+ table
+ .file_io()
+ .new_output(&path)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"corrupt"))
+ .await
+ .unwrap();
+ assert!(read(&table, Some(vec![RowRange::new(5, 5)])).await.is_err());
+}
+
+#[tokio::test]
+async fn
updated_columns_and_deletion_vectors_are_applied_when_reading_sidecars() {
+ for incremental in [false, true] {
+ let table = table(&[]).await;
+ let initial = append(&table, 0, 100).await;
+ let input = RecordBatch::try_from_iter([
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![5, 7])) as ArrayRef,
+ ),
+ (
+ "value",
+ Arc::new(StringArray::from(vec!["changed-5", "changed-7"])) as
ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let update = table.new_write_builder().new_update().unwrap();
+ let messages = if incremental {
+ update
+ .new_update_by_row_id()
+ .await
+ .unwrap()
+ .update_columns(vec![input], vec!["value".into()])
+ .await
+ .unwrap()
+ } else {
+ update
+ .update_by_arrow_with_row_id(vec![input])
+ .await
+ .unwrap()
+ };
+ assert!(messages
+ .iter()
+ .flat_map(|m| &m.new_files)
+ .all(|file| file.extra_files == vec![format!("{}.row",
file.file_name)]));
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages.clone())
+ .await
+ .unwrap();
+ let deletions = update.delete_by_row_id(vec![6]).await.unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(deletions)
+ .await
+ .unwrap();
+ remove_primary(table.file_io(), &table, &initial).await;
+ remove_primary(table.file_io(), &table, &messages).await;
+ assert_eq!(
+ rows(&read(&table, Some(vec![RowRange::new(5,
7)])).await.unwrap()),
+ vec![(5, "changed-5".into(), 5), (7, "changed-7".into(), 7)]
+ );
+ }
+}
+
+#[tokio::test]
+async fn
explicit_abort_deletes_sidecar_while_writer_close_keeps_prepared_files() {
+ let table = table(&[]).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch(0, 5)).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let paths = messages[0].new_files[0].collect_files(&format!("{}/bucket-0",
table.location()));
+ assert_eq!(paths.len(), 2);
+ writer.close().await;
+ for path in &paths {
+ assert!(table.file_io().exists(path).await.unwrap());
+ }
+ builder.new_commit().abort(&messages).await.unwrap();
+ for path in &paths {
+ assert!(!table.file_io().exists(path).await.unwrap());
+ }
+ // A producer close before prepare owns and removes all its current files.
+ writer.write_arrow_batch(&batch(5, 5)).await.unwrap();
+ writer.close().await;
+ assert!(table
+ .file_io()
+ .list_status_recursive(&format!("{}/bucket-0", table.location()))
+ .await
+ .unwrap()
+ .is_empty());
+}
+
+#[tokio::test]
+async fn file_index_and_limit_do_not_turn_full_reads_into_sidecar_reads() {
+ use paimon::spec::{Datum, PredicateBuilder};
+ let table = table(&[("file-index.bitmap.columns", "id")]).await;
+ let messages = append(&table, 0, 100).await;
+ for file in messages.iter().flat_map(|m| &m.new_files) {
+ let sidecar = file
+ .extra_files
+ .iter()
+ .find(|name| name.ends_with(".row"))
+ .unwrap();
+ table
+ .file_io()
+ .delete_file(
+ &file.aligned_file_path(&format!("{}/bucket-0",
table.location()), sidecar),
+ )
+ .await
+ .unwrap();
+ }
+ // A one-row bitmap selection must still decode the existing primary file.
+ for requested_ranges in [None, Some(vec![RowRange::new(0, 99)])] {
+ let mut builder = table.new_read_builder();
+ builder.with_filter(
+ PredicateBuilder::new(table.schema().fields())
+ .equal("id", Datum::Int(5))
+ .unwrap(),
+ );
+ if let Some(ranges) = requested_ranges {
+ builder.with_row_ranges(ranges);
+ }
+ let plan = builder.new_scan().plan().await.unwrap();
+ let actual: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(actual.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+ }
+ let mut builder = table.new_read_builder();
+ builder.with_limit(1);
+ let plan = builder.new_scan().plan().await.unwrap();
+ let actual: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(actual.iter().map(RecordBatch::num_rows).sum::<usize>(), 1);
+ let update = table.new_write_builder().new_update().unwrap();
+ let input = RecordBatch::try_from_iter([
+ ("_ROW_ID", Arc::new(Int64Array::from(vec![0])) as ArrayRef),
+ (
+ "value",
+ Arc::new(StringArray::from(vec!["changed"])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let messages = update
+ .update_by_arrow_with_row_id(vec![input])
+ .await
+ .unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages.clone())
+ .await
+ .unwrap();
+ for file in messages.iter().flat_map(|m| &m.new_files) {
+ let name = file
+ .extra_files
+ .iter()
+ .find(|name| name.ends_with(".row"))
+ .unwrap();
+ table
+ .file_io()
+ .delete_file(&file.aligned_file_path(&format!("{}/bucket-0",
table.location()), name))
+ .await
+ .unwrap();
+ }
+ let mut builder = table.new_read_builder();
+ builder.with_limit(1);
+ let plan = builder.new_scan().plan().await.unwrap();
+ let actual: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(actual[0].num_rows(), 1);
+ assert_eq!(
+ actual[0]
+ .column(1)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap()
+ .value(0),
+ "changed"
+ );
+}
+
+#[tokio::test]
+async fn inline_vector_sidecar_preserves_values_and_parent_nulls() {
+ use arrow_array::{
+ builder::{FixedSizeListBuilder, Float32Builder},
+ Array, FixedSizeListArray, Float32Array,
+ };
+ use paimon::spec::{FloatType, VectorType};
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "embedding",
+ DataType::Vector(VectorType::new(3,
DataType::Float(FloatType::new())).unwrap()),
+ )
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("data-evolution.row-sidecar.enabled", "true")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table("memory:/inline_vector_row",
TableSchema::new(0, &schema));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ let mut values = FixedSizeListBuilder::new(Float32Builder::new(),
3).with_field(Arc::new(
+ arrow_schema::Field::new("element", arrow_schema::DataType::Float32,
true),
+ ));
+ for id in 0..100 {
+ for dimension in 0..3 {
+ values.values().append_value((id * 3 + dimension) as f32);
+ }
+ values.append(id != 6);
+ }
+ let data = RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from_iter_values(0..100)) as ArrayRef,
+ ),
+ ("embedding", Arc::new(values.finish()) as ArrayRef),
+ ])
+ .unwrap();
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&data).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(messages[0].new_files[0].extra_files.len(), 1);
+ builder.new_commit().commit(messages.clone()).await.unwrap();
+ remove_primary(&io, &table, &messages).await;
+ let mut builder = table.new_read_builder();
+ builder.with_row_ranges(vec![RowRange::new(5, 6)]);
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let vectors = batches[0]
+ .column(1)
+ .as_any()
+ .downcast_ref::<FixedSizeListArray>()
+ .unwrap();
+ assert_eq!(vectors.len(), 2);
+ assert!(vectors.is_null(1));
+ let values = vectors.value(0);
+ assert_eq!(
+ values
+ .as_any()
+ .downcast_ref::<Float32Array>()
+ .unwrap()
+ .values()
+ .as_ref(),
+ &[15.0, 16.0, 17.0]
+ );
+}
+
+#[tokio::test]
+async fn inline_blob_views_generate_normal_sidecars() {
+ use arrow_array::LargeBinaryArray;
+ use paimon::catalog::Identifier;
+ use paimon::spec::{BlobType, BlobViewStruct};
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("data-evolution.row-sidecar.enabled", "true")
+ .option("blob-view-field", "payload")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table("memory:/blob_view_row",
TableSchema::new(0, &schema));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ let value = BlobViewStruct::new(Identifier::new("db", "source"), 1, 2)
+ .serialize()
+ .unwrap();
+ let data = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![1])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from_iter([None::<&[u8]>])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer.write_arrow_batch(&data).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let files: Vec<_> = messages.iter().flat_map(|m| &m.new_files).collect();
+ assert_eq!(files.len(), 1);
+ assert_eq!(
+ files[0].extra_files,
+ vec![format!("{}.row", files[0].file_name)]
+ );
+ let unresolved = RecordBatch::try_from_iter([
+ ("id", Arc::new(Int32Array::from(vec![2])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from_iter([Some(value.as_slice())])) as
ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let error = writer.write_arrow_batch(&unresolved).await.unwrap_err();
+ assert!(error.to_string().contains("BlobView is not resolved"));
+}
+
+#[test]
+fn invalid_row_sidecar_selection_options_are_rejected() {
+ use paimon::spec::CoreOptions;
+ let options = HashMap::from([(
+ "data-evolution.row-sidecar.enabled".into(),
+ "invalid".into(),
+ )]);
+ assert!(CoreOptions::new(&options)
+ .data_evolution_row_sidecar_enabled()
+ .is_err());
+ for value in ["0", "-1", "invalid"] {
+ let options = HashMap::from([(
+ "data-evolution.row-sidecar.max-selected-rows".into(),
+ value.into(),
+ )]);
+ assert!(CoreOptions::new(&options)
+ .data_evolution_row_sidecar_max_selected_rows()
+ .is_err());
+ }
+ for value in ["0", "-0.1", "1.1", "invalid"] {
+ let options = HashMap::from([(
+ "data-evolution.row-sidecar.max-selection-ratio".into(),
+ value.into(),
+ )]);
+ assert!(CoreOptions::new(&options)
+ .data_evolution_row_sidecar_max_selection_ratio()
+ .is_err());
+ }
+}
+
+#[tokio::test]
+async fn automatic_row_block_flush_releases_memory_reservations() {
+ use paimon::resource::ResourceContext;
+ let table = table(&[("write.parquet-buffer-size", "1b")]).await;
+ let resources = ResourceContext::builder()
+ .memory_limit(512 * 1024)
+ .build()
+ .unwrap();
+ let builder = table.new_write_builder().with_resources(resources.clone());
+ let mut writer = builder.new_write().unwrap();
+ for index in 0..100 {
+ writer
+ .write_arrow_batch(&batch(index * 1000, 1000))
+ .await
+ .unwrap();
+ assert!(resources.metrics().reserved_memory_bytes < 256 * 1024);
+ }
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(resources.metrics().reserved_memory_bytes, 0);
+ assert_eq!(messages[0].new_files[0].row_count, 100000);
+}
+
+#[tokio::test]
+async fn
inline_blob_sidecars_materialize_references_and_keep_raw_payloads_after_merging()
{
+ use arrow_array::{Array, LargeBinaryArray};
+ use paimon::spec::{BlobDescriptor, BlobType};
+ for option in ["blob-descriptor-field", "blob.stored-descriptor-fields"] {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(BlobType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("data-evolution.row-sidecar.enabled", "true")
+ .option(option, "payload")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table("memory:/inline_blob_sidecar",
TableSchema::new(0, &schema));
+ setup_dirs(&io, table.location()).await;
+ persist_table_schema(&io, table.location(), table.schema()).await;
+ // The actual payload itself is a valid descriptor. Format provenance,
+ // rather than inspecting magic bytes, must distinguish BlobData.
+ let payload = BlobDescriptor::new("memory:/must-not-read".into(), 0,
1).serialize();
+ let source = [
+ b"prefix".as_slice(),
+ payload.as_slice(),
+ b"suffix".as_slice(),
+ ]
+ .concat();
+ io.new_output("memory:/source")
+ .unwrap()
+ .write(bytes::Bytes::from(source))
+ .await
+ .unwrap();
+ let reference =
+ BlobDescriptor::new("memory:/source".into(), 6, payload.len() as
i64).serialize();
+ let input = RecordBatch::try_from_iter([
+ (
+ "id",
+ Arc::new(Int32Array::from_iter_values(0..100)) as ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(LargeBinaryArray::from_iter(
+ (0..100).map(|id| (id !=
6).then_some(reference.as_slice())),
+ )) as ArrayRef,
+ ),
+ (
+ "value",
+ Arc::new(Int32Array::from_iter_values(0..100)) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ 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();
+ builder.new_commit().commit(messages.clone()).await.unwrap();
+ let descriptor_table = table.copy_with_options(HashMap::from([(
+ "blob-as-descriptor".into(),
+ "true".into(),
+ )]));
+ for ranges in [None, Some(vec![RowRange::new(5, 5)])] {
+ let mut read = descriptor_table.new_read_builder();
+ read.with_projection(&["payload"]).unwrap();
+ if let Some(ranges) = ranges {
+ read.with_row_ranges(ranges);
+ }
+ let plan = read.new_scan().plan().await.unwrap();
+ let actual: Vec<RecordBatch> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let blobs = actual[0]
+ .column(0)
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap();
+ assert_eq!(blobs.value(0), reference);
+ }
+ let update = table.new_write_builder().new_update().unwrap();
+ let changed = RecordBatch::try_from_iter([
+ ("_ROW_ID", Arc::new(Int64Array::from(vec![5])) as ArrayRef),
+ ("value", Arc::new(Int32Array::from(vec![999])) as ArrayRef),
+ ])
+ .unwrap();
+ let updates = update
+ .update_by_arrow_with_row_id(vec![changed])
+ .await
+ .unwrap();
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(updates.clone())
+ .await
+ .unwrap();
+ io.delete_file("memory:/source").await.unwrap();
+ remove_primary(&io, &table, &messages).await;
+ remove_primary(&io, &table, &updates).await;
+ for with_row_id in [false, true] {
+ let mut read = table.new_read_builder();
+ read.with_projection(if with_row_id {
+ &["id", "payload", "value", "_ROW_ID"]
+ } else {
+ &["id", "payload", "value"]
+ })
+ .unwrap();
+ read.with_row_ranges(vec![RowRange::new(5, 6)]);
+ let plan = read.new_scan().plan().await.unwrap();
+ let actual: Vec<RecordBatch> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ assert_eq!(actual.len(), 1);
+ let blobs = actual[0]
+ .column(1)
+ .as_any()
+ .downcast_ref::<LargeBinaryArray>()
+ .unwrap();
+ assert_eq!(blobs.value(0), payload);
+ assert!(blobs.is_null(1));
+ let values = actual[0]
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ assert_eq!(values.values().as_ref(), &[999, 6]);
+ assert!(actual[0]
+ .schema()
+ .field(1)
+ .metadata()
+ .keys()
+ .all(|key| !key.contains("blob-data")));
+ }
+ }
+}