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 d8c7945d [core] Align dynamic PK events and first-row scans with Java
(#845)
d8c7945d is described below
commit d8c7945d50a17b6a6ec0b7b1311e3399c9e30893
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 15 22:40:11 2026 +0800
[core] Align dynamic PK events and first-row scans with Java (#845)
---
crates/paimon/src/arrow/mod.rs | 1 +
crates/paimon/src/arrow/partition.rs | 200 +++++++++++++++++++++
crates/paimon/src/table/format_table_read.rs | 126 +-------------
crates/paimon/src/table/kv_file_reader.rs | 12 +-
crates/paimon/src/table/sort_merge.rs | 106 ++++++++++++
crates/paimon/src/table/table_read.rs | 9 +-
crates/paimon/src/table/table_scan.rs | 102 ++++++-----
crates/paimon/src/table/table_write.rs | 35 +++-
crates/paimon/tests/dynamic_bucket_scan_test.rs | 220 ++++++++++++++++++++++++
crates/paimon/tests/first_row_scan_test.rs | 162 +++++++++++++++++
10 files changed, 794 insertions(+), 179 deletions(-)
diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index 0995bd45..e30d83e5 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -19,6 +19,7 @@ pub(crate) mod filtering;
pub(crate) mod format;
pub(crate) mod nested_evolution;
mod parquet_read_budget;
+pub(crate) mod partition;
pub(crate) mod residual;
mod row_filter;
pub(crate) mod schema_evolution;
diff --git a/crates/paimon/src/arrow/partition.rs
b/crates/paimon/src/arrow/partition.rs
new file mode 100644
index 00000000..5e426cab
--- /dev/null
+++ b/crates/paimon/src/arrow/partition.rs
@@ -0,0 +1,200 @@
+// 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.
+
+//! Expand a serialized partition field into an Arrow column.
+
+use crate::arrow::paimon_type_to_arrow;
+use crate::spec::{extract_datum, BinaryRow, DataType, Datum};
+use crate::Error;
+use arrow_array::{
+ new_null_array, ArrayRef, BinaryArray, BooleanArray, Date32Array,
Decimal128Array,
+ Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array,
StringArray,
+ Time32MillisecondArray, TimestampMicrosecondArray,
TimestampMillisecondArray,
+ TimestampNanosecondArray,
+};
+use std::sync::Arc;
+
+pub(crate) fn partition_array(
+ partition: &BinaryRow,
+ partition_index: usize,
+ data_type: &DataType,
+ num_rows: usize,
+) -> crate::Result<ArrayRef> {
+ let arrow_type = paimon_type_to_arrow(data_type)?;
+ if partition.arity() <= partition_index as i32 ||
partition.is_null_at(partition_index) {
+ return Ok(new_null_array(&arrow_type, num_rows));
+ }
+
+ let datum = extract_datum(partition, partition_index, data_type)?;
+ let Some(datum) = datum else {
+ return Ok(new_null_array(&arrow_type, num_rows));
+ };
+
+ Ok(match (datum, data_type) {
+ (Datum::Bool(value), DataType::Boolean(_)) => {
+ Arc::new(BooleanArray::from(vec![Some(value); num_rows]))
+ }
+ (Datum::TinyInt(value), DataType::TinyInt(_)) => {
+ Arc::new(Int8Array::from(vec![Some(value); num_rows]))
+ }
+ (Datum::SmallInt(value), DataType::SmallInt(_)) => {
+ Arc::new(Int16Array::from(vec![Some(value); num_rows]))
+ }
+ (Datum::Int(value), DataType::Int(_)) => {
+ Arc::new(Int32Array::from(vec![Some(value); num_rows]))
+ }
+ (Datum::Long(value), DataType::BigInt(_)) => {
+ Arc::new(Int64Array::from(vec![Some(value); num_rows]))
+ }
+ (Datum::Float(value), DataType::Float(_)) => {
+ Arc::new(Float32Array::from(vec![Some(value); num_rows]))
+ }
+ (Datum::Double(value), DataType::Double(_)) => {
+ Arc::new(Float64Array::from(vec![Some(value); num_rows]))
+ }
+ (Datum::String(value), DataType::Char(_) | DataType::VarChar(_)) => {
+ let values = std::iter::repeat_with(|| Some(value.as_str()))
+ .take(num_rows)
+ .collect::<Vec<_>>();
+ Arc::new(StringArray::from(values))
+ }
+ (Datum::Bytes(value), DataType::Binary(_) | DataType::VarBinary(_)) =>
{
+ let values = std::iter::repeat_with(|| Some(value.as_slice()))
+ .take(num_rows)
+ .collect::<Vec<_>>();
+ Arc::new(BinaryArray::from(values))
+ }
+ (
+ Datum::Decimal {
+ unscaled, scale, ..
+ },
+ DataType::Decimal(decimal),
+ ) => Arc::new(
+ Decimal128Array::from(vec![Some(unscaled); num_rows])
+ .with_precision_and_scale(decimal.precision() as u8, scale as
i8)
+ .map_err(|error| Error::DataInvalid {
+ message: format!("Invalid decimal partition: {error}"),
+ source: Some(Box::new(error)),
+ })?,
+ ),
+ (Datum::Date(value), DataType::Date(_)) => {
+ Arc::new(Date32Array::from(vec![Some(value); num_rows]))
+ }
+ (Datum::Time(value), DataType::Time(_)) => {
+ Arc::new(Time32MillisecondArray::from(vec![Some(value); num_rows]))
+ }
+ (Datum::Timestamp { millis, nanos }, DataType::Timestamp(ts)) => {
+ timestamp_array(millis, nanos, ts.precision(), None, num_rows)?
+ }
+ (Datum::LocalZonedTimestamp { millis, nanos },
DataType::LocalZonedTimestamp(ts)) => {
+ timestamp_array(millis, nanos, ts.precision(), Some("UTC"),
num_rows)?
+ }
+ (_, other) => {
+ return Err(Error::Unsupported {
+ message: format!(
+ "Partition column type '{other:?}' is not supported by the
Rust reader yet"
+ ),
+ });
+ }
+ })
+}
+
+fn timestamp_array(
+ millis: i64,
+ nanos: i32,
+ precision: u32,
+ timezone: Option<&'static str>,
+ num_rows: usize,
+) -> crate::Result<ArrayRef> {
+ let array: ArrayRef = match precision {
+ 0..=3 => {
+ let array = TimestampMillisecondArray::from(vec![Some(millis);
num_rows]);
+ match timezone {
+ Some(tz) => Arc::new(array.with_timezone(tz)),
+ None => Arc::new(array),
+ }
+ }
+ 4..=6 => {
+ let value = millis * 1_000 + (nanos as i64) / 1_000;
+ let array = TimestampMicrosecondArray::from(vec![Some(value);
num_rows]);
+ match timezone {
+ Some(tz) => Arc::new(array.with_timezone(tz)),
+ None => Arc::new(array),
+ }
+ }
+ 7..=9 => {
+ let value = millis * 1_000_000 + (nanos as i64);
+ let array = TimestampNanosecondArray::from(vec![Some(value);
num_rows]);
+ match timezone {
+ Some(tz) => Arc::new(array.with_timezone(tz)),
+ None => Arc::new(array),
+ }
+ }
+ _ => {
+ return Err(Error::Unsupported {
+ message: format!("Unsupported timestamp precision for
partition: {precision}"),
+ });
+ }
+ };
+ Ok(array)
+}
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::{
+ extract_datum_from_array, DecimalType, IntType,
LocalZonedTimestampType, VarCharType,
+ };
+
+ #[test]
+ fn partition_columns_preserve_nulls_decimals_and_timestamp_precision() {
+ let values = [
+ (None, DataType::VarChar(VarCharType::string_type())),
+ (Some(Datum::Int(-7)), DataType::Int(IntType::new())),
+ (
+ Some(Datum::Decimal {
+ unscaled: -12345678901234567890,
+ precision: 20,
+ scale: 3,
+ }),
+ DataType::Decimal(DecimalType::new(20, 3).unwrap()),
+ ),
+ (
+ Some(Datum::LocalZonedTimestamp {
+ millis: 1234,
+ nanos: 567890,
+ }),
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(9).unwrap()),
+ ),
+ ];
+ let datums: Vec<_> = values
+ .iter()
+ .map(|(value, ty)| (value.as_ref(), ty))
+ .collect();
+ let partition = BinaryRow::from_datums(&datums);
+ for (index, (value, ty)) in values.iter().enumerate() {
+ let column = partition_array(&partition, index, ty, 3).unwrap();
+ assert_eq!(column.len(), 3);
+ assert_eq!(column.data_type(), &paimon_type_to_arrow(ty).unwrap());
+ for row in 0..3 {
+ assert_eq!(
+ extract_datum_from_array(&column, row, index, ty).unwrap(),
+ *value
+ );
+ }
+ }
+ }
+}
diff --git a/crates/paimon/src/table/format_table_read.rs
b/crates/paimon/src/table/format_table_read.rs
index 9f0814d6..cb6e232f 100644
--- a/crates/paimon/src/table/format_table_read.rs
+++ b/crates/paimon/src/table/format_table_read.rs
@@ -21,15 +21,11 @@ use super::data_file_reader::DataFileReader;
use super::read_builder::split_scan_predicates;
use super::table_read::configured_parquet_read_budget;
use super::{ArrowRecordBatchStream, Table};
-use crate::arrow::{build_target_arrow_schema, paimon_type_to_arrow,
ParquetReadBudget};
-use crate::spec::{extract_datum, BinaryRow, DataField, DataType, Datum,
Predicate};
+use crate::arrow::partition::partition_array;
+use crate::arrow::{build_target_arrow_schema, ParquetReadBudget};
+use crate::spec::{DataField, Predicate};
use crate::{DataSplit, Error};
-use arrow_array::{
- new_null_array, ArrayRef, BinaryArray, BooleanArray, Date32Array,
Float32Array, Float64Array,
- Int16Array, Int32Array, Int64Array, Int8Array, RecordBatch,
RecordBatchOptions, StringArray,
- Time32MillisecondArray, TimestampMicrosecondArray,
TimestampMillisecondArray,
- TimestampNanosecondArray,
-};
+use arrow_array::{RecordBatch, RecordBatchOptions};
use async_stream::try_stream;
use futures::StreamExt;
use std::sync::Arc;
@@ -278,120 +274,6 @@ fn project_format_batch(
})
}
-fn partition_array(
- partition: &BinaryRow,
- partition_index: usize,
- data_type: &DataType,
- num_rows: usize,
-) -> crate::Result<ArrayRef> {
- let arrow_type = paimon_type_to_arrow(data_type)?;
- if partition.arity() <= partition_index as i32 ||
partition.is_null_at(partition_index) {
- return Ok(new_null_array(&arrow_type, num_rows));
- }
-
- let datum = extract_datum(partition, partition_index, data_type)?;
- let Some(datum) = datum else {
- return Ok(new_null_array(&arrow_type, num_rows));
- };
-
- Ok(match (datum, data_type) {
- (Datum::Bool(value), DataType::Boolean(_)) => {
- Arc::new(BooleanArray::from(vec![Some(value); num_rows]))
- }
- (Datum::TinyInt(value), DataType::TinyInt(_)) => {
- Arc::new(Int8Array::from(vec![Some(value); num_rows]))
- }
- (Datum::SmallInt(value), DataType::SmallInt(_)) => {
- Arc::new(Int16Array::from(vec![Some(value); num_rows]))
- }
- (Datum::Int(value), DataType::Int(_)) => {
- Arc::new(Int32Array::from(vec![Some(value); num_rows]))
- }
- (Datum::Long(value), DataType::BigInt(_)) => {
- Arc::new(Int64Array::from(vec![Some(value); num_rows]))
- }
- (Datum::Float(value), DataType::Float(_)) => {
- Arc::new(Float32Array::from(vec![Some(value); num_rows]))
- }
- (Datum::Double(value), DataType::Double(_)) => {
- Arc::new(Float64Array::from(vec![Some(value); num_rows]))
- }
- (Datum::String(value), DataType::Char(_) | DataType::VarChar(_)) => {
- let values = std::iter::repeat_with(|| Some(value.as_str()))
- .take(num_rows)
- .collect::<Vec<_>>();
- Arc::new(StringArray::from(values))
- }
- (Datum::Bytes(value), DataType::Binary(_) | DataType::VarBinary(_)) =>
{
- let values = std::iter::repeat_with(|| Some(value.as_slice()))
- .take(num_rows)
- .collect::<Vec<_>>();
- Arc::new(BinaryArray::from(values))
- }
- (Datum::Date(value), DataType::Date(_)) => {
- Arc::new(Date32Array::from(vec![Some(value); num_rows]))
- }
- (Datum::Time(value), DataType::Time(_)) => {
- Arc::new(Time32MillisecondArray::from(vec![Some(value); num_rows]))
- }
- (Datum::Timestamp { millis, nanos }, DataType::Timestamp(ts)) => {
- timestamp_array(millis, nanos, ts.precision(), None, num_rows)?
- }
- (Datum::LocalZonedTimestamp { millis, nanos },
DataType::LocalZonedTimestamp(ts)) => {
- timestamp_array(millis, nanos, ts.precision(), Some("UTC"),
num_rows)?
- }
- (_, other) => {
- return Err(Error::Unsupported {
- message: format!(
- "Format table partition column type '{other:?}' is not
supported by the Rust reader yet"
- ),
- });
- }
- })
-}
-
-fn timestamp_array(
- millis: i64,
- nanos: i32,
- precision: u32,
- timezone: Option<&'static str>,
- num_rows: usize,
-) -> crate::Result<ArrayRef> {
- let array: ArrayRef = match precision {
- 0..=3 => {
- let array = TimestampMillisecondArray::from(vec![Some(millis);
num_rows]);
- match timezone {
- Some(tz) => Arc::new(array.with_timezone(tz)),
- None => Arc::new(array),
- }
- }
- 4..=6 => {
- let value = millis * 1_000 + (nanos as i64) / 1_000;
- let array = TimestampMicrosecondArray::from(vec![Some(value);
num_rows]);
- match timezone {
- Some(tz) => Arc::new(array.with_timezone(tz)),
- None => Arc::new(array),
- }
- }
- 7..=9 => {
- let value = millis * 1_000_000 + (nanos as i64);
- let array = TimestampNanosecondArray::from(vec![Some(value);
num_rows]);
- match timezone {
- Some(tz) => Arc::new(array.with_timezone(tz)),
- None => Arc::new(array),
- }
- }
- _ => {
- return Err(Error::Unsupported {
- message: format!(
- "Unsupported timestamp precision for format table
partition: {precision}"
- ),
- });
- }
- };
- Ok(array)
-}
-
fn apply_limit(batch: RecordBatch, remaining: &mut Option<usize>) ->
Option<RecordBatch> {
let Some(value) = remaining else {
return Some(batch);
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index f3adf84f..8bc665e1 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -27,14 +27,14 @@
use super::data_file_reader::DataFileReader;
use super::sort_merge::{
- AggregateMergeFunction, DeduplicateMergeFunction, MergeFunction,
PartialUpdateMergeFunction,
- SortMergeReaderBuilder,
+ AggregateMergeFunction, DeduplicateMergeFunction, FirstRowMergeFunction,
MergeFunction,
+ PartialUpdateMergeFunction, SortMergeReaderBuilder,
};
use crate::arrow::{build_target_arrow_schema, ParquetReadBudget};
use crate::deletion_vector::DeletionVectorFactory;
use crate::io::FileIO;
use crate::spec::{
- BigIntType, DataField, DataFileMeta, DataType as PaimonDataType,
MergeEngine,
+ BigIntType, CoreOptions, DataField, DataFileMeta, DataType as
PaimonDataType, MergeEngine,
PartialUpdateConfig, Predicate, TinyIntType, SEQUENCE_NUMBER_FIELD_ID,
SEQUENCE_NUMBER_FIELD_NAME, VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
};
@@ -297,9 +297,9 @@ impl KeyValueFileReader {
&config.primary_keys,
)?))
}
- MergeEngine::FirstRow => Err(Error::Unsupported {
- message: "KeyValueFileReader does not support
merge-engine=first-row; first-row reads should use the non-KV path".to_string(),
- }),
+ MergeEngine::FirstRow => Ok(Box::new(FirstRowMergeFunction {
+ ignore_delete:
CoreOptions::new(&config.table_options).ignore_delete(),
+ })),
MergeEngine::Aggregation =>
Ok(Box::new(AggregateMergeFunction::new(
&config.table_options,
&config.table_name,
diff --git a/crates/paimon/src/table/sort_merge.rs
b/crates/paimon/src/table/sort_merge.rs
index e63be64a..1e88005b 100644
--- a/crates/paimon/src/table/sort_merge.rs
+++ b/crates/paimon/src/table/sort_merge.rs
@@ -182,6 +182,44 @@ impl MergeFunction for DeduplicateMergeFunction {
}
}
+/// First-row merge: keep the earliest sequence, retaining the first input on
ties.
+/// Java rejects retracts even when an earlier add has already been selected.
+pub(crate) struct FirstRowMergeFunction {
+ pub ignore_delete: bool,
+}
+
+impl MergeFunction for FirstRowMergeFunction {
+ fn merge(
+ &self,
+ rows: &[MergeRow],
+ _batch_buffer: &[BufferedBatch],
+ _source_output_col_indices: &[usize],
+ _output_schema: &SchemaRef,
+ ) -> crate::Result<MergeResult> {
+ let mut first: Option<&MergeRow> = None;
+ for row in rows {
+ if !RowKind::from_value(row.value_kind)?.is_add() {
+ if self.ignore_delete {
+ continue;
+ }
+ return Err(Error::Unsupported {
+ message: "merge-engine=first-row does not support DELETE
or UPDATE_BEFORE rows; set ignore-delete=true to ignore them".to_string(),
+ });
+ }
+ if first.is_none_or(|best| row.sequence_number <
best.sequence_number) {
+ first = Some(row);
+ }
+ }
+ Ok(match first {
+ Some(row) => MergeResult::SourceRow {
+ batch_idx: row.batch_idx,
+ row_idx: row.row_idx,
+ },
+ None => MergeResult::Omit,
+ })
+ }
+}
+
/// Partial-update merge: for each non-key column, keep the latest non-null
/// value or apply its configured field aggregator.
///
@@ -1305,6 +1343,74 @@ mod tests {
use std::collections::HashMap;
use std::sync::Arc;
+ #[test]
+ fn first_row_merge_keeps_earliest_sequence_and_first_tie() {
+ let rows: Vec<_> = [30, 10, 10, 20]
+ .into_iter()
+ .enumerate()
+ .map(|(row_idx, sequence_number)| MergeRow {
+ batch_idx: 0,
+ row_idx,
+ sequence_number,
+ value_kind: 0,
+ user_sequences: vec![],
+ })
+ .collect();
+ let result = FirstRowMergeFunction {
+ ignore_delete: false,
+ }
+ .merge(&rows, &[], &[], &make_output_schema())
+ .unwrap();
+ assert!(matches!(
+ result,
+ MergeResult::SourceRow {
+ batch_idx: 0,
+ row_idx: 1
+ }
+ ));
+ }
+
+ #[test]
+ fn first_row_merge_validates_every_retract_and_honors_ignore_delete() {
+ for kind in [1, 3] {
+ let mut rows = vec![
+ MergeRow {
+ batch_idx: 0,
+ row_idx: 0,
+ sequence_number: 1,
+ value_kind: 0,
+ user_sequences: vec![],
+ },
+ MergeRow {
+ batch_idx: 0,
+ row_idx: 1,
+ sequence_number: 2,
+ value_kind: kind,
+ user_sequences: vec![],
+ },
+ ];
+ let merge = FirstRowMergeFunction {
+ ignore_delete: false,
+ };
+ assert!(matches!(
+ merge.merge(&rows, &[], &[], &make_output_schema()),
+ Err(Error::Unsupported { .. })
+ ));
+ let merge = FirstRowMergeFunction {
+ ignore_delete: true,
+ };
+ assert!(matches!(
+ merge.merge(&rows, &[], &[], &make_output_schema()).unwrap(),
+ MergeResult::SourceRow { row_idx: 0, .. }
+ ));
+ rows.remove(0);
+ assert!(matches!(
+ merge.merge(&rows, &[], &[], &make_output_schema()).unwrap(),
+ MergeResult::Omit
+ ));
+ }
+ }
+
fn make_schema() -> SchemaRef {
Arc::new(Schema::new(vec![
Field::new("pk", DataType::Int32, false),
diff --git a/crates/paimon/src/table/table_read.rs
b/crates/paimon/src/table/table_read.rs
index 7f5f70f1..b684d411 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -727,12 +727,15 @@ impl<'a> PaimonTableRead<'a> {
let merge_engine = core_options.merge_engine()?;
// Route supported PK merge engines through the split-aware reader.
- // Deduplicate may mix raw and KV splits. Partial-update and
aggregation
+ // Deduplicate and first-row may mix raw and KV splits. Partial-update
and aggregation
// use KV reads normally, but fully materialized DV plans can read raw.
if has_primary_keys
&& matches!(
merge_engine,
- MergeEngine::Deduplicate | MergeEngine::PartialUpdate |
MergeEngine::Aggregation
+ MergeEngine::Deduplicate
+ | MergeEngine::FirstRow
+ | MergeEngine::PartialUpdate
+ | MergeEngine::Aggregation
)
{
return self.read_pk(data_splits, &core_options);
@@ -745,7 +748,7 @@ impl<'a> PaimonTableRead<'a> {
}
}
- /// Read PK table. For `Deduplicate`, splits marked raw convertible by scan
+ /// Read PK table. For `Deduplicate` and `FirstRow`, raw-convertible
splits from scan
/// planning (mirrors Java `DataSplit#convertToRawFiles`) use the faster
/// DataFileReader; the rest go through KeyValueFileReader for sort-merge
/// dedup. A fully materialized deletion-vector plan for `PartialUpdate` or
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 42e008d9..dda84796 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -724,8 +724,10 @@ fn should_skip_level_zero_for_scan(
return false;
}
- (deletion_vectors_enabled && !deletion_vectors_merge_on_read)
- || merge_engine.is_ok_and(|e| e == crate::spec::MergeEngine::FirstRow)
+ if deletion_vectors_enabled {
+ return !deletion_vectors_merge_on_read;
+ }
+ merge_engine.is_ok_and(|e| e == crate::spec::MergeEngine::FirstRow)
}
fn is_system_field_id(field_id: i32) -> bool {
@@ -1301,7 +1303,6 @@ impl<'a> PaimonTableScan<'a> {
let data_evolution_enabled = core_options.data_evolution_enabled();
let has_primary_keys = !self.table.schema().primary_keys().is_empty();
- let deletion_vectors_enabled = core_options.deletion_vectors_enabled();
// Skip level-0 files for PK tables when:
// - DV mode: level-0 files are unmerged, DV handles dedup at higher
levels
@@ -1312,13 +1313,7 @@ impl<'a> PaimonTableScan<'a> {
//
// Non-read paths (overwrite, truncate, writer restore) set
scan_all_files=true
// to see all files including level-0, matching Java's CommitScanner
behavior.
- let skip_level_zero = should_skip_level_zero_for_scan(
- self.scan_all_files || self.is_streaming(),
- has_primary_keys,
- deletion_vectors_enabled,
- core_options.deletion_vectors_merge_on_read(),
- core_options.merge_engine(),
- );
+ let skip_level_zero = self.skip_level_zero();
let partition_fields = self.table.schema().partition_fields();
@@ -1535,31 +1530,12 @@ impl<'a> PaimonTableScan<'a> {
/// are still enforced exactly by the post-merge residual filter in
/// `KeyValueFileReader`.
///
- /// Exempt (full predicates kept):
- /// - Deletion-vector tables without merge-on-read: they read raw with
- /// per-row masks, stats are a superset of live rows, full pruning stays
- /// safe. With merge-on-read enabled, visible L0 versions require the
- /// same key-only pruning rule as an ordinary PK merge read.
- /// - `merge-engine=first-row`: planned with `skip_level_zero` and read
- /// via `DataFileReader` (see `TableRead::to_arrow`), no merge on the
- /// read path — pruning a file drops exactly the rows the raw path's
- /// exact residual filter would drop anyway. If first-row ever gains a
- /// merge read path, this exemption must be revisited.
+ /// Full predicates are safe for materialized first-row / deletion-vector
+ /// files. First-row all-files scans and incremental scans retain every
+ /// version before the reader applies its residual filter.
fn stats_pruning_predicates(&self) -> Vec<Predicate> {
let has_primary_keys = !self.table.schema().primary_keys().is_empty();
- let core_options = CoreOptions::new(self.table.schema().options());
- let deletion_vectors_enabled = core_options.deletion_vectors_enabled();
- let deletion_vectors_merge_on_read =
core_options.deletion_vectors_merge_on_read();
- // An unknown merge engine stays conservative (key-only pruning); the
- // read side fails on it anyway before returning rows.
- let first_row = matches!(
- core_options.merge_engine(),
- Ok(crate::spec::MergeEngine::FirstRow)
- );
- if has_primary_keys
- && (self.is_streaming()
- || ((!deletion_vectors_enabled ||
deletion_vectors_merge_on_read) && !first_row))
- {
+ if has_primary_keys && self.requires_key_merge() {
retain_primary_key_conjuncts(
&self.data_predicates,
self.table.schema().fields(),
@@ -1570,6 +1546,28 @@ impl<'a> PaimonTableScan<'a> {
}
}
+ fn requires_key_merge(&self) -> bool {
+ let options = self.table.schema().core_options();
+ self.is_streaming()
+ || match options.merge_engine() {
+ Ok(crate::spec::MergeEngine::FirstRow) =>
!self.skip_level_zero(),
+ _ => {
+ !options.deletion_vectors_enabled() ||
options.deletion_vectors_merge_on_read()
+ }
+ }
+ }
+
+ fn skip_level_zero(&self) -> bool {
+ let options = self.table.schema().core_options();
+ should_skip_level_zero_for_scan(
+ self.scan_all_files || self.is_streaming(),
+ !self.table.schema().primary_keys().is_empty(),
+ options.deletion_vectors_enabled(),
+ options.deletion_vectors_merge_on_read(),
+ options.merge_engine(),
+ )
+ }
+
/// Project file-safe predicates onto trimmed primary-key columns while
/// preserving table-schema field indices.
fn key_stats_predicates(&self, predicates: &[Predicate]) -> Vec<Predicate>
{
@@ -2096,17 +2094,10 @@ impl<'a> PaimonTableScan<'a> {
// sort-merge reader sees every version of a key. The comparator
decodes
// the trimmed-PK min/max keys written by the kv writer.
//
- // Deletion-vector tables without merge-on-read and first-row tables
read
- // without merging (stale rows are masked by DVs / level-0 is skipped),
- // so they keep plain size-based packing. DV merge-on-read includes L0
- // files and must preserve overlapping key ranges just like ordinary
MOR.
- let use_key_interval_packing = self.is_streaming()
- || (!core_options.deletion_vectors_enabled()
- || core_options.deletion_vectors_merge_on_read())
- && !matches!(
- core_options.merge_engine(),
- Ok(crate::spec::MergeEngine::FirstRow)
- );
+ // Materialized first-row / DV data keeps size-based packing. First-row
+ // all-files scans and incremental scans retain L0 and must keep
+ // overlapping versions together.
+ let use_key_interval_packing = self.requires_key_merge();
let pk_comparator = if use_key_interval_packing {
KeyComparator::from_table_schema(self.table.schema())
} else {
@@ -2275,9 +2266,7 @@ impl<'a> PaimonTableScan<'a> {
// Java MergeTreeSplitGenerator#splitForBatch). Only engines
// whose writer deduplicates at flush guarantee a file never
// holds two rows of one key, so only they may mark groups raw
- // convertible; see merge_tree_split_for_batch. (First-row
- // tables do not take this path today, but its writer dedups
- // too, so keep the gate accurate.)
+ // convertible; see merge_tree_split_for_batch.
let file_keys_unique = matches!(
core_options.merge_engine(),
Ok(crate::spec::MergeEngine::Deduplicate)
@@ -3147,6 +3136,25 @@ mod tests {
));
}
+ #[test]
+ fn test_first_row_dv_merge_on_read_keeps_level_zero() {
+ // Java permits first-row DVs in pk-clustering-override tables.
+ assert!(!should_skip_level_zero_for_scan(
+ false,
+ true,
+ true,
+ true,
+ Ok(crate::spec::MergeEngine::FirstRow)
+ ));
+ assert!(should_skip_level_zero_for_scan(
+ false,
+ true,
+ true,
+ false,
+ Ok(crate::spec::MergeEngine::FirstRow)
+ ));
+ }
+
#[test]
fn test_scan_all_files_disables_first_row_level_zero_skip() {
assert!(!should_skip_level_zero_for_scan(
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index be343117..1a9c9a70 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -21,6 +21,7 @@
//! and [pypaimon
FileStoreWrite](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/write/file_store_write.py)
use crate::arrow::build_target_arrow_schema;
+use crate::arrow::partition::partition_array;
use crate::spec::PartitionComputer;
use crate::spec::{
first_row_supports_changelog_producer, BinaryRow, ChangelogProducer,
CoreOptions, DataField,
@@ -684,7 +685,39 @@ impl TableWrite {
}
for (key, row_indices) in delete_groups {
let sub_batch = take_rows(batch, &row_indices)?;
- let delete_batch = Self::add_value_kind_column(&sub_batch, 1)?;
+ // Java DeleteExistingProcessor emits the incoming values with
+ // the old partition and DELETE kind. Routing the file alone
+ // leaves incorrect physical values and partition statistics.
+ let partition = BinaryRow::from_serialized_bytes(&key.0)?;
+ let mut columns = sub_batch.columns().to_vec();
+ for (partition_index, field) in
+ self.table.schema().partition_fields().iter().enumerate()
+ {
+ let column_index =
+
sub_batch.schema().index_of(field.name()).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!(
+ "Missing partition field '{}': {error}",
+ field.name()
+ ),
+ source: Some(Box::new(error)),
+ }
+ })?;
+ columns[column_index] = partition_array(
+ &partition,
+ partition_index,
+ field.data_type(),
+ sub_batch.num_rows(),
+ )?;
+ }
+ let sub_batch =
+ RecordBatch::try_new(sub_batch.schema(),
columns).map_err(|error| {
+ crate::Error::DataInvalid {
+ message: format!("Failed to restore old partition
for delete: {error}"),
+ source: Some(Box::new(error)),
+ }
+ })?;
+ let delete_batch = Self::add_value_kind_column(&sub_batch, 3)?;
result.push((key, delete_batch));
}
}
diff --git a/crates/paimon/tests/dynamic_bucket_scan_test.rs
b/crates/paimon/tests/dynamic_bucket_scan_test.rs
new file mode 100644
index 00000000..15123330
--- /dev/null
+++ b/crates/paimon/tests/dynamic_bucket_scan_test.rs
@@ -0,0 +1,220 @@
+// 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::{Int32Array, Int8Array, RecordBatch, StringArray};
+use common::incremental_helpers::{
+ make_partitioned_batch, memory_table, persist_table_schema, setup_dirs,
write_batch,
+};
+use futures::TryStreamExt;
+use paimon::spec::{
+ DataField, DataType, Datum, IntType, PredicateBuilder, Schema,
TableSchema, TinyIntType,
+ VarCharType, VALUE_KIND_FIELD_ID, VALUE_KIND_FIELD_NAME,
+};
+use paimon::table::{IncrementalScanMode, ReadBuilder};
+
+async fn events(
+ builder: &ReadBuilder<'_>,
+ splits: &[paimon::DataSplit],
+) -> Vec<(String, i32, i32, i8)> {
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(splits)
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut events: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| {
+ let pt = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let id = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let value = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let kind = batch
+ .column(3)
+ .as_any()
+ .downcast_ref::<Int8Array>()
+ .unwrap();
+ (0..batch.num_rows())
+ .map(|i| {
+ (
+ pt.value(i).to_string(),
+ id.value(i),
+ value.value(i),
+ kind.value(i),
+ )
+ })
+ .collect::<Vec<_>>()
+ })
+ .collect();
+ events.sort_unstable();
+ events
+}
+
+#[tokio::test]
+async fn dynamic_and_cross_partition_scans_preserve_migration_events() {
+ for cross_partition in [false, true] {
+ for engine in ["deduplicate", "first-row"] {
+ let schema = Schema::builder()
+ .column("pt", DataType::VarChar(VarCharType::string_type()))
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .primary_key(if cross_partition {
+ vec!["id"]
+ } else {
+ vec!["id", "pt"]
+ })
+ .partition_keys(["pt"])
+ .option("bucket", "-1")
+ .option("merge-engine", engine)
+ .option("dynamic-bucket.target-row-num", "1")
+ .option("source.split.target-size", "1b")
+ .option("source.split.open-file-cost", "1b")
+ .build()
+ .unwrap();
+ let path =
format!("memory:/bucket_scan/{cross_partition}/{engine}");
+ let (io, table) = memory_table(&path, TableSchema::new(0,
&schema));
+ setup_dirs(&io, &path).await;
+ persist_table_schema(&io, &path, table.schema()).await;
+ write_batch(
+ &table,
+ &make_partitioned_batch(vec!["a", "a"], vec![1, 2], vec![10,
20]),
+ )
+ .await;
+ // Reopening the writer must restore the key-to-partition/bucket
index.
+ write_batch(
+ &table,
+ &make_partitioned_batch(vec!["b", "a"], vec![1, 3], vec![99,
30]),
+ )
+ .await;
+ let mut expected = vec![
+ ("a".into(), 1, 10, 0),
+ ("a".into(), 2, 20, 0),
+ ("a".into(), 3, 30, 0),
+ ];
+ if !(cross_partition && engine == "first-row") {
+ expected.push(("b".into(), 1, 99, 0));
+ }
+ if cross_partition && engine == "deduplicate" {
+ // Java DeleteExistingProcessor replaces the partition fields
+ // of the incoming row and emits DELETE, not UPDATE_BEFORE.
+ expected.push(("a".into(), 1, 99, 3));
+ }
+ expected.sort_unstable();
+ for partition in [None, Some("a"), Some("b")] {
+ let mut builder = table.new_read_builder();
+ let mut fields = table.schema().fields().to_vec();
+ fields.push(DataField::new(
+ VALUE_KIND_FIELD_ID,
+ VALUE_KIND_FIELD_NAME.to_string(),
+ DataType::TinyInt(TinyIntType::new()),
+ ));
+ builder.with_read_type(fields);
+ if let Some(partition) = partition {
+ builder.with_filter(
+ PredicateBuilder::new(table.schema().fields())
+ .equal("pt", Datum::String(partition.into()))
+ .unwrap(),
+ );
+ }
+ let plan = builder
+ .new_incremental_scan(IncrementalScanMode::Delta, 0, 2)
+ .plan_combined_delta()
+ .await
+ .unwrap();
+ assert!(plan.splits().iter().all(|s| s.is_streaming()));
+ let expected: Vec<_> = expected
+ .iter()
+ .filter(|event| partition.is_none_or(|p| event.0 == p))
+ .cloned()
+ .collect();
+ assert_eq!(
+ events(&builder, plan.splits()).await,
+ expected,
+ "cross_partition={cross_partition}, engine={engine},
partition={partition:?}"
+ );
+ }
+ let builder = table.new_read_builder();
+ let plan = builder
+ .new_scan()
+ .with_scan_all_files()
+ .plan()
+ .await
+ .unwrap();
+ assert!(plan.splits().iter().any(|s| s.bucket() > 0));
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut actual: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| {
+ let pt = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<StringArray>()
+ .unwrap();
+ let id = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let value = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ (0..batch.num_rows())
+ .map(|i| (pt.value(i).to_string(), id.value(i),
value.value(i)))
+ .collect::<Vec<_>>()
+ })
+ .collect();
+ actual.sort_unstable();
+ let mut current: Vec<_> = expected
+ .iter()
+ .filter(|event| {
+ event.3 == 0
+ && !(cross_partition
+ && engine == "deduplicate"
+ && event.0 == "a"
+ && event.1 == 1)
+ })
+ .map(|event| (event.0.clone(), event.1, event.2))
+ .collect();
+ current.sort_unstable();
+ assert_eq!(actual, current);
+ }
+ }
+}
diff --git a/crates/paimon/tests/first_row_scan_test.rs
b/crates/paimon/tests/first_row_scan_test.rs
new file mode 100644
index 00000000..c9f4a158
--- /dev/null
+++ b/crates/paimon/tests/first_row_scan_test.rs
@@ -0,0 +1,162 @@
+// 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::{Int32Array, RecordBatch};
+use common::incremental_helpers::{
+ make_batch, memory_table, persist_table_schema, pk_schema, setup_dirs,
write_batch,
+};
+use futures::TryStreamExt;
+use paimon::spec::{Datum, PredicateBuilder};
+use paimon::table::{IncrementalScanMode, Plan, ReadBuilder, Table};
+
+async fn table_with_versions(path: &str, compacted: bool) -> Table {
+ let (io, table) = memory_table(
+ path,
+ pk_schema(&[
+ ("merge-engine", "first-row"),
+ ("source.split.target-size", "1b"),
+ ("source.split.open-file-cost", "1b"),
+ ]),
+ );
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1, 2], vec![10, 20]))
+ .await
+ .unwrap();
+ let mut messages = writer.prepare_commit().await.unwrap();
+ if compacted {
+ // A unique, sorted run needs only a level upgrade during compaction.
+ for message in &mut messages {
+ for file in &mut message.new_files {
+ file.level = 1;
+ }
+ }
+ }
+ builder.new_commit().commit(messages).await.unwrap();
+ write_batch(&table, &make_batch(vec![1, 3], vec![99, 30])).await;
+ table
+}
+
+async fn rows(builder: &ReadBuilder<'_>, plan: &Plan) -> Vec<(i32, i32)> {
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows: Vec<_> = 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::<Int32Array>()
+ .unwrap();
+ (0..batch.num_rows())
+ .map(|i| (ids.value(i), values.value(i)))
+ .collect::<Vec<_>>()
+ })
+ .collect();
+ rows.sort_unstable();
+ rows
+}
+
+#[tokio::test]
+async fn first_row_batch_reads_compacted_rows_and_skips_new_level_zero() {
+ let table = table_with_versions("memory:/first_row/compacted", true).await;
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ assert_eq!(plan.snapshot_id(), Some(2));
+ assert!(plan.splits().iter().all(
+ |split| split.raw_convertible() &&
split.data_files().iter().all(|file| file.level > 0)
+ ));
+ assert_eq!(rows(&builder, &plan).await, vec![(1, 10), (2, 20)]);
+}
+
+#[tokio::test]
+async fn first_row_all_files_keeps_overlapping_versions_in_one_split() {
+ for compacted in [false, true] {
+ let path = format!("memory:/first_row/all_files/{compacted}");
+ let table = table_with_versions(&path, compacted).await;
+ let builder = table.new_read_builder();
+ let plan = builder
+ .new_scan()
+ .with_scan_all_files()
+ .plan()
+ .await
+ .unwrap();
+ // Both ranges overlap at key 1, even with a one-byte target split
size.
+ assert_eq!(plan.splits().len(), 1);
+ assert!(!plan.splits()[0].raw_convertible());
+ assert_eq!(plan.splits()[0].data_files().len(), 2);
+ assert_eq!(rows(&builder, &plan).await, vec![(1, 10), (2, 20), (3,
30)]);
+ }
+}
+
+#[tokio::test]
+async fn first_row_value_filter_runs_after_merging_all_versions() {
+ let table = table_with_versions("memory:/first_row/value_filter",
false).await;
+ for (value, expected) in [(10, vec![(1, 10)]), (99, vec![])] {
+ let predicate = PredicateBuilder::new(table.schema().fields())
+ .equal("value", Datum::Int(value))
+ .unwrap();
+ let mut builder = table.new_read_builder();
+ builder.with_filter(predicate);
+ let plan = builder
+ .new_scan()
+ .with_scan_all_files()
+ .plan()
+ .await
+ .unwrap();
+ assert_eq!(
+ plan.splits()
+ .iter()
+ .map(|s| s.data_files().len())
+ .sum::<usize>(),
+ 2
+ );
+ assert_eq!(rows(&builder, &plan).await, expected);
+ }
+}
+
+#[tokio::test]
+async fn first_row_incremental_preserves_events_instead_of_merging() {
+ let table = table_with_versions("memory:/first_row/incremental",
false).await;
+ let builder = table.new_read_builder();
+ let plan = builder
+ .new_incremental_scan(IncrementalScanMode::Delta, 0, 2)
+ .plan_combined_delta()
+ .await
+ .unwrap();
+ assert!(plan.splits().iter().all(|split| split.is_streaming()));
+ assert_eq!(
+ rows(&builder, &plan).await,
+ vec![(1, 10), (1, 99), (2, 20), (3, 30)]
+ );
+}