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 1aa6c493 Support PK partial-update and aggregation reads with deletion 
vectors (#905)
1aa6c493 is described below

commit 1aa6c493b9a2075b4df05c801366a200c56c1ffa
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 21 19:06:12 2026 +0800

    Support PK partial-update and aggregation reads with deletion vectors (#905)
---
 crates/paimon/src/table/kv_file_reader.rs | 164 ++++++++++++++++++++++++++++++
 crates/paimon/src/table/read_builder.rs   |  51 ++++------
 crates/paimon/src/table/table_read.rs     |  63 ++++--------
 3 files changed, 202 insertions(+), 76 deletions(-)

diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index 9b2a868f..cfc265bc 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -1077,6 +1077,170 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn dv_merge_on_read_supports_partial_update_and_aggregation() {
+        for (engine, options, second_value, expected) in [
+            ("partial-update", vec![], None, 10),
+            (
+                "aggregation",
+                vec![("fields.value.aggregate-function", "sum")],
+                Some(5),
+                15,
+            ),
+        ] {
+            let file_io = test_file_io();
+            let table_path = format!("memory:/dv_merge_on_read_{engine}");
+            setup_dirs(&file_io, &table_path).await;
+            let mut table_options = vec![
+                ("merge-engine", engine),
+                ("source.split.target-size", "1b"),
+                ("source.split.open-file-cost", "1b"),
+            ];
+            table_options.extend(options);
+            let table = pk_table(&file_io, &table_path, &table_options);
+
+            write_commit(&table, &int_batch(vec![1, 2], vec![Some(10), 
Some(20)])).await;
+            write_commit(&table, &int_batch(vec![1, 3], vec![second_value, 
Some(30)])).await;
+
+            // The Rust writer does not yet support DV compaction for these
+            // merge engines. Read the real L0 files through a DV-enabled view
+            // of the same table, as a Java-written table would be read.
+            let read_table = table.copy_with_options(HashMap::from([
+                ("deletion-vectors.enabled".to_string(), "true".to_string()),
+                (
+                    "deletion-vectors.merge-on-read".to_string(),
+                    "true".to_string(),
+                ),
+            ]));
+
+            let plan = read_table
+                .new_read_builder()
+                .new_scan()
+                .plan()
+                .await
+                .unwrap();
+            assert_eq!(plan.splits().len(), 1, "overlapping L0 files must 
merge");
+            assert!(!plan.splits()[0].raw_convertible());
+            let batches = read_rows(&read_table, None, None).await;
+            assert_eq!(int_column(&batches, "id"), vec![1, 2, 3]);
+            assert_eq!(int_column(&batches, "value"), vec![expected, 20, 30]);
+
+            let fields = read_table.schema().fields().to_vec();
+            let merged_filter = PredicateBuilder::new(&fields)
+                .equal("value", Datum::Int(expected))
+                .unwrap();
+            let filtered = read_rows(&read_table, Some(&["id"]), 
Some(merged_filter)).await;
+            assert_eq!(int_column(&filtered, "id"), vec![1]);
+            if engine == "aggregation" {
+                let input_filter = PredicateBuilder::new(&fields)
+                    .equal("value", Datum::Int(10))
+                    .unwrap();
+                let stale = read_rows(&read_table, None, 
Some(input_filter)).await;
+                assert_eq!(
+                    stale.iter().map(RecordBatch::num_rows).sum::<usize>(),
+                    0,
+                    "an unmerged input value must not leak"
+                );
+            }
+        }
+    }
+
+    /// A compacted file's DV must be applied before its surviving values are
+    /// merged with an L0 file, while a materialized compacted-only split stays
+    /// on the raw path. This exercises both routes for each merge function.
+    #[tokio::test]
+    async fn partial_update_and_aggregation_apply_dv_before_merge() {
+        for (engine, options, second_value, expected) in [
+            ("partial-update", vec![], None, 10),
+            (
+                "aggregation",
+                vec![("fields.value.aggregate-function", "sum")],
+                Some(5),
+                15,
+            ),
+        ] {
+            let file_io = test_file_io();
+            let table_path = format!("memory:/dv_compacted_and_l0_{engine}");
+            setup_dirs(&file_io, &table_path).await;
+            let mut table_options = vec![("merge-engine", engine)];
+            table_options.extend(options);
+            let table = pk_table(&file_io, &table_path, &table_options);
+            write_commit(&table, &int_batch(vec![1, 2], vec![Some(10), 
Some(20)])).await;
+            write_commit(&table, &int_batch(vec![1, 3], vec![second_value, 
Some(30)])).await;
+            write_commit(&table, &int_batch(vec![4], vec![Some(40)])).await;
+
+            let all_files = table
+                .new_read_builder()
+                .new_scan()
+                .with_scan_all_files()
+                .plan()
+                .await
+                .unwrap();
+            let mut files = all_files
+                .splits()
+                .iter()
+                .flat_map(|split| split.data_files().iter().cloned())
+                .collect::<Vec<_>>();
+            files.sort_by_key(|file| file.min_sequence_number);
+            assert_eq!(files.len(), 3);
+            files[0].level = 1;
+            files[2].level = 1;
+            let deletion_file = write_deletion_file(&file_io, &table_path, 
&[1]).await;
+            let split_builder = || {
+                DataSplitBuilder::new()
+                    .with_snapshot(2)
+                    .with_partition(BinaryRow::new(0))
+                    .with_bucket(0)
+                    .with_bucket_path(format!("{table_path}/bucket-0"))
+                    .with_total_buckets(1)
+            };
+            let read_table = table.copy_with_options(HashMap::from([(
+                "deletion-vectors.enabled".to_string(),
+                "true".to_string(),
+            )]));
+            let read = read_table.new_read_builder().new_read().unwrap();
+
+            let raw_split = split_builder()
+                .with_data_files(vec![files[0].clone()])
+                .with_data_deletion_files(vec![Some(deletion_file.clone())])
+                .with_raw_convertible(true)
+                .build()
+                .unwrap();
+            let raw_batches = read
+                .to_arrow(&[raw_split])
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap();
+            assert_eq!(int_column(&raw_batches, "id"), vec![1]);
+            assert_eq!(int_column(&raw_batches, "value"), vec![10]);
+
+            let merge_split = split_builder()
+                .with_data_files(files[..2].to_vec())
+                .with_data_deletion_files(vec![Some(deletion_file), None])
+                .with_raw_convertible(false)
+                .build()
+                .unwrap();
+            let disjoint_raw_split = split_builder()
+                .with_data_files(vec![files[2].clone()])
+                .with_raw_convertible(true)
+                .build()
+                .unwrap();
+            let merged_batches = read
+                .to_arrow(&[merge_split, disjoint_raw_split])
+                .unwrap()
+                .try_collect::<Vec<_>>()
+                .await
+                .unwrap();
+            let mut rows = int_column(&merged_batches, "id")
+                .into_iter()
+                .zip(int_column(&merged_batches, "value"))
+                .collect::<Vec<_>>();
+            rows.sort_unstable_by_key(|row| row.0);
+            assert_eq!(rows, vec![(1, expected), (3, 30), (4, 40)]);
+        }
+    }
+
     #[tokio::test]
     async fn dynamic_dv_merge_on_read_is_ignored_without_deletion_vectors() {
         let file_io = test_file_io();
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index c25f789b..063b898c 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -925,7 +925,7 @@ mod tests {
         )
     }
 
-    async fn read_compacted_dv_table(merge_engine: &str) -> Vec<RecordBatch> {
+    async fn read_compacted_dv_table(merge_engine: &str, merge_on_read: bool) 
-> Vec<RecordBatch> {
         let tempdir = tempdir().unwrap();
         let table_path = local_file_path(tempdir.path());
         let bucket_dir = tempdir.path().join("bucket-0");
@@ -945,6 +945,14 @@ mod tests {
             write_test_deletion_file(&file_io, 
&local_file_path(&index_dir.join("dv")), &[1]).await;
 
         let table = dv_pk_table(&table_path, merge_engine);
+        let table = if merge_on_read {
+            table.copy_with_options(HashMap::from([(
+                "deletion-vectors.merge-on-read".to_string(),
+                "true".to_string(),
+            )]))
+        } else {
+            table
+        };
         let mut data_file =
             test_data_file::<crate::spec::DataFileMeta>("data.parquet", 3, 
file_size);
         data_file.delete_row_count = Some(0);
@@ -2099,7 +2107,7 @@ mod tests {
 
     #[tokio::test]
     async fn 
test_direct_table_read_reads_compacted_partial_update_with_deletion_vectors() {
-        let batches = read_compacted_dv_table("partial-update").await;
+        let batches = read_compacted_dv_table("partial-update", false).await;
 
         assert_eq!(collect_int_column(&batches, "id"), vec![1, 3]);
         assert_eq!(collect_int_column(&batches, "value"), vec![10, 30]);
@@ -2107,41 +2115,18 @@ mod tests {
 
     #[tokio::test]
     async fn 
test_direct_table_read_reads_compacted_aggregation_with_deletion_vectors() {
-        let batches = read_compacted_dv_table("aggregation").await;
+        let batches = read_compacted_dv_table("aggregation", false).await;
 
         assert_eq!(collect_int_column(&batches, "id"), vec![1, 3]);
         assert_eq!(collect_int_column(&batches, "value"), vec![10, 30]);
     }
 
-    #[test]
-    fn test_direct_table_read_rejects_partial_update_dv_merge_on_read() {
-        let table = dv_pk_table(
-            "/tmp/test-partial-update-dv-merge-on-read",
-            "partial-update",
-        )
-        .copy_with_options(HashMap::from([(
-            "deletion-vectors.merge-on-read".to_string(),
-            "true".to_string(),
-        )]));
-        let mut data_file = 
test_data_file::<crate::spec::DataFileMeta>("data.parquet", 1, 0);
-        data_file.delete_row_count = Some(0);
-        let split = DataSplitBuilder::new()
-            .with_snapshot(1)
-            .with_partition(BinaryRow::new(0))
-            .with_bucket(0)
-            
.with_bucket_path("/tmp/test-partial-update-dv-merge-on-read/bucket-0".to_string())
-            .with_total_buckets(1)
-            .with_data_files(vec![data_file])
-            .build()
-            .unwrap();
-
-        let result =
-            TableRead::new(&table, table.schema().fields().to_vec(), 
Vec::new()).to_arrow(&[split]);
-
-        assert!(matches!(
-            result,
-            Err(crate::Error::Unsupported { ref message })
-                if message.contains("merge-on-read")
-        ));
+    #[tokio::test]
+    async fn 
test_direct_table_read_supports_partial_update_and_aggregation_dv_merge_on_read()
 {
+        for engine in ["partial-update", "aggregation"] {
+            let batches = read_compacted_dv_table(engine, true).await;
+            assert_eq!(collect_int_column(&batches, "id"), vec![1, 3]);
+            assert_eq!(collect_int_column(&batches, "value"), vec![10, 30]);
+        }
     }
 }
diff --git a/crates/paimon/src/table/table_read.rs 
b/crates/paimon/src/table/table_read.rs
index ed1838d1..dde0f512 100644
--- a/crates/paimon/src/table/table_read.rs
+++ b/crates/paimon/src/table/table_read.rs
@@ -871,10 +871,9 @@ impl<'a> PaimonTableRead<'a> {
     /// 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
-    /// `Aggregation` can also be read raw because DVs already mask stale rows.
-    /// Plans that still need any per-key merge fail closed because mixing raw
-    /// and merged outputs would produce incorrect results.
+    /// dedup. Deletion-vector splits for any merge engine are read raw only
+    /// when their rows are fully materialized; otherwise the per-file DVs are
+    /// applied before the key merge.
     fn read_pk(
         &self,
         data_splits: &[DataSplit],
@@ -903,39 +902,9 @@ impl<'a> PaimonTableRead<'a> {
             return self.read_kv(data_splits, core_options);
         }
 
-        if matches!(
-            merge_engine,
-            MergeEngine::PartialUpdate | MergeEngine::Aggregation
-        ) {
-            let merge_engine_name = match merge_engine {
-                MergeEngine::PartialUpdate => "partial-update",
-                MergeEngine::Aggregation => "aggregation",
-                _ => unreachable!("guarded by partial-update/aggregation 
match"),
-            };
-            if core_options.deletion_vectors_merge_on_read() {
-                return Err(crate::Error::Unsupported {
-                    message: format!(
-                        "merge-engine={merge_engine_name} with 
deletion-vectors.merge-on-read=true is not supported"
-                    ),
-                });
-            }
-            if !data_splits
-                .iter()
-                .all(DataSplit::is_fully_materialized_pk_dv)
-            {
-                return Err(crate::Error::Unsupported {
-                    message: format!(
-                        "merge-engine={merge_engine_name} with deletion 
vectors can only read fully materialized compacted splits"
-                    ),
-                });
-            }
-            return self.read_raw(data_splits);
-        }
-
-        // Compacted deletion-vector splits read raw: their stale versions are
-        // masked directly by DVs. A split containing level-0 data goes through
-        // the key merge; KeyValueFileReader applies any attached per-file DVs
-        // before merging the uncompacted versions.
+        // Fully materialized deletion-vector splits read raw: their stale
+        // versions are masked directly by DVs. Other splits go through the key
+        // merge; KeyValueFileReader applies any attached per-file DVs first.
         let mut kv_splits = Vec::new();
         let mut raw_splits = Vec::new();
         for split in data_splits {
@@ -1643,12 +1612,12 @@ fn scalar_compare(
 /// planning treats the missing stat as "no deletes" for compatibility, so the
 /// read side must fall back to the merge reader, which drops them.
 ///
-/// Deletion-vector tables merge only splits containing level-0 files. Fully
-/// compacted splits stay on the raw path, while the merge reader applies any
-/// attached DVs before reconciling uncompacted key versions.
+/// Deletion-vector tables also merge any split that is not fully materialized,
+/// including level-0 data and legacy or retract-containing compacted files.
+/// The merge reader applies attached DVs before reconciling key versions.
 fn pk_split_needs_merge(split: &DataSplit, dv_enabled: bool) -> bool {
     if dv_enabled {
-        return split.data_files().iter().any(|f| f.level == 0);
+        return !split.is_fully_materialized_pk_dv();
     }
     !split.raw_convertible()
         || split
@@ -1883,10 +1852,18 @@ mod tests {
         let legacy = split(vec![file("a", 5, None)], true);
         assert!(pk_split_needs_merge(&legacy, false));
 
-        // Deletion-vector tables dispatch on level 0 only.
+        // DV reads can only bypass the merge when the split is known to hold
+        // fully materialized rows. Level, raw-convertibility and retract-row
+        // metadata all matter, including for caller-constructed splits.
         let dv_l0 = split(vec![file("a", 0, None)], false);
         assert!(pk_split_needs_merge(&dv_l0, true));
-        let dv_compacted = split(vec![file("a", 5, None)], false);
+        let dv_non_raw = split(vec![file("a", 5, Some(0))], false);
+        assert!(pk_split_needs_merge(&dv_non_raw, true));
+        let dv_legacy = split(vec![file("a", 5, None)], true);
+        assert!(pk_split_needs_merge(&dv_legacy, true));
+        let dv_retracts = split(vec![file("a", 5, Some(1))], true);
+        assert!(pk_split_needs_merge(&dv_retracts, true));
+        let dv_compacted = split(vec![file("a", 5, Some(0))], true);
         assert!(!pk_split_needs_merge(&dv_compacted, true));
     }
 

Reply via email to