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 0c32ab00 fix(core): preserve partial-update deletion state (#1139)
0c32ab00 is described below

commit 0c32ab002397a70d62abdb1d9244cd853568273c
Author: reswqa <[email protected]>
AuthorDate: Sun Oct 18 16:00:45 2026 +0800

    fix(core): preserve partial-update deletion state (#1139)
---
 crates/paimon/src/table/sort_merge.rs              | 147 +++++----
 .../tests/pk_partial_update_delete_state_test.rs   | 347 +++++++++++++++++++++
 crates/paimon/tests/rowkind_field_test.rs          |   8 +-
 3 files changed, 443 insertions(+), 59 deletions(-)

diff --git a/crates/paimon/src/table/sort_merge.rs 
b/crates/paimon/src/table/sort_merge.rs
index 665ff7d9..9bb072e2 100644
--- a/crates/paimon/src/table/sort_merge.rs
+++ b/crates/paimon/src/table/sort_merge.rs
@@ -292,39 +292,6 @@ struct RuntimeSequenceGroup {
 }
 
 impl PartialUpdateMergeFunction {
-    fn materialize_source_row(
-        batch_idx: usize,
-        row_idx: usize,
-        batch_buffer: &[BufferedBatch],
-        source_output_col_indices: &[usize],
-        output_schema: &SchemaRef,
-    ) -> crate::Result<RecordBatch> {
-        let columns: Vec<ArrayRef> = output_schema
-            .fields()
-            .iter()
-            .enumerate()
-            .map(|(index, field)| {
-                let column = batch_buffer[batch_idx]
-                    .column_for_output(index, source_output_col_indices)
-                    .slice(row_idx, 1);
-                if !field.is_nullable() && column.is_null(0) {
-                    return Err(Error::DataInvalid {
-                        message: format!(
-                            "Partial-update delete row has NULL for 
non-nullable field '{}'",
-                            field.name()
-                        ),
-                        source: None,
-                    });
-                }
-                Ok(column)
-            })
-            .collect::<crate::Result<_>>()?;
-        RecordBatch::try_new(output_schema.clone(), columns).map_err(|error| 
Error::DataInvalid {
-            message: format!("Failed to materialize partial-update delete row: 
{error}"),
-            source: Some(Box::new(error)),
-        })
-    }
-
     /// Java replaces the current row with the DELETE value. Later inserts may
     /// leave some columns null, so their merge must start from that value.
     fn replace_with_source(
@@ -535,7 +502,7 @@ impl PartialUpdateMergeFunction {
         }
         let mut saw_add = false;
         let mut current_delete_row = false;
-        let mut last_retract_source = None;
+        let mut saw_retract = false;
         let mut latest_row = None;
 
         for row_idx in ordered_row_indices {
@@ -546,7 +513,7 @@ impl PartialUpdateMergeFunction {
                     continue;
                 }
                 latest_row = Some(row);
-                if !saw_add && last_retract_source.is_none() {
+                if !saw_add && !saw_retract {
                     // Java initializes the row from the first retract before
                     // applying sequence-group retractions. That row also
                     // becomes the DELETE payload when no insert follows.
@@ -560,8 +527,7 @@ impl PartialUpdateMergeFunction {
                     )?;
                     group_sequence_rows.fill(Some((row.batch_idx, 
row.row_idx)));
                 }
-                last_retract_source = Some((row.batch_idx, row.row_idx));
-                current_delete_row = false;
+                saw_retract = true;
                 if self.sequence_group_enabled {
                     let mut full_delete = false;
                     for (group_idx, group) in 
self.sequence_groups.iter().enumerate() {
@@ -649,7 +615,9 @@ impl PartialUpdateMergeFunction {
                 });
             }
             saw_add = true;
-            current_delete_row = false;
+            if !self.sequence_group_enabled {
+                current_delete_row = false;
+            }
             latest_row = Some(row);
 
             for (output_col_idx, selected) in 
selected_by_col.iter_mut().enumerate() {
@@ -715,6 +683,13 @@ impl PartialUpdateMergeFunction {
                     continue;
                 }
 
+                if group
+                    .sequence_indices
+                    .iter()
+                    .any(|index| 
self.sequence_group_partial_delete.contains(index))
+                {
+                    current_delete_row = false;
+                }
                 group_sequence_rows[group_idx] = Some((row.batch_idx, 
row.row_idx));
                 for &output_col_idx in &group.sequence_indices {
                     selected_by_col[output_col_idx] = Some((row.batch_idx, 
row.row_idx));
@@ -722,30 +697,17 @@ impl PartialUpdateMergeFunction {
             }
         }
 
-        if current_delete_row {
-            return match last_retract_source {
-                Some((batch_idx, row_idx)) => 
Ok(MergeResult::MaterializedDeleteRow(
-                    Self::materialize_source_row(
-                        batch_idx,
-                        row_idx,
-                        batch_buffer,
-                        source_output_col_indices,
-                        output_schema,
-                    )?,
-                )),
-                None => Ok(MergeResult::Omit),
-            };
-        }
-        if !saw_add && last_retract_source.is_none() {
+        if !saw_add && !saw_retract {
             return Ok(MergeResult::Omit);
         }
+        let deleted = current_delete_row || !saw_add;
 
         let output_columns: Vec<ArrayRef> = output_schema
             .fields()
             .iter()
             .enumerate()
             .map(|(output_col_idx, field)| {
-                if let Some(column) = merged_metadata_column(field, 
latest_row.unwrap(), !saw_add) {
+                if let Some(column) = merged_metadata_column(field, 
latest_row.unwrap(), deleted) {
                     return Ok(column);
                 }
                 let column = match aggregators
@@ -761,7 +723,7 @@ impl PartialUpdateMergeFunction {
                         None => new_null_array(field.data_type(), 1),
                     },
                 };
-                if saw_add && !field.is_nullable() && column.is_null(0) {
+                if (saw_add || current_delete_row) && !field.is_nullable() && 
column.is_null(0) {
                     return Err(Error::DataInvalid {
                         message: format!(
                             "merge-engine=partial-update produced NULL for 
non-nullable field '{}'",
@@ -781,10 +743,10 @@ impl PartialUpdateMergeFunction {
             }
         })?;
 
-        if saw_add {
-            Ok(MergeResult::MaterializedRow(batch))
-        } else {
+        if deleted {
             Ok(MergeResult::MaterializedDeleteRow(batch))
+        } else {
+            Ok(MergeResult::MaterializedRow(batch))
         }
     }
 }
@@ -3448,6 +3410,75 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_partial_update_delete_preserves_payload_and_latest_metadata() {
+        let schema = make_schema();
+        let batch = make_batch_with_kind(
+            &schema,
+            vec![1, 1, 1],
+            vec![10, 11, 12],
+            vec![0, 3, 1],
+            vec![Some("initial"), Some("deleted"), Some("before")],
+        );
+        let rows = [0, 3, 1]
+            .into_iter()
+            .enumerate()
+            .map(|(row_idx, value_kind)| MergeRow {
+                batch_idx: 0,
+                row_idx,
+                sequence_number: 10 + row_idx as i64,
+                value_kind,
+                user_sequence: None,
+            })
+            .collect::<Vec<_>>();
+        let options = HashMap::from([
+            ("merge-engine".into(), "partial-update".into()),
+            (
+                "partial-update.remove-record-on-delete".into(),
+                "true".into(),
+            ),
+        ]);
+        let merge = PartialUpdateMergeFunction::new(&options, 
"test_table").unwrap();
+        let result = merge
+            .merge(
+                &rows,
+                &[BufferedBatch::Source(batch)],
+                &[0, 1, 2, 3],
+                &schema,
+            )
+            .unwrap();
+        let MergeResult::MaterializedDeleteRow(deleted) = result else {
+            panic!("expected a materialized delete row");
+        };
+        assert_eq!(
+            deleted
+                .column(1)
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap()
+                .value(0),
+            12
+        );
+        assert_eq!(
+            deleted
+                .column(2)
+                .as_any()
+                .downcast_ref::<Int8Array>()
+                .unwrap()
+                .value(0),
+            RowKind::Delete.to_value()
+        );
+        assert_eq!(
+            deleted
+                .column(3)
+                .as_any()
+                .downcast_ref::<StringArray>()
+                .unwrap()
+                .value(0),
+            "deleted"
+        );
+    }
+
     #[tokio::test]
     async fn 
test_partial_update_delete_value_is_baseline_for_later_sparse_insert() {
         let schema = make_schema();
diff --git a/crates/paimon/tests/pk_partial_update_delete_state_test.rs 
b/crates/paimon/tests/pk_partial_update_delete_state_test.rs
new file mode 100644
index 00000000..9ffc819f
--- /dev/null
+++ b/crates/paimon/tests/pk_partial_update_delete_state_test.rs
@@ -0,0 +1,347 @@
+// 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 std::sync::Arc;
+
+use arrow_array::{Array, ArrayRef, Int32Array, Int8Array, RecordBatch};
+use arrow_schema::{DataType as ArrowType, Field, Schema as ArrowSchema};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, IntType, RowKind, Schema, TableSchema, 
VALUE_KIND_FIELD_NAME};
+use paimon::table::Table;
+
+const FIELDS: [&str; 6] = [
+    "id",
+    "seq_left",
+    "left_value",
+    "seq_right",
+    "right_value",
+    "seq_tie",
+];
+
+#[derive(Clone, Copy, Debug)]
+struct Event {
+    values: [Option<i32>; 6],
+    kind: RowKind,
+}
+
+const INITIAL: Event = Event {
+    values: [Some(1), Some(1), Some(10), Some(1), Some(20), Some(1)],
+    kind: RowKind::Insert,
+};
+const DELETED: Event = Event {
+    values: [Some(1), Some(2), Some(10), None, None, Some(1)],
+    kind: RowKind::Delete,
+};
+const RESTORED: Event = Event {
+    values: [Some(1), Some(3), Some(40), None, None, Some(1)],
+    kind: RowKind::Insert,
+};
+
+fn event(
+    kind: RowKind,
+    left: (Option<i32>, Option<i32>),
+    right: (Option<i32>, Option<i32>),
+    tie: Option<i32>,
+) -> Event {
+    Event {
+        values: [Some(1), left.0, left.1, right.0, right.1, tie],
+        kind,
+    }
+}
+
+fn group_options(composite: bool, aggregate: bool) -> Vec<(&'static str, 
&'static str)> {
+    let mut options = vec![
+        (
+            if composite {
+                "fields.seq_left,seq_tie.sequence-group"
+            } else {
+                "fields.seq_left.sequence-group"
+            },
+            "left_value",
+        ),
+        ("fields.seq_right.sequence-group", "right_value"),
+        ("partial-update.remove-record-on-sequence-group", "seq_left"),
+    ];
+    if aggregate {
+        options.push(("fields.right_value.aggregate-function", "sum"));
+    }
+    options
+}
+
+async fn table(options: &[(&str, &str)]) -> Table {
+    let mut builder = Schema::builder()
+        .primary_key(["id"])
+        .option("bucket", "1")
+        .option("merge-engine", "partial-update");
+    for field in FIELDS {
+        builder = builder.column(field, DataType::Int(IntType::new()));
+    }
+    for (key, value) in options {
+        builder = builder.option(*key, *value);
+    }
+    let schema = TableSchema::new(0, &builder.build().unwrap());
+    let path = format!("memory:/partial-update-delete-{}", 
uuid::Uuid::new_v4());
+    let (file_io, table) = memory_table(&path, schema);
+    setup_dirs(&file_io, &path).await;
+    persist_table_schema(&file_io, &path, table.schema()).await;
+    table
+}
+
+async fn write(table: &Table, events: &[Event]) {
+    let mut fields = FIELDS
+        .iter()
+        .map(|name| Field::new(*name, ArrowType::Int32, *name != "id"))
+        .collect::<Vec<_>>();
+    fields.push(Field::new(VALUE_KIND_FIELD_NAME, ArrowType::Int8, false));
+    let mut columns = (0..FIELDS.len())
+        .map(|index| {
+            Arc::new(Int32Array::from(
+                events
+                    .iter()
+                    .map(|event| event.values[index])
+                    .collect::<Vec<_>>(),
+            )) as ArrayRef
+        })
+        .collect::<Vec<_>>();
+    columns.push(Arc::new(Int8Array::from(
+        events
+            .iter()
+            .map(|event| event.kind.to_value())
+            .collect::<Vec<_>>(),
+    )));
+    let batch = RecordBatch::try_new(Arc::new(ArrowSchema::new(fields)), 
columns).unwrap();
+    let builder = table.new_write_builder();
+    let mut writer = builder.new_write().unwrap();
+    writer.write_arrow_batch(&batch).await.unwrap();
+    let messages = writer.prepare_commit().await.unwrap();
+    builder.new_commit().commit(messages).await.unwrap();
+}
+
+async fn write_layout(table: &Table, events: &[Event], boundaries: usize) {
+    let mut start = 0;
+    for end in 1..=events.len() {
+        if end == events.len() || boundaries & (1 << (end - 1)) != 0 {
+            write(table, &events[start..end]).await;
+            start = end;
+        }
+    }
+}
+
+async fn assert_projections(table: &Table, expected: Option<[Option<i32>; 6]>, 
boundaries: usize) {
+    for projection in [FIELDS.to_vec(), vec!["id"], vec!["right_value"], 
vec![]] {
+        let mut builder = table.new_read_builder();
+        builder.with_projection(&projection).unwrap();
+        let plan = builder.new_scan().plan().await.unwrap();
+        let batches: Vec<RecordBatch> = builder
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        let mut actual = Vec::new();
+        for batch in batches {
+            for row_index in 0..batch.num_rows() {
+                actual.push(
+                    batch
+                        .columns()
+                        .iter()
+                        .map(|column| {
+                            let array = 
column.as_any().downcast_ref::<Int32Array>().unwrap();
+                            (!array.is_null(row_index)).then(|| 
array.value(row_index))
+                        })
+                        .collect::<Vec<_>>(),
+                );
+            }
+        }
+        let expected_rows: Vec<Vec<Option<i32>>> = expected
+            .map(|values| {
+                vec![projection
+                    .iter()
+                    .map(|name| values[FIELDS.iter().position(|field| field == 
name).unwrap()])
+                    .collect()]
+            })
+            .unwrap_or_default();
+        assert_eq!(
+            actual, expected_rows,
+            "boundaries={boundaries:b}, projection={projection:?}"
+        );
+    }
+}
+
+async fn assert_deleted_then_restored(
+    options: &[(&str, &str)],
+    events: &[Event],
+    restored: Event,
+    expected: [Option<i32>; 6],
+) {
+    for boundaries in 0..(1 << (events.len() - 1)) {
+        let table = table(options).await;
+        write_layout(&table, events, boundaries).await;
+        assert_projections(&table, None, boundaries).await;
+        write(&table, &[restored]).await;
+        assert_projections(&table, Some(expected), boundaries).await;
+    }
+}
+
+async fn assert_all_layouts(
+    options: &[(&str, &str)],
+    events: &[Event],
+    expected: [Option<i32>; 6],
+) {
+    for boundaries in 0..(1 << (events.len() - 1)) {
+        let table = table(options).await;
+        write_layout(&table, events, boundaries).await;
+        assert_projections(&table, Some(expected), boundaries).await;
+    }
+}
+
+#[tokio::test]
+async fn delete_state_survives_other_group_updates() {
+    for composite in [false, true] {
+        for kind in [RowKind::Insert, RowKind::UpdateAfter] {
+            let trailing = event(kind, (None, None), (Some(3), Some(30)), 
None);
+            assert_deleted_then_restored(
+                &group_options(composite, false),
+                &[INITIAL, DELETED, trailing],
+                RESTORED,
+                [Some(1), Some(3), Some(40), Some(3), Some(30), Some(1)],
+            )
+            .await;
+        }
+    }
+}
+
+#[tokio::test]
+async fn delete_state_survives_old_selected_group_updates() {
+    for composite in [false, true] {
+        for kind in [RowKind::Insert, RowKind::UpdateAfter] {
+            let trailing = event(kind, (Some(1), Some(99)), (None, None), 
Some(1));
+            assert_deleted_then_restored(
+                &group_options(composite, false),
+                &[INITIAL, DELETED, trailing],
+                RESTORED,
+                RESTORED.values,
+            )
+            .await;
+        }
+    }
+}
+
+#[tokio::test]
+async fn delete_state_survives_subsequent_retractions() {
+    for composite in [false, true] {
+        for kind in [RowKind::Delete, RowKind::UpdateBefore] {
+            for (trailing, right_sequence) in [
+                (
+                    event(kind, (None, None), (Some(3), Some(30)), None),
+                    Some(3),
+                ),
+                (
+                    event(kind, (Some(1), Some(99)), (None, None), Some(1)),
+                    None,
+                ),
+                (event(kind, (None, None), (None, None), None), None),
+            ] {
+                assert_deleted_then_restored(
+                    &group_options(composite, false),
+                    &[INITIAL, DELETED, trailing],
+                    RESTORED,
+                    [Some(1), Some(3), Some(40), right_sequence, None, 
Some(1)],
+                )
+                .await;
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn whole_row_delete_ignores_update_before_payload() {
+    let before = event(
+        RowKind::UpdateBefore,
+        (Some(9), Some(99)),
+        (Some(9), Some(99)),
+        Some(9),
+    );
+    let restored = event(RowKind::Insert, (None, None), (Some(3), Some(30)), 
None);
+    assert_deleted_then_restored(
+        &[("partial-update.remove-record-on-delete", "true")],
+        &[INITIAL, DELETED, before],
+        restored,
+        [Some(1), Some(2), Some(10), Some(3), Some(30), Some(1)],
+    )
+    .await;
+}
+
+#[tokio::test]
+async fn accepted_group_updates_restore_all_fields_across_merge_boundaries() {
+    let other = event(RowKind::Insert, (None, None), (Some(3), Some(30)), 
None);
+    for composite in [false, true] {
+        for kind in [RowKind::Insert, RowKind::UpdateAfter] {
+            for (sequence, tie) in [(2, 1), (2, 2), (3, 0)] {
+                let restored = event(kind, (Some(sequence), Some(40)), (None, 
None), Some(tie));
+                let old_delete = event(RowKind::Delete, (Some(1), Some(99)), 
(None, None), Some(1));
+                assert_all_layouts(
+                    &group_options(composite, false),
+                    &[INITIAL, DELETED, other, restored, old_delete],
+                    [
+                        Some(1),
+                        Some(sequence),
+                        Some(40),
+                        Some(3),
+                        Some(30),
+                        Some(tie),
+                    ],
+                )
+                .await;
+            }
+        }
+    }
+}
+
+#[tokio::test]
+async fn deleted_aggregate_state_survives_merge_boundaries() {
+    let other = event(RowKind::Insert, (None, None), (Some(3), Some(30)), 
None);
+    let retract = event(RowKind::Delete, (None, None), (Some(4), Some(5)), 
None);
+    for composite in [false, true] {
+        assert_deleted_then_restored(
+            &group_options(composite, true),
+            &[INITIAL, DELETED, other, retract],
+            RESTORED,
+            [Some(1), Some(3), Some(40), Some(4), Some(25), Some(1)],
+        )
+        .await;
+    }
+}
+
+#[tokio::test]
+async fn ignore_delete_preserves_existing_group_values() {
+    let mut options = group_options(true, false);
+    options.retain(|(key, _)| *key != 
"partial-update.remove-record-on-sequence-group");
+    options.push(("ignore-delete", "true"));
+    let other = event(RowKind::Insert, (None, None), (Some(3), Some(30)), 
None);
+    assert_all_layouts(
+        &options,
+        &[INITIAL, DELETED, other],
+        [Some(1), Some(1), Some(10), Some(3), Some(30), Some(1)],
+    )
+    .await;
+}
diff --git a/crates/paimon/tests/rowkind_field_test.rs 
b/crates/paimon/tests/rowkind_field_test.rs
index d4cd97fa..bb1f18fc 100644
--- a/crates/paimon/tests/rowkind_field_test.rs
+++ b/crates/paimon/tests/rowkind_field_test.rs
@@ -416,7 +416,13 @@ async fn 
partial_update_last_non_null_reverses_over_null_delete_payload() {
     write_batch(&table, &partial_update_batch(1, Some(100), 0)).await;
     write_batch(&table, &partial_update_batch(2, None, 3)).await;
     write_batch(&table, &partial_update_batch(1, Some(5), 0)).await;
-    assert_eq!(scan_single_version_value(&table).await, (2, Some(5)));
+    let builder = table.new_read_builder();
+    let plan = builder.new_scan().plan().await.unwrap();
+    let mut stream = 
builder.new_read().unwrap().to_arrow(plan.splits()).unwrap();
+    assert!(stream.next().await.is_none());
+
+    write_batch(&table, &partial_update_batch(3, None, 0)).await;
+    assert_eq!(scan_single_version_value(&table).await, (3, Some(5)));
 }
 
 #[tokio::test]

Reply via email to