This is an automated email from the ASF dual-hosted git repository.

yihua pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hudi-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new c9366eb2 perf(core): merge only matched keys in event-time 
copy-on-write upserts (#882)
c9366eb2 is described below

commit c9366eb288ed8904852803fb8ce43ac145cdd025
Author: Y Ethan Guo <[email protected]>
AuthorDate: Thu Oct 29 22:37:07 2026 -0700

    perf(core): merge only matched keys in event-time copy-on-write upserts 
(#882)
    
    With an ordering field, a copy-on-write upsert passed every stored row of a
    touched file group through the record merger, although only stored rows 
whose
    key is incoming can change. The merger now sees those rows plus the incoming
    ones; every other stored row stays in place. A file group with repeated
    stored keys still goes through the full merge. Rows are written in the order
    the commit-time path already uses.
    
    Closes #881
---
 crates/core/src/write/rewrite/cow.rs   |   8 +-
 crates/core/src/write/rewrite/rows.rs  | 130 +++++++++++++++++++++++++++++++--
 crates/core/tests/table_write_tests.rs | 111 ++++++++++++++++++++++++++++
 3 files changed, 242 insertions(+), 7 deletions(-)

diff --git a/crates/core/src/write/rewrite/cow.rs 
b/crates/core/src/write/rewrite/cow.rs
index a0b45e46..93ff5fa7 100644
--- a/crates/core/src/write/rewrite/cow.rs
+++ b/crates/core/src/write/rewrite/cow.rs
@@ -569,7 +569,13 @@ pub(super) fn merge_file_group_task(
                     concat_batches(&old.schema(), [&old, &incoming])?
                 };
                 let selected = if ctx.event_time && old.num_rows() > 0 {
-                    merge_with_event_time(ctx.hudi_configs.clone(), &old, 
&incoming, &combined)?
+                    merge_with_event_time(
+                        ctx.hudi_configs.clone(),
+                        &combined,
+                        old.num_rows(),
+                        &old_by_key,
+                        &incoming_keys,
+                    )?
                 } else {
                     selected
                 };
diff --git a/crates/core/src/write/rewrite/rows.rs 
b/crates/core/src/write/rewrite/rows.rs
index bbce1e07..e6d628be 100644
--- a/crates/core/src/write/rewrite/rows.rs
+++ b/crates/core/src/write/rewrite/rows.rs
@@ -21,18 +21,40 @@
 
 use super::*;
 
+/// Indices into `combined` (the group's `num_old` stored rows, then the
+/// incoming rows) that survive an event-time merge, in `combined` order.
+///
+/// Only the stored rows whose key is incoming can change, so the merger sees
+/// just those and the incoming rows. A group with a repeated stored key sends
+/// every stored row through it, since the merger also collapses repeats.
 pub(super) fn merge_with_event_time(
     hudi_configs: std::sync::Arc<crate::config::HudiConfigs>,
-    old: &RecordBatch,
-    incoming: &RecordBatch,
     combined: &RecordBatch,
+    num_old: usize,
+    old_by_key: &HashMap<String, u32>,
+    incoming_keys: &[String],
 ) -> Result<Vec<u32>> {
-    let merger = RecordMerger::new(old.schema(), hudi_configs);
+    let mut touched = vec![old_by_key.len() < num_old; num_old];
+    for key in incoming_keys {
+        if let Some(&index) = old_by_key.get(key) {
+            touched[index as usize] = true;
+        }
+    }
+    let (mut candidates, mut selected): (Vec<u32>, Vec<u32>) =
+        (0..num_old as u32).partition(|&index| touched[index as usize]);
+    candidates.extend(num_old as u32..combined.num_rows() as u32);
+    let candidate_rows = take_batch(combined, &candidates)?;
+    let merger = RecordMerger::new(combined.schema(), hudi_configs);
     let merged = 
merger.merge_record_batches(RecordBatches::new_with_data_batches([
-        old.clone(),
-        incoming.clone(),
+        candidate_rows.clone(),
     ]))?;
-    selected_indices_for_merged(combined, &merged)
+    selected.extend(
+        selected_indices_for_merged(&candidate_rows, &merged)?
+            .into_iter()
+            .map(|index| candidates[index as usize]),
+    );
+    selected.sort_unstable();
+    Ok(selected)
 }
 
 pub(super) fn selected_indices_for_merged(
@@ -480,3 +502,99 @@ pub(super) fn partial_merge(
     }
     RecordBatch::try_new(combined.schema(), columns).map_err(Into::into)
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::config::HudiConfigs;
+    use arrow::array::Int64Array;
+    use arrow_schema::{DataType, Field, Schema};
+    use std::sync::Arc;
+
+    /// Rows are `(commit time, key, ts)`; the seqno is unique per row.
+    fn combined(rows: &[(&str, &str, i64)]) -> RecordBatch {
+        let schema = Arc::new(Schema::new(vec![
+            Field::new(MetaField::CommitTime.as_ref(), DataType::Utf8, false),
+            Field::new(MetaField::CommitSeqno.as_ref(), DataType::Utf8, false),
+            Field::new(MetaField::RecordKey.as_ref(), DataType::Utf8, false),
+            Field::new("ts", DataType::Int64, false),
+        ]));
+        let seqnos: Vec<String> = rows
+            .iter()
+            .enumerate()
+            .map(|(index, (commit, _, _))| format!("{commit}_0-{index}-0"))
+            .collect();
+        RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(StringArray::from_iter_values(rows.iter().map(|r| 
r.0))),
+                Arc::new(StringArray::from(seqnos)),
+                Arc::new(StringArray::from_iter_values(rows.iter().map(|r| 
r.1))),
+                Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| 
r.2))),
+            ],
+        )
+        .unwrap()
+    }
+
+    /// The selection over every row of the group, sorted into `combined` 
order.
+    fn full_merge(configs: Arc<HudiConfigs>, combined: &RecordBatch) -> 
Vec<u32> {
+        let merged = RecordMerger::new(combined.schema(), configs)
+            
.merge_record_batches(RecordBatches::new_with_data_batches([combined.clone()]))
+            .unwrap();
+        let mut selected = selected_indices_for_merged(combined, 
&merged).unwrap();
+        selected.sort_unstable();
+        selected
+    }
+
+    #[test]
+    fn test_merge_with_event_time_matches_full_merge() {
+        let configs = Arc::new(HudiConfigs::new([
+            (RecordMergeStrategy, "OVERWRITE_WITH_LATEST".to_string()),
+            (OrderingFields, "ts".to_string()),
+        ]));
+        // Incoming: lower (a), equal (b) and higher (c) ordering than stored,
+        // two versions of d, and a new key f twice; e is untouched.
+        let incoming = [
+            ("002", "a", 50),
+            ("002", "b", 100),
+            ("002", "c", 150),
+            ("002", "d", 120),
+            ("002", "d", 130),
+            ("002", "f", 10),
+            ("002", "f", 20),
+        ];
+        let unique = vec![
+            ("001", "e", 100),
+            ("001", "a", 100),
+            ("001", "b", 100),
+            ("001", "c", 100),
+            ("001", "d", 100),
+        ];
+        let mut repeated = unique.clone();
+        repeated.extend([("001", "e", 90), ("001", "a", 110)]);
+        for old in [unique, repeated] {
+            let rows: Vec<_> = 
old.iter().chain(incoming.iter()).copied().collect();
+            let combined = combined(&rows);
+            let keys = keys(&combined, MetaField::RecordKey.as_ref()).unwrap();
+            let num_old = old.len();
+            let old_by_key: HashMap<String, u32> = keys[..num_old]
+                .iter()
+                .enumerate()
+                .map(|(index, key)| (key.clone(), index as u32))
+                .collect();
+            let selected = merge_with_event_time(
+                configs.clone(),
+                &combined,
+                num_old,
+                &old_by_key,
+                &keys[num_old..],
+            )
+            .unwrap();
+            assert_eq!(selected, full_merge(configs.clone(), &combined), 
"{old:?}");
+            if num_old == 5 {
+                // e and a stored; b, c, the later d and the later f incoming.
+                assert_eq!(selected, vec![0, 1, 6, 7, 9, 11]);
+            }
+        }
+    }
+}
diff --git a/crates/core/tests/table_write_tests.rs 
b/crates/core/tests/table_write_tests.rs
index 533dbd82..fa3e9c0c 100644
--- a/crates/core/tests/table_write_tests.rs
+++ b/crates/core/tests/table_write_tests.rs
@@ -885,6 +885,117 @@ async fn test_upsert_uses_event_time_ordering() {
     assert_eq!(values.value(0), 4);
 }
 
+/// Rows are `(id, value, event_time, _hoodie_is_deleted)`.
+fn ordered_flagged_batch(rows: Vec<(&str, i64, i64, bool)>) -> RecordBatch {
+    let schema = Arc::new(Schema::new(vec![
+        Field::new("id", DataType::Utf8, false),
+        Field::new("value", DataType::Int64, false),
+        Field::new("event_time", DataType::Int64, false),
+        Field::new("_hoodie_is_deleted", DataType::Boolean, true),
+    ]));
+    RecordBatch::try_new(
+        schema,
+        vec![
+            Arc::new(StringArray::from_iter_values(rows.iter().map(|r| r.0))),
+            Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| r.1))),
+            Arc::new(Int64Array::from_iter_values(rows.iter().map(|r| r.2))),
+            Arc::new(BooleanArray::from(
+                rows.iter().map(|r| r.3).collect::<Vec<_>>(),
+            )),
+        ],
+    )
+    .unwrap()
+}
+
+fn ordered_flagged_rows(batches: &[RecordBatch]) -> Vec<(String, i64, i64, 
bool)> {
+    let mut rows = Vec::new();
+    for batch in batches {
+        let column = |name: &str| batch.column_by_name(name).unwrap().as_any();
+        let ids = column("id").downcast_ref::<StringArray>().unwrap();
+        let values = column("value").downcast_ref::<Int64Array>().unwrap();
+        let times = column("event_time").downcast_ref::<Int64Array>().unwrap();
+        let deleted = column("_hoodie_is_deleted")
+            .downcast_ref::<BooleanArray>()
+            .unwrap();
+        for row in 0..batch.num_rows() {
+            rows.push((
+                ids.value(row).to_string(),
+                values.value(row),
+                times.value(row),
+                deleted.value(row),
+            ));
+        }
+    }
+    rows.sort();
+    rows
+}
+
+/// Copy-on-write event-time upserts into a group where most stored rows are
+/// untouched: each touched key resolves by ordering value (ties go to the
+/// incoming row), a winning delete marker removes the row, in-batch duplicates
+/// keep the highest ordering value, and untouched rows pass through.
+#[tokio::test]
+async fn test_cow_event_time_upsert_merges_only_touched_keys() {
+    let dir = tempdir().unwrap();
+    let mut table = Table::create(dir.path().to_str().unwrap())
+        .with_table_name("t")
+        .with_record_key_fields(["id"])
+        .with_ordering_fields(["event_time"])
+        .with_populates_meta_fields(true)
+        .create()
+        .await
+        .unwrap();
+    let stored: Vec<_> = ["a", "b", "c", "d", "e", "f", "g", "h"]
+        .into_iter()
+        .zip(1..)
+        .map(|(id, value)| (id, value, 100, false))
+        .collect();
+    table.upsert([ordered_flagged_batch(stored)]).await.unwrap();
+    table
+        .upsert([ordered_flagged_batch(vec![
+            ("a", 10, 50, false),
+            ("b", 20, 100, false),
+            ("c", 30, 150, false),
+            ("c", 31, 120, false),
+            ("d", 40, 150, true),
+            ("e", 50, 50, true),
+            ("i", 90, 10, false),
+            ("j", 100, 10, false),
+            ("j", 101, 20, false),
+        ])])
+        .await
+        .unwrap();
+    table
+        .upsert_with(
+            [ordered_flagged_batch(vec![
+                ("g", 70, 50, false),
+                ("h", 80, 200, false),
+            ])],
+            UpsertOptions {
+                update_columns: Some(vec!["value".to_string()]),
+            },
+        )
+        .await
+        .unwrap();
+
+    let batches = table.read(&ReadOptions::new()).await.unwrap();
+    let expected = [
+        ("a", 1, 100),
+        ("b", 20, 100),
+        ("c", 30, 150),
+        ("e", 5, 100),
+        ("f", 6, 100),
+        ("g", 7, 100),
+        ("h", 80, 100),
+        ("i", 90, 10),
+        ("j", 101, 20),
+    ]
+    .into_iter()
+    .map(|(id, value, event_time)| (id.to_string(), value, event_time, false))
+    .collect::<Vec<_>>();
+    assert_eq!(ordered_flagged_rows(&batches), expected);
+}
+
 #[tokio::test]
 async fn test_merge_on_read_writes_parquet_logs_and_merges_snapshot() {
     let dir = tempdir().unwrap();

Reply via email to