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 baac87ca fix(core): align evolution stats and aggregation flush with
Java (#839)
baac87ca is described below
commit baac87caa90f600757144e88c00f046f6c21a775
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 15 16:08:25 2026 +0800
fix(core): align evolution stats and aggregation flush with Java (#839)
---
crates/paimon/src/table/kv_file_reader.rs | 15 +-
crates/paimon/src/table/kv_file_writer.rs | 384 +++++++++++++++++++++++++++---
crates/paimon/src/table/sort_merge.rs | 48 ++--
crates/paimon/src/table/stats_filter.rs | 193 ++++++++++++---
crates/paimon/src/table/table_scan.rs | 323 +++++++++++++++++++++++--
crates/paimon/src/table/table_write.rs | 1 +
docs/src/sql.md | 4 +-
7 files changed, 866 insertions(+), 102 deletions(-)
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index 3b0a31ce..f3adf84f 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -2270,10 +2270,9 @@ mod tests {
assert!(!names.contains(&"banana".to_string()));
}
- /// Aggregation (sum): inputs 10 + 20 merge to 30. `value = 30` must match
- /// the merged row (a pre-merge filter would drop both inputs);
- /// `value = 10` must match nothing (a pre-merge filter would keep the
- /// 10-input and leak it).
+ /// Each flush aggregates its duplicate keys first (30 and 9); the read
+ /// merges both files to 39. `value = 39` must match the merged row, while
+ /// `value = 10` must not leak an input row.
#[tokio::test]
async fn kv_read_aggregation_filters_on_merged_value() {
let file_io = test_file_io();
@@ -2288,17 +2287,17 @@ mod tests {
],
);
- write_commit(&table, &int_batch(vec![1], vec![Some(10)])).await;
- write_commit(&table, &int_batch(vec![1], vec![Some(20)])).await;
+ write_commit(&table, &int_batch(vec![1, 1], vec![Some(10),
Some(20)])).await;
+ write_commit(&table, &int_batch(vec![1, 1], vec![Some(4),
Some(5)])).await;
let fields = table.schema().fields().to_vec();
let match_merged = PredicateBuilder::new(&fields)
- .equal("value", Datum::Int(30))
+ .equal("value", Datum::Int(39))
.unwrap();
let batches = read_rows(&table, None, Some(match_merged)).await;
assert_eq!(int_column(&batches, "id"), vec![1]);
- assert_eq!(int_column(&batches, "value"), vec![30]);
+ assert_eq!(int_column(&batches, "value"), vec![39]);
let match_input = PredicateBuilder::new(&fields)
.equal("value", Datum::Int(10))
diff --git a/crates/paimon/src/table/kv_file_writer.rs
b/crates/paimon/src/table/kv_file_writer.rs
index bf1cd9aa..514d7524 100644
--- a/crates/paimon/src/table/kv_file_writer.rs
+++ b/crates/paimon/src/table/kv_file_writer.rs
@@ -26,6 +26,7 @@
//!
//! Reference:
[org.apache.paimon.io.KeyValueDataFileWriterImpl](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/io/KeyValueDataFileWriterImpl.java)
+use crate::arrow::arrow_fields_to_paimon;
use crate::arrow::format::create_format_writer;
use crate::io::FileIO;
use crate::spec::stats::{compute_column_stats, BinaryTableStats};
@@ -35,6 +36,7 @@ use crate::spec::{
SEQUENCE_NUMBER_FIELD_NAME, VALUE_KIND_FIELD_NAME,
};
use crate::table::prepared_files::PreparedFiles;
+use crate::table::sort_merge::{AggregateMergeFunction, BufferedBatch,
MergeRow};
use crate::Result;
use arrow_array::{Array, BooleanArray, Int64Array, Int8Array, RecordBatch,
UInt32Array};
use arrow_ord::sort::{lexsort_to_indices, SortColumn, SortOptions};
@@ -79,7 +81,9 @@ pub(crate) struct KeyValueWriteConfig {
pub changelog_file_prefix: String,
pub changelog_file_compression: String,
pub changelog_file_format: String,
- /// Primary key column indices in the user schema.
+ /// Full primary keys, including partition columns which must not be
aggregated.
+ pub primary_keys: Vec<String>,
+ /// Trimmed primary key column indices in the user schema.
pub primary_key_indices: Vec<usize>,
/// Paimon DataTypes for each primary key column (same order as
primary_key_indices).
pub primary_key_types: Vec<DataType>,
@@ -255,7 +259,7 @@ impl KeyValueFileWriter {
// Deduplicate → keep last row per key group (highest seq)
// FirstRow → keep first row per key group (lowest seq)
// PartialUpdate → per column, keep the latest non-null value
- // Aggregation → keep all rows for read-side field-wise merge
+ // Aggregation → apply per-field aggregators in sequence order
let (data_batch, data_seq, data_indices) = match
self.config.merge_engine {
MergeEngine::PartialUpdate => {
let (merged, merged_seq) =
@@ -264,7 +268,14 @@ impl KeyValueFileWriter {
UInt32Array::from_iter_values(0..u32::try_from(merged.num_rows()).unwrap());
(merged, merged_seq, identity)
}
- MergeEngine::Deduplicate | MergeEngine::FirstRow |
MergeEngine::Aggregation => {
+ MergeEngine::Aggregation => {
+ let (merged, merged_seq) =
+ self.merge_aggregation_rows(&combined, seq_array.as_ref(),
&sorted_indices)?;
+ let identity =
+
UInt32Array::from_iter_values(0..u32::try_from(merged.num_rows()).unwrap());
+ (merged, merged_seq, identity)
+ }
+ MergeEngine::Deduplicate | MergeEngine::FirstRow => {
let selected = self.select_flush_indices(&combined,
&sorted_indices)?;
(
combined.clone(),
@@ -281,6 +292,16 @@ impl KeyValueFileWriter {
None
};
+ // Java derives file sequence bounds from emitted rows; allocation
still
+ // advances over all buffered inputs, including rows folded away.
+ let output_sequences =
data_seq.as_any().downcast_ref::<Int64Array>().unwrap();
+ let (min_output_seq, max_output_seq) = data_indices
+ .values()
+ .iter()
+ .map(|&idx| output_sequences.value(idx as usize))
+ .fold((i64::MAX, i64::MIN), |(min, max), seq| {
+ (min.min(seq), max.max(seq))
+ });
let data_file = self
.write_indexed_file(
&data_batch,
@@ -291,8 +312,8 @@ impl KeyValueFileWriter {
file_ordinal: self.written_files.len(),
file_format: &self.config.file_format,
file_compression: &self.config.file_compression,
- min_sequence_number: start_seq,
- max_sequence_number: end_seq,
+ min_sequence_number: min_output_seq,
+ max_sequence_number: max_output_seq,
delete_row_count: data_delete_row_count,
},
)
@@ -587,14 +608,108 @@ impl KeyValueFileWriter {
MergeEngine::PartialUpdate => {
unreachable!("partial-update merges rows at flush via
merge_partial_update_rows")
}
- // Aggregation keeps every row on flush and performs the per-field
- // merge on the read side.
- MergeEngine::Aggregation => Ok((0..sorted_indices.len())
- .map(|idx| sorted_indices.value(idx))
- .collect()),
+ MergeEngine::Aggregation => {
+ unreachable!("aggregation merges rows at flush via
merge_aggregation_rows")
+ }
}
}
+ fn merge_aggregation_rows(
+ &self,
+ batch: &RecordBatch,
+ seq_array: &dyn Array,
+ sorted_indices: &UInt32Array,
+ ) -> Result<(RecordBatch, Arc<dyn Array>)> {
+ let schema = batch.schema();
+ let value_kind_idx = schema
+ .fields()
+ .iter()
+ .position(|field| field.name() == VALUE_KIND_FIELD_NAME);
+ let value_kinds = value_kind_idx
+ .map(|idx| {
+ batch
+ .column(idx)
+ .as_any()
+ .downcast_ref::<Int8Array>()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "_VALUE_KIND column must be Int8".into(),
+ source: None,
+ })
+ })
+ .transpose()?;
+ let output_indices: Vec<_> = (0..batch.num_columns())
+ .filter(|idx| Some(*idx) != value_kind_idx)
+ .collect();
+ let output_fields: Vec<_> = output_indices
+ .iter()
+ .map(|&idx| schema.field(idx).clone())
+ .collect();
+ let output_schema = Arc::new(ArrowSchema::new(output_fields.clone()));
+ let sequence_fields: Vec<_> = self
+ .config
+ .sequence_field_indices
+ .iter()
+ .map(|&idx| schema.field(idx).name().clone())
+ .collect();
+ let merge = AggregateMergeFunction::new(
+ &self.config.table_options,
+ &self.config.table_name,
+ &arrow_fields_to_paimon(&output_fields)?,
+ &self.config.primary_keys,
+ &sequence_fields,
+ )?;
+ let rows: Vec<_> = sorted_indices
+ .values()
+ .iter()
+ .map(|&idx| MergeRow {
+ batch_idx: 0,
+ row_idx: idx as usize,
+ // Ordering is already established by the write buffer's Arrow
sort.
+ sequence_number: 0,
+ user_sequences: Vec::new(),
+ value_kind: value_kinds
+ .filter(|kinds| kinds.is_valid(idx as usize))
+ .map_or(0, |kinds| kinds.value(idx as usize)),
+ })
+ .collect();
+ let key_rows = self.convert_key_rows(batch)?;
+ let buffers = [BufferedBatch::Source(batch.clone())];
+ let mut merged = Vec::new();
+ let mut last_indices = Vec::new();
+ let mut start = 0;
+ while start < rows.len() {
+ let mut end = start + 1;
+ while end < rows.len()
+ && key_rows.row(rows[end].row_idx) ==
key_rows.row(rows[start].row_idx)
+ {
+ end += 1;
+ }
+ let group = rows[start..end].iter().collect::<Vec<_>>();
+ merged.push(merge.merge_ordered(&group, &buffers, &output_indices,
&output_schema)?);
+ // Java retains the last row in sequence-field order, which need
not
+ // have the largest arrival sequence number.
+ last_indices.push(sorted_indices.value(end - 1));
+ start = end;
+ }
+ let arrow_error = |e: arrow_schema::ArrowError|
crate::Error::DataInvalid {
+ message: format!("Failed to build merged aggregation batch: {e}"),
+ source: Some(Box::new(e)),
+ };
+ let merged =
+ arrow_select::concat::concat_batches(&output_schema,
&merged).map_err(arrow_error)?;
+ let merged = if let Some(idx) = value_kind_idx {
+ let mut columns = merged.columns().to_vec();
+ columns.insert(idx, Arc::new(Int8Array::from(vec![0;
merged.num_rows()])));
+ RecordBatch::try_new(schema, columns).map_err(arrow_error)?
+ } else {
+ merged
+ };
+ let merged_seq =
+ arrow_select::take::take(seq_array,
&UInt32Array::from(last_indices), None)
+ .map_err(arrow_error)?;
+ Ok((merged, merged_seq))
+ }
+
/// Merge same-key rows at flush for the partial-update engine, mirroring
/// Java `MergeTreeWriter#flushWriteBuffer` (the write buffer applies the
/// merge function before any file is written) with the same semantics as
@@ -836,7 +951,7 @@ mod tests {
use super::*;
use crate::io::FileIOBuilder;
use crate::spec::IntType;
- use arrow_array::{Int32Array, UInt32Array};
+ use arrow_array::{Int32Array, RecordBatchReader, StringArray, UInt32Array};
use std::collections::HashMap;
fn test_write_config(merge_engine: MergeEngine) -> KeyValueWriteConfig {
@@ -866,6 +981,7 @@ mod tests {
changelog_file_prefix: "changelog-".to_string(),
changelog_file_compression: "none".to_string(),
changelog_file_format: "parquet".to_string(),
+ primary_keys: vec!["id".into()],
primary_key_indices: vec![0],
primary_key_types: vec![DataType::Int(IntType::new())],
sequence_field_indices: vec![1],
@@ -1349,33 +1465,247 @@ mod tests {
));
}
- #[test]
- fn test_select_flush_indices_keeps_all_rows_for_aggregation_engine() {
+ #[tokio::test]
+ async fn test_flush_aggregation_merges_same_key_across_buffered_batches() {
let schema = Arc::new(ArrowSchema::new(vec![
- Arc::new(ArrowField::new("id", ArrowDataType::Int32, false)),
- Arc::new(ArrowField::new("seq", ArrowDataType::Int64, false)),
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("value", ArrowDataType::Int32, true),
+ ]));
+ let mut config = test_write_config(MergeEngine::Aggregation);
+ config.sequence_field_indices.clear();
+ config.write_buffer_size = i64::MAX;
+ config
+ .table_options
+ .insert("fields.value.aggregate-function".into(), "sum".into());
+ let io = FileIOBuilder::new("memory").build().unwrap();
+ let mut writer = KeyValueFileWriter::new(io.clone(), config,
7).unwrap();
+ for value in [10, 20] {
+ let batch = RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1])),
+ Arc::new(Int32Array::from(vec![value])),
+ ],
+ )
+ .unwrap();
+ writer.write(&batch).await.unwrap();
+ }
+ let prepared = writer.prepare_commit().await.unwrap();
+ assert_eq!(prepared.data_files.len(), 1);
+ let file = &prepared.data_files[0];
+ assert_eq!(file.row_count, 1);
+ assert_eq!((file.min_sequence_number, file.max_sequence_number), (8,
8));
+ let physical = read_kv_file(&io, file).await;
+ assert_eq!(physical.num_rows(), 1);
+ assert_eq!(
+ physical
+ .column_by_name("value")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values(),
+ &[30]
+ );
+ }
+
+ #[tokio::test]
+ async fn
test_flush_aggregation_sequence_partition_keys_and_input_changelog() {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("p", ArrowDataType::Int32, false),
+ ArrowField::new("seq", ArrowDataType::Int64, true),
+ ArrowField::new(VALUE_KIND_FIELD_NAME, ArrowDataType::Int8, false),
+ ArrowField::new("amount", ArrowDataType::Int32, true),
+ ArrowField::new("label", ArrowDataType::Utf8, true),
+ ArrowField::new("note", ArrowDataType::Utf8, true),
]));
let batch = RecordBatch::try_new(
schema,
vec![
- Arc::new(Int32Array::from(vec![1, 1])) as Arc<dyn
arrow_array::Array>,
- Arc::new(Int64Array::from(vec![10, 20])) as Arc<dyn
arrow_array::Array>,
+ Arc::new(Int32Array::from(vec![2, 1, 1, 1, 2])),
+ Arc::new(Int32Array::from(vec![7; 5])),
+ Arc::new(Int64Array::from(vec![
+ Some(30),
+ Some(20),
+ Some(10),
+ Some(20),
+ None,
+ ])),
+ Arc::new(Int8Array::from(vec![0, 2, 0, 2, 0])),
+ Arc::new(Int32Array::from(vec![None, Some(10), Some(20), None,
None])),
+ Arc::new(StringArray::from(vec![
+ "two", "later", "early", "tie", "low",
+ ])),
+ Arc::new(StringArray::from(vec![
+ None,
+ Some("kept"),
+ None,
+ None,
+ None,
+ ])),
],
)
.unwrap();
- let sorted_indices = UInt32Array::from(vec![0, 1]);
- let writer = KeyValueFileWriter::new(
- FileIOBuilder::new("memory").build().unwrap(),
- test_write_config(MergeEngine::Aggregation),
- 0,
- )
- .unwrap();
+ let mut config = test_write_config(MergeEngine::Aggregation);
+ // Only id participates in within-partition grouping, but p is also a
+ // primary key and must not be summed by the default aggregator.
+ config.primary_keys = vec!["id".into(), "p".into()];
+ config.sequence_field_indices = vec![2];
+ config.write_buffer_size = i64::MAX;
+ config.input_changelog = true;
+ for (key, value) in [
+ ("fields.default-aggregate-function", "sum"),
+ ("fields.label.aggregate-function", "listagg"),
+ ("fields.note.aggregate-function", "last_non_null_value"),
+ ] {
+ config.table_options.insert(key.into(), value.into());
+ }
+ let io = FileIOBuilder::new("memory").build().unwrap();
+ let mut writer = KeyValueFileWriter::new(io.clone(), config,
7).unwrap();
+ writer.write(&batch.slice(0, 2)).await.unwrap();
+ writer.write(&batch.slice(2, 3)).await.unwrap();
+ let prepared = writer.prepare_commit().await.unwrap();
+ let file = &prepared.data_files[0];
+ assert_eq!(file.row_count, 2);
+ assert_eq!(
+ (file.min_sequence_number, file.max_sequence_number),
+ (7, 10)
+ );
+ assert_eq!(file.delete_row_count, Some(0));
+ let physical = read_kv_file(&io, file).await;
+ let i32_values = |name| {
+ physical
+ .column_by_name(name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>()
+ };
+ let i64_values = |name| {
+ physical
+ .column_by_name(name)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>()
+ };
+ assert_eq!(i32_values("id"), vec![Some(1), Some(2)]);
+ assert_eq!(i32_values("p"), vec![Some(7), Some(7)]);
+ assert_eq!(i32_values("amount"), vec![Some(30), None]);
+ assert_eq!(i64_values("seq"), vec![Some(20), Some(30)]);
+ assert_eq!(
+ i64_values(SEQUENCE_NUMBER_FIELD_NAME),
+ vec![Some(10), Some(7)]
+ );
+ let labels = physical
+ .column_by_name("label")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ assert_eq!(
+ labels.iter().collect::<Vec<_>>(),
+ vec![Some("early,later,tie"), Some("low,two")]
+ );
+ let notes = physical
+ .column_by_name("note")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ assert_eq!(notes.iter().collect::<Vec<_>>(), vec![Some("kept"), None]);
+ assert_eq!(
+ physical
+ .column_by_name(VALUE_KIND_FIELD_NAME)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int8Array>()
+ .unwrap()
+ .values(),
+ &[0, 0]
+ );
+ let changelog = &prepared.changelog_files[0];
+ assert_eq!(changelog.row_count, 5);
+ let changelog = read_kv_file(&io, changelog).await;
+ assert_eq!(
+ changelog
+ .column_by_name("amount")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .iter()
+ .collect::<Vec<_>>(),
+ vec![Some(20), Some(10), None, None, None]
+ );
+ assert_eq!(
+ changelog
+ .column_by_name(VALUE_KIND_FIELD_NAME)
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int8Array>()
+ .unwrap()
+ .values(),
+ &[0, 2, 2, 0, 0]
+ );
+ // Sequence allocation advances over all consumed input, not output
rows.
+ writer.write(&batch.slice(1, 1)).await.unwrap();
+ let next = writer.prepare_commit().await.unwrap();
+ assert_eq!(
+ (
+ next.data_files[0].min_sequence_number,
+ next.data_files[0].max_sequence_number
+ ),
+ (12, 12)
+ );
+ }
- let selected = writer
- .select_flush_indices(&batch, &sorted_indices)
+ #[tokio::test]
+ async fn test_flush_aggregation_rejects_retract_before_writing_files() {
+ for kind in [1, 3] {
+ let schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("seq", ArrowDataType::Int64, false),
+ ArrowField::new(VALUE_KIND_FIELD_NAME, ArrowDataType::Int8,
false),
+ ]));
+ let batch = RecordBatch::try_new(
+ schema,
+ vec![
+ Arc::new(Int32Array::from(vec![1, 2])),
+ Arc::new(Int64Array::from(vec![10, 20])),
+ Arc::new(Int8Array::from(vec![0, kind])),
+ ],
+ )
+ .unwrap();
+ let mut writer = KeyValueFileWriter::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ test_write_config(MergeEngine::Aggregation),
+ 0,
+ )
.unwrap();
+ writer.write(&batch).await.unwrap();
+ let err = writer.prepare_commit().await.err().unwrap();
+ assert!(
+ matches!(err, crate::Error::Unsupported { message } if
message.contains("DELETE or UPDATE_BEFORE"))
+ );
+ assert!(writer.written_files.is_empty());
+ assert!(writer.written_changelog_files.is_empty());
+ }
+ }
- assert_eq!(selected, vec![0, 1]);
+ async fn read_kv_file(io: &FileIO, file: &DataFileMeta) -> RecordBatch {
+ let path = format!("memory:/kv-test/bucket-0/{}", file.file_name);
+ let data = io.new_input(&path).unwrap().read().await.unwrap();
+ let reader =
+
parquet::arrow::arrow_reader::ParquetRecordBatchReader::try_new(data,
1024).unwrap();
+ let schema = reader.schema();
+ let batches = reader.map(|batch| batch.unwrap()).collect::<Vec<_>>();
+ arrow_select::concat::concat_batches(&schema, &batches).unwrap()
}
#[test]
diff --git a/crates/paimon/src/table/sort_merge.rs
b/crates/paimon/src/table/sort_merge.rs
index d1f2bdee..e63be64a 100644
--- a/crates/paimon/src/table/sort_merge.rs
+++ b/crates/paimon/src/table/sort_merge.rs
@@ -604,16 +604,17 @@ impl AggregateMergeFunction {
aggregators: Mutex::new(aggregators),
})
}
-}
-impl MergeFunction for AggregateMergeFunction {
- fn merge(
+ /// Aggregate a key group already ordered by user sequence and auto
sequence.
+ /// The write buffer uses its sorted order directly; the merge reader sorts
+ /// references from its input streams before sharing the same
implementation.
+ pub(super) fn merge_ordered(
&self,
- rows: &[MergeRow],
+ rows: &[&MergeRow],
batch_buffer: &[BufferedBatch],
source_output_col_indices: &[usize],
output_schema: &SchemaRef,
- ) -> crate::Result<MergeResult> {
+ ) -> crate::Result<RecordBatch> {
if rows.is_empty() {
return Err(Error::UnexpectedError {
message: "merge called with empty rows".to_string(),
@@ -631,15 +632,6 @@ impl MergeFunction for AggregateMergeFunction {
}
}
- // Sort row indices by user sequence (if configured), then system
- // sequence, so per-field aggregators see the canonical "ascending
- // sequence" order documented in their contracts.
- let mut ordered_row_indices: Vec<usize> = (0..rows.len()).collect();
- ordered_row_indices.sort_by(|&lhs_idx, &rhs_idx| {
- compare_sequence_order(&rows[lhs_idx], &rows[rhs_idx])
- .then_with(|| lhs_idx.cmp(&rhs_idx))
- });
-
let mut aggregators = self
.aggregators
.lock()
@@ -653,8 +645,7 @@ impl MergeFunction for AggregateMergeFunction {
}
}
- for &row_idx in &ordered_row_indices {
- let row = &rows[row_idx];
+ for row in rows {
for (col_idx, slot) in aggregators.iter_mut().enumerate() {
if let Some(agg) = slot.as_mut() {
let source_array = batch_buffer[row.batch_idx]
@@ -667,7 +658,7 @@ impl MergeFunction for AggregateMergeFunction {
// Use the last sorted row to source primary-key column values: every
// row in the group shares the same PK by construction, so any row
// works; picking the last one keeps the slice cheap to compute.
- let pk_source = &rows[*ordered_row_indices.last().unwrap()];
+ let pk_source = rows.last().unwrap();
let output_columns: Vec<ArrayRef> = aggregators
.iter()
@@ -716,7 +707,28 @@ impl MergeFunction for AggregateMergeFunction {
}
})?;
- Ok(MergeResult::MaterializedRow(batch))
+ Ok(batch)
+ }
+}
+
+impl MergeFunction for AggregateMergeFunction {
+ fn merge(
+ &self,
+ rows: &[MergeRow],
+ batch_buffer: &[BufferedBatch],
+ source_output_col_indices: &[usize],
+ output_schema: &SchemaRef,
+ ) -> crate::Result<MergeResult> {
+ let mut ordered: Vec<_> = rows.iter().collect();
+ // Stable sorting keeps input order for equal sequences.
+ ordered.sort_by(|lhs, rhs| compare_sequence_order(lhs, rhs));
+ self.merge_ordered(
+ &ordered,
+ batch_buffer,
+ source_output_col_indices,
+ output_schema,
+ )
+ .map(MergeResult::MaterializedRow)
}
}
diff --git a/crates/paimon/src/table/stats_filter.rs
b/crates/paimon/src/table/stats_filter.rs
index 010a2344..8c778b2b 100644
--- a/crates/paimon/src/table/stats_filter.rs
+++ b/crates/paimon/src/table/stats_filter.rs
@@ -355,14 +355,66 @@ fn extract_stats_datum(row: &BinaryRow, index: usize,
data_type: &DataType) -> O
///
/// In data evolution mode, a logical row can be spread across multiple files
with
/// different column sets. After `group_by_overlapping_row_id`, each group
contains
-/// files covering the same row ID range. Stats for each field come from the
file
-/// with the highest `max_sequence_number` that actually contains that field.
+/// files with overlapping row ID ranges. A field can use stats only from a
+/// unique latest provider covering the entire group, interpreted by field ID
+/// in that provider's schema.
///
/// Reference:
[DataEvolutionFileStoreScan.evolutionStats](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionFileStoreScan.java)
+pub(super) async fn data_evolution_group_matches_predicates_for_table(
+ table: &Table,
+ group: &[DataFileMeta],
+ predicates: &[Predicate],
+ schema_cache: &mut HashMap<i64, Option<Arc<ResolvedStatsSchema>>>,
+) -> bool {
+ if has_always_false(predicates, &[]) {
+ return false;
+ }
+ if predicates
+ .iter()
+ .all(|p| matches!(p, Predicate::AlwaysTrue))
+ {
+ return true;
+ }
+ let mut schemas = Vec::with_capacity(group.len());
+ for file in group {
+ let Some(schema) = resolve_stats_schema(table, file.schema_id,
schema_cache).await else {
+ // Unknown file schemas cannot establish either field absence or
stats layout.
+ return true;
+ };
+ schemas.push(schema);
+ }
+ data_evolution_group_matches_resolved_predicates(
+ group,
+ predicates,
+ table.schema().fields(),
+ &schemas,
+ )
+}
+
+#[cfg(test)]
pub(super) fn data_evolution_group_matches_predicates(
group: &[DataFileMeta],
predicates: &[Predicate],
table_fields: &[DataField],
+) -> bool {
+ let schema = Arc::new(ResolvedStatsSchema {
+ file_fields: table_fields.to_vec(),
+ field_mapping: identity_field_mapping(table_fields.len()),
+ key_fields: Vec::new(),
+ });
+ data_evolution_group_matches_resolved_predicates(
+ group,
+ predicates,
+ table_fields,
+ &vec![schema; group.len()],
+ )
+}
+
+fn data_evolution_group_matches_resolved_predicates(
+ group: &[DataFileMeta],
+ predicates: &[Predicate],
+ table_fields: &[DataField],
+ schemas: &[Arc<ResolvedStatsSchema>],
) -> bool {
if predicates.is_empty() || group.is_empty() {
return true;
@@ -381,42 +433,78 @@ pub(super) fn data_evolution_group_matches_predicates(
return true;
}
- // Sort files by max_sequence_number descending so the highest-seq file
wins per field.
- let mut sorted_files: Vec<&DataFileMeta> = group.iter().collect();
- sorted_files.sort_by_key(|f| std::cmp::Reverse(f.max_sequence_number));
-
- // For each table field, find which file (index in sorted_files) provides
it.
- // Use file_data_columns (based on write_cols) to determine which file
contains
- // the field, not file_stats_columns (based on value_stats_cols) which only
- // indicates stats coverage.
- let field_sources: Vec<Option<(usize, usize)>> = {
- let per_file_columns: Vec<Vec<&str>> = sorted_files
- .iter()
- .map(|file| file_data_columns(file, table_fields))
- .collect();
- table_fields
- .iter()
- .enumerate()
- .map(|(field_idx, field)| {
- for (file_idx, cols) in per_file_columns.iter().enumerate() {
- if cols.iter().any(|c| *c == field.name()) {
- return Some((file_idx, field_idx));
- }
- }
- None
- })
- .collect()
+ let ranges: Option<Vec<_>> = group
+ .iter()
+ .map(|file| {
+ let start = file.first_row_id?;
+ let end = start.checked_add(file.row_count)?;
+ (end > start).then_some((start, end))
+ })
+ .collect();
+ let Some(ranges) = ranges else {
+ return true;
+ };
+ let start = ranges.iter().map(|range| range.0).min().unwrap();
+ let end = ranges.iter().map(|range| range.1).max().unwrap();
+ let Some(row_count) = end.checked_sub(start) else {
+ return true;
};
- // Build per-file stats without arity validation — data evolution files
- // may have fewer columns than the current table schema.
- let file_stats: Vec<FileStatsRows> = sorted_files
+ // Resolve providers by field ID in each file's schema, then choose the
latest
+ // provider. Column names in write_cols/value_stats_cols belong to that
schema.
+ let mut sorted_files: Vec<_> = group.iter().zip(schemas).collect();
+ sorted_files.sort_by_key(|(file, _)|
std::cmp::Reverse(file.max_sequence_number));
+ let per_file_columns: Vec<_> = sorted_files
.iter()
- .map(|file| FileStatsRows::from_data_file(file, table_fields))
+ .map(|(file, schema)| file_data_columns(file, &schema.file_fields))
.collect();
+ let mut field_sources = vec![None; table_fields.len()];
+ let mut usable_provider = vec![false; table_fields.len()];
+ for field_idx in 0..table_fields.len() {
+ let mut providers =
+ sorted_files
+ .iter()
+ .enumerate()
+ .filter_map(|(file_idx, (file, schema))| {
+ let old_idx = schema.field_mapping[field_idx]?;
+ per_file_columns[file_idx]
+ .contains(&schema.file_fields[old_idx].name())
+ .then_some((file_idx, *file))
+ });
+ if let Some((file_idx, file)) = providers.next() {
+ field_sources[field_idx] = Some((file_idx, field_idx));
+ let tied = providers
+ .next()
+ .is_some_and(|(_, next)| next.max_sequence_number ==
file.max_sequence_number);
+ usable_provider[field_idx] =
+ !tied && file.first_row_id == Some(start) && file.row_count ==
row_count;
+ }
+ }
- // row_count is the max across the group (overlapping row ranges).
- let row_count = group.iter().map(|f| f.row_count).max().unwrap_or(0);
+ let file_stats: Vec<FileStatsRows> = sorted_files
+ .iter()
+ .map(|(file, schema)| {
+ let mut stats = FileStatsRows::from_data_file(file,
&schema.file_fields);
+ let mapping = table_fields
+ .iter()
+ .enumerate()
+ .map(|(idx, field)| {
+ let old_idx = schema.field_mapping[idx]?;
+ // Java ignores stats for changed types. Keep the field's
provider
+ // but mark its stats unknown, rather than treating it as
absent/null.
+ if !usable_provider[idx]
+ || schema.file_fields[old_idx].data_type() !=
field.data_type()
+ || !valid_evolution_stats(&stats, old_idx,
field.data_type(), row_count)
+ {
+ return None;
+ }
+ stats.stats_index(old_idx)
+ })
+ .collect();
+ stats.stats_col_mapping = Some(mapping);
+ stats
+ })
+ .collect();
predicates.iter().all(|predicate| {
data_evolution_predicate_may_match(
@@ -429,6 +517,45 @@ pub(super) fn data_evolution_group_matches_predicates(
})
}
+/// Java DataEvolutionFileStoreScan.isValidStats: inconsistent statistics must
+/// not become evidence that an entire row-id group can be dropped.
+fn valid_evolution_stats(
+ stats: &FileStatsRows,
+ index: usize,
+ data_type: &DataType,
+ row_count: i64,
+) -> bool {
+ let Some(stats_index) = stats.stats_index(index) else {
+ return false;
+ };
+ if [&stats.min_values, &stats.max_values].iter().any(|row| {
+ row.as_ref().is_none_or(|row| {
+ stats_index >= row.arity() as usize
+ || row.data().len() <
BinaryRow::cal_fix_part_size_in_bytes(row.arity()) as usize
+ })
+ }) {
+ return false;
+ }
+ let nulls = stats.null_count(index);
+ if nulls.is_some_and(|count| count < 0 || count > row_count) {
+ return false;
+ }
+ match (
+ stats.min_value(index, data_type),
+ stats.max_value(index, data_type),
+ ) {
+ (None, None) => true,
+ (Some(min), Some(max)) => {
+ nulls != Some(row_count)
+ && matches!(
+ min.partial_cmp(&max),
+ Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
+ )
+ }
+ _ => false,
+ }
+}
+
/// Resolve which columns a file actually contains (for field source
resolution).
/// Uses `write_cols` if present, otherwise assumes all table fields.
/// This is distinct from `file_stats_columns` which resolves stats coverage.
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index b48e6229..431c8407 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -28,7 +28,7 @@ use super::kv_file_reader::retain_primary_key_conjuncts;
use super::partition_filter::PartitionFilter;
use super::row_position_selection::RowPositionSelection;
use super::stats_filter::{
- data_evolution_group_matches_predicates,
data_file_matches_predicates_for_table,
+ data_evolution_group_matches_predicates_for_table,
data_file_matches_predicates_for_table,
data_file_matches_predicates_with_key_stats, group_by_overlapping_row_id,
FileStatsRows,
ResolvedStatsSchema,
};
@@ -2037,6 +2037,7 @@ impl<'a> PaimonTableScan<'a> {
// Group by (partition, bucket), decomposing entries to avoid cloning
partition.
let groups = group_data_files_by_partition_bucket(entries);
+ let mut stats_schema_cache = HashMap::new();
let snapshot_id = snapshot.id();
let base_path = table_path.trim_end_matches('/');
@@ -2126,16 +2127,19 @@ impl<'a> PaimonTableScan<'a> {
row_id_groups
} else {
let before = row_id_groups.len();
- let groups = row_id_groups
- .into_iter()
- .filter(|group| {
- data_evolution_group_matches_predicates(
- group,
- &self.data_predicates,
- self.table.schema().fields(),
- )
- })
- .collect::<Vec<_>>();
+ let mut groups = Vec::with_capacity(before);
+ for group in row_id_groups {
+ if data_evolution_group_matches_predicates_for_table(
+ self.table,
+ &group,
+ &self.data_predicates,
+ &mut stats_schema_cache,
+ )
+ .await
+ {
+ groups.push(group);
+ }
+ }
if let Some(trace) = trace.as_deref_mut() {
trace.data_evolution_groups_pruned_by_stats += before
- groups.len();
}
@@ -3602,6 +3606,291 @@ mod tests {
assert_eq!(file_names_from_files(&pruned), vec!["old-id.parquet"]);
}
+ #[tokio::test]
+ async fn test_data_evolution_stats_use_file_schema_after_evolution() {
+ let old_schema = TableSchema::new(
+ 0,
+ &PaimonSchema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("a", DataType::Int(IntType::new()))
+ .column("b", DataType::Int(IntType::new()))
+ .build()
+ .unwrap(),
+ );
+ let cases = [
+ (
+ "drop",
+ vec![SchemaChange::drop_column("a".into())],
+ "b",
+ Datum::Int(20),
+ ),
+ (
+ "reorder",
+ vec![SchemaChange::update_column_position(
+ ColumnMove::move_first("b".into()),
+ )],
+ "b",
+ Datum::Int(20),
+ ),
+ (
+ "rename",
+ vec![SchemaChange::rename_column("b".into(),
"renamed".into())],
+ "renamed",
+ Datum::Int(20),
+ ),
+ (
+ "widen",
+ vec![SchemaChange::update_column_type(
+ "a".into(),
+ DataType::Double(crate::spec::DoubleType::new()),
+ )],
+ "a",
+ Datum::Double(10.0),
+ ),
+ ];
+ for (name, changes, field, literal) in cases {
+ let schema = old_schema.apply_changes(changes).unwrap();
+ let table =
data_evolution_test_table(&format!("memory:/de_stats_{name}"), schema);
+ write_schema_file(&table, &old_schema).await;
+ for dense in [false, true] {
+ let mut file = make_evo_file("old.parquet", 10, 1, 1, Some(0));
+ let mut stats = BinaryRowBuilder::new(if dense { 2 } else { 3
});
+ if dense {
+ stats.write_int(0, 10);
+ stats.write_int(1, 20);
+ file.write_cols = Some(vec!["a".into(), "b".into()]);
+ file.value_stats_cols = Some(vec!["a".into(), "b".into()]);
+ } else {
+ stats.write_int(0, 1);
+ stats.write_int(1, 10);
+ stats.write_int(2, 20);
+ }
+ let stats = stats.build_serialized();
+ file.value_stats = BinaryTableStats::new(
+ stats.clone(),
+ stats,
+ vec![Some(0); if dense { 2 } else { 3 }],
+ );
+ let entries = vec![ManifestEntry::new(
+ FileKind::Add,
+ BinaryRowBuilder::new(0).build_serialized(),
+ 0,
+ -1,
+ file,
+ 0,
+ )];
+ let pb = PredicateBuilder::new(table.schema().fields());
+ for (predicate, expected) in [
+ (pb.equal(field, literal.clone()).unwrap(), 1),
+ // Type changes cannot reuse old stats; matching unchanged
+ // fields can still reject this file after rename/reorder.
+ (
+ pb.equal(
+ field,
+ match literal {
+ Datum::Double(_) => Datum::Double(999.0),
+ _ => Datum::Int(999),
+ },
+ )
+ .unwrap(),
+ usize::from(name == "widen"),
+ ),
+ ] {
+ let scan =
+ PaimonTableScan::new(&table, None, vec![predicate],
None, None, None);
+ let plan = scan
+ .plan_snapshot_from_entries(
+ diff_snapshot_with_schema(7, table.schema().id()),
+ entries.clone(),
+ None,
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+ assert_eq!(plan.splits().len(), expected, "{name},
dense={dense}");
+ assert_eq!(plan.snapshot_id(), Some(7));
+ }
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn test_data_evolution_stats_require_unique_full_range_provider() {
+ let table = data_evolution_test_table(
+ "memory:/de_stats_providers",
+ two_column_schema(0, "id", "value"),
+ );
+ let pb = PredicateBuilder::new(table.schema().fields());
+ let make_file = |name: &str, first, count, seq, value| {
+ let mut file = make_evo_file(name, 10, count, seq, Some(first));
+ file.write_cols = Some(vec!["id".into()]);
+ let mut row = BinaryRowBuilder::new(1);
+ row.write_int(0, value);
+ let row = row.build_serialized();
+ file.value_stats = BinaryTableStats::new(row.clone(), row,
vec![Some(0)]);
+ file
+ };
+ for (name, files) in [
+ (
+ "partial",
+ vec![make_file("old", 0, 10, 1, 5), make_file("new", 0, 5, 2,
20)],
+ ),
+ (
+ "tied",
+ vec![
+ make_file("left", 0, 10, 2, 20),
+ make_file("right", 0, 10, 2, 5),
+ ],
+ ),
+ (
+ "chain",
+ vec![
+ make_file("left", 0, 5, 1, 20),
+ make_file("right", 4, 6, 2, 20),
+ ],
+ ),
+ ] {
+ for reverse in [false, true] {
+ let mut files = files.clone();
+ if reverse {
+ files.reverse();
+ }
+ let entries: Vec<_> = files
+ .into_iter()
+ .map(|file| {
+ ManifestEntry::new(
+ FileKind::Add,
+ BinaryRowBuilder::new(0).build_serialized(),
+ 0,
+ -1,
+ file,
+ 0,
+ )
+ })
+ .collect();
+ for predicate in [
+ pb.equal("id", Datum::Int(5)).unwrap(),
+ pb.is_null("id").unwrap(),
+ ] {
+ let plan =
+ PaimonTableScan::new(&table, None, vec![predicate],
None, None, None)
+ .plan_snapshot_from_entries(
+ diff_snapshot_with_schema(7, 0),
+ entries.clone(),
+ None,
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+ assert_eq!(plan.splits().len(), 1, "{name},
reverse={reverse}");
+ assert_eq!(plan.splits()[0].data_files().len(), 2);
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn test_data_evolution_stats_reject_inconsistent_field_stats() {
+ let fields = vec![DataField::new(
+ 0,
+ "id".into(),
+ DataType::Int(IntType::new()),
+ )];
+ let predicate = PredicateBuilder::new(&fields)
+ .equal("id", Datum::Int(5))
+ .unwrap();
+ for (min, max, nulls) in [
+ (Some(10), Some(10), -1),
+ (Some(10), Some(10), 11),
+ (None, Some(10), 0),
+ (Some(10), None, 0),
+ (Some(10), Some(1), 0),
+ (Some(10), Some(10), 10),
+ ] {
+ let stats_row = |value: Option<i32>| {
+ let mut row = BinaryRowBuilder::new(1);
+ if let Some(value) = value {
+ row.write_int(0, value);
+ } else {
+ row.set_null_at(0);
+ }
+ row.build_serialized()
+ };
+ let mut file = make_evo_file("invalid", 10, 10, 1, Some(0));
+ file.value_stats =
+ BinaryTableStats::new(stats_row(min), stats_row(max),
vec![Some(nulls)]);
+ assert!(
+ data_evolution_group_matches_predicates(
+ &[file],
+ std::slice::from_ref(&predicate),
+ &fields
+ ),
+ "{min:?}, {max:?}, {nulls}"
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn
test_data_evolution_stats_distinguish_readded_fields_and_unknown_schemas() {
+ let old_schema = two_column_schema(0, "id", "value");
+ let schema = old_schema
+ .apply_changes(vec![
+ SchemaChange::drop_column("value".into()),
+ SchemaChange::add_column("value".into(),
DataType::Int(IntType::new())),
+ ])
+ .unwrap();
+ let table = data_evolution_test_table("memory:/de_stats_readd",
schema);
+ write_schema_file(&table, &old_schema).await;
+ let pb = PredicateBuilder::new(table.schema().fields());
+ for file_schema_id in [0, 99] {
+ let mut file = make_evo_file("old.parquet", 10, 1, 1, Some(0));
+ file.schema_id = file_schema_id;
+ // The old name exists, but it is not the re-added field's ID.
+ file.write_cols = Some(vec!["value".into()]);
+ for (predicate, expected) in [
+ (
+ pb.equal("value", Datum::Int(20)).unwrap(),
+ usize::from(file_schema_id == 99),
+ ),
+ (pb.is_null("value").unwrap(), 1),
+ (
+ pb.is_not_null("value").unwrap(),
+ usize::from(file_schema_id == 99),
+ ),
+ (Predicate::AlwaysFalse, 0),
+ ] {
+ let entries = vec![ManifestEntry::new(
+ FileKind::Add,
+ BinaryRowBuilder::new(0).build_serialized(),
+ 0,
+ -1,
+ file.clone(),
+ 0,
+ )];
+ let plan = PaimonTableScan::new(&table, None, vec![predicate],
None, None, None)
+ .plan_snapshot_from_entries(
+ diff_snapshot_with_schema(7, table.schema().id()),
+ entries,
+ None,
+ None,
+ None,
+ None,
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ plan.splits().len(),
+ expected,
+ "file schema {file_schema_id}"
+ );
+ }
+ }
+ }
+
#[tokio::test]
async fn test_data_evolution_pruning_matches_renamed_columns_by_field_id()
{
let schema_v0 = two_column_schema(0, "id", "old_name");
@@ -4519,12 +4808,13 @@ mod tests {
#[test]
fn test_data_evolution_group_matches_or_prunes_when_no_child_matches() {
let fields = int_field();
- let file = test_data_file_meta(
+ let mut file = test_data_file_meta(
int_stats_row(Some(10)),
int_stats_row(Some(20)),
vec![Some(0)],
5,
);
+ file.first_row_id = Some(0);
let pb = PredicateBuilder::new(&fields);
let predicate = Predicate::or(vec![
pb.less_than("id", Datum::Int(5)).unwrap(),
@@ -4541,12 +4831,13 @@ mod tests {
#[test]
fn test_data_evolution_group_matches_or_keeps_when_any_child_matches() {
let fields = int_field();
- let file = test_data_file_meta(
+ let mut file = test_data_file_meta(
int_stats_row(Some(10)),
int_stats_row(Some(20)),
vec![Some(0)],
5,
);
+ file.first_row_id = Some(0);
let pb = PredicateBuilder::new(&fields);
let predicate = Predicate::or(vec![
pb.less_than("id", Datum::Int(15)).unwrap(),
@@ -4563,12 +4854,13 @@ mod tests {
#[test]
fn test_data_evolution_group_is_not_pruned_by_a_row_id_predicate() {
let fields = int_field();
- let file = test_data_file_meta(
+ let mut file = test_data_file_meta(
int_stats_row(Some(10)),
int_stats_row(Some(20)),
vec![Some(0)],
5,
);
+ file.first_row_id = Some(0);
let row_id =
crate::spec::row_id_leaf(crate::spec::PredicateOperator::GtEq,
vec![Datum::Long(100)]);
@@ -4587,12 +4879,13 @@ mod tests {
#[test]
fn test_data_evolution_group_matches_not_prunes_when_inner_must_match() {
let fields = int_field();
- let file = test_data_file_meta(
+ let mut file = test_data_file_meta(
int_stats_row(Some(10)),
int_stats_row(Some(10)),
vec![Some(0)],
5,
);
+ file.first_row_id = Some(0);
let predicate = Predicate::negate(
PredicateBuilder::new(&fields)
.equal("id", Datum::Int(10))
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 1a0c3993..be343117 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -1002,6 +1002,7 @@ impl TableWrite {
changelog_file_prefix: self.changelog_file_prefix.clone(),
changelog_file_compression:
self.changelog_file_compression.clone(),
changelog_file_format: self.changelog_file_format.clone(),
+ primary_keys: self.table.schema().primary_keys().to_vec(),
primary_key_indices: self.primary_key_indices.clone(),
primary_key_types: self.primary_key_types.clone(),
sequence_field_indices: self.sequence_field_indices.clone(),
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 276e6c23..e55a644b 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -2372,7 +2372,9 @@ Rust currently supports `merge-engine=aggregation` in
basic mode only. It works
with fixed buckets and ordinary dynamic buckets (`'bucket' = '-1'`) when the
primary key includes all partition columns. It supports per-field aggregate
functions such as `sum`, `min`, `max`, value functions, boolean functions, and
-`listagg`, plus `fields.default-aggregate-function`.
+`listagg`, plus `fields.default-aggregate-function`. Each write-buffer flush
+aggregates duplicate primary keys before writing data files, so a file contains
+at most one row per key. Input changelog files retain the original rows.
Sequence fields are always merged with `last_value`. Defining
`fields.<sequence-field>.aggregate-function` is rejected, matching Java schema