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 b77f5ce5 fix(core): fold partial updates in both ordering directions
(#679)
b77f5ce5 is described below
commit b77f5ce51a07c7faf422f7de81083acd4e857e3e
Author: Lin Liu <[email protected]>
AuthorDate: Tue Sep 1 17:20:55 2026 -0700
fix(core): fold partial updates in both ordering directions (#679)
---
.../src/file_group/reader_v2/buffer/key_based.rs | 193 ++++++++++++++++++---
crates/core/tests/gold_parity_tests.rs | 10 ++
.../mor/avro/table_partial_update_event_time.sql | 74 ++++++++
.../mor/avro/table_partial_update_event_time.zip | Bin 0 -> 16147 bytes
crates/test/src/lib.rs | 21 +++
5 files changed, 272 insertions(+), 26 deletions(-)
diff --git a/crates/core/src/file_group/reader_v2/buffer/key_based.rs
b/crates/core/src/file_group/reader_v2/buffer/key_based.rs
index 676f31f7..92a4aa21 100644
--- a/crates/core/src/file_group/reader_v2/buffer/key_based.rs
+++ b/crates/core/src/file_group/reader_v2/buffer/key_based.rs
@@ -1458,37 +1458,50 @@ impl HoodieFileGroupRecordBuffer for
KeyBasedFileGroupRecordBuffer {
// is the `else`:
//
// Partial-update (IS_PARTIAL / KEEP_VALUES): the incoming record
carries only
- // a subset of columns; overlay its present columns onto the prior
buffered
- // record for this key, producing the UNION of their columns. The
result
- // stays narrower than the reader schema until a base row or later
update
- // supplies the rest, so base-vs-log can still fill the gaps. With no
prior
- // data record it stays narrow and is padded at the base/drain step.
+ // a subset of columns; fold it together with the prior buffered
record for
+ // this key, producing the UNION of their columns. The result stays
narrower
+ // than the reader schema until a base row or later update supplies
the rest,
+ // so base-vs-log can still fill the gaps. With no prior data record
it stays
+ // narrow and is padded at the base/drain step.
let inc_is_partial = match (record.get_record(),
self.base.reader_schema.as_ref()) {
(Some(ib), Some(target)) => schema_is_partial(&ib.schema(),
target),
_ => false,
};
if inc_is_partial {
- let record = match self.base.records.get(key)? {
- Some(prior) => match (record.get_record(), prior.get_record())
{
- (Some(ib), Some(prior_batch)) => {
- let union = union_schema(&ib.schema(),
&prior_batch.schema());
- let merged = overlay_partial_over_prior(&ib,
&prior_batch, &union)?;
- BufferedRecord::new_data(record.record_key, merged,
record.ordering_value)
- }
- // Prior is a delete tombstone (no columns) → keep
incoming as-is.
- _ => record,
- },
- None => record,
- };
- // Single-probe merge (perf): probe `key` once via
`merge_in_place` and
- // overwrite in place, instead of get(probe+clone) → delta_merge →
insert
- // (probe+clone+probe). The merger only reads `existing` by
reference. A2
- // semantics preserved: the merged BatchRef payload is stored
in-memory
- // (IPC serialization deferred to spill only).
+ // Mirrors Java `DefaultSparkRecordMerger.partialMerge`, which
folds in BOTH
+ // directions: the ordering WINNER supplies every column it
carries and the
+ // loser only the columns the winner omits. The union takes the
winner's
+ // ordering value, so a later update is compared against the
winner's
+ // position on the timeline rather than the loser's.
+ //
+ // Single-probe merge (perf): the fold runs inside
`merge_in_place`, which
+ // probes `key` once and overwrites the slot in place rather than
+ // get(probe+clone) → merge → insert(probe+clone+probe). A2
semantics
+ // preserved: the merged payload is stored in-memory (IPC
serialization
+ // deferred to spill only).
let merger = &self.base.buffered_record_merger;
- self.base
- .records
- .merge_in_place(key, |existing| merger.delta_merge(&record,
existing))?;
+ let commit_time_ordering = self.base.record_merge_mode ==
"COMMIT_TIME_ORDERING";
+ self.base.records.merge_in_place(key, |existing| {
+ // Folding needs columns on both sides. With no prior, or a
prior that
+ // is a delete tombstone, the ordering comparison alone
decides.
+ let Some((prior, prior_batch, ib)) = existing
+ .and_then(|prior| Some((prior, prior.get_record()?,
record.get_record()?)))
+ else {
+ return merger.delta_merge(&record, existing);
+ };
+ let union = union_schema(&ib.schema(), &prior_batch.schema());
+ let new_wins = commit_time_ordering ||
should_keep_newer_record(prior, &record);
+ let (winner, loser, ordering_value) = if new_wins {
+ (&ib, &prior_batch, record.ordering_value.clone())
+ } else {
+ (&prior_batch, &ib, prior.ordering_value.clone())
+ };
+ Ok(Some(BufferedRecord::new_data(
+ record.record_key.clone(),
+ overlay_partial_over_prior(winner, loser, &union)?,
+ ordering_value,
+ )))
+ })?;
} else if self.ignore_defaults || self.unavailable_value.is_some() {
// Full-schema partial-update (IGNORE_DEFAULTS or
FILL_UNAVAILABLE),
// log-vs-log blend. Mirrors Java
`EventTimePartialRecordMerger.deltaMerge`,
@@ -6200,7 +6213,10 @@ mod tests {
}
fn build_pu_event_buffer() -> KeyBasedFileGroupRecordBuffer {
- let merge_mode = "EVENT_TIME_ORDERING";
+ build_pu_buffer("EVENT_TIME_ORDERING")
+ }
+
+ fn build_pu_buffer(merge_mode: &str) -> KeyBasedFileGroupRecordBuffer {
let mut ctx = ReaderContext::empty();
ctx.table_config.insert(
HudiTableConfig::OrderingFields.as_ref().to_string(),
@@ -6246,6 +6262,27 @@ mod tests {
BufferedRecord::new_data(key.to_string(), batch,
Some(OrderingValue::Long(ts)))
}
+ /// A partial record carrying `_hoodie_record_key` + `ts` + `note` (omits
`a`),
+ /// with ordering value = `ts`. Disjoint from `pu_event_partial`'s
columns, so a
+ /// fold of the two is observable in both directions.
+ fn pu_event_partial_note(key: &str, ts: i64, note: &str) -> BufferedRecord
{
+ let s = Arc::new(Schema::new(vec![
+ Field::new("_hoodie_record_key", DataType::Utf8, false),
+ Field::new("ts", DataType::Int64, false),
+ Field::new("note", DataType::Utf8, true),
+ ]));
+ let batch = RecordBatch::try_new(
+ s,
+ vec![
+ Arc::new(StringArray::from(vec![key])) as _,
+ Arc::new(Int64Array::from(vec![ts])) as _,
+ Arc::new(StringArray::from(vec![Some(note)])) as _,
+ ],
+ )
+ .unwrap();
+ BufferedRecord::new_data(key.to_string(), batch,
Some(OrderingValue::Long(ts)))
+ }
+
fn pu_event_base() -> RecordBatch {
RecordBatch::try_new(
pu_event_schema(),
@@ -6326,6 +6363,110 @@ mod tests {
);
}
+ /// EVENT_TIME, log-vs-log: the HIGHER-ordering partial arrives FIRST, so
the
+ /// second (stale) partial loses the ordering comparison. Java folds in
both
+ /// directions, so the loser's unique column still lands: `a` from ts=9,
+ /// `note` from ts=2.
+ #[test]
+ fn test_partial_update_event_time_out_of_order_folds_loser_columns() {
+ let mut buffer = build_pu_event_buffer();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 9, 99), "k1")
+ .unwrap();
+ buffer
+ .process_next_data_record(pu_event_partial_note("k1", 2, "late"),
"k1")
+ .unwrap();
+ assert_eq!(
+ pu_event_drain(buffer),
+ (9, 99, Some("late".to_string())),
+ "winner keeps a=99 at ts=9; the losing partial still contributes
note"
+ );
+ }
+
+ /// The folded union carries the WINNER's ordering value: a later update
at ts=5
+ /// must lose to a union whose winner sat at ts=9. Carrying the loser's
ts=2
+ /// instead would let this update take over.
+ #[test]
+ fn test_partial_update_event_time_fold_keeps_winner_ordering() {
+ let mut buffer = build_pu_event_buffer();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 9, 99), "k1")
+ .unwrap();
+ buffer
+ .process_next_data_record(pu_event_partial_note("k1", 2, "late"),
"k1")
+ .unwrap();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 5, 55), "k1")
+ .unwrap();
+ assert_eq!(
+ pu_event_drain(buffer),
+ (9, 99, Some("late".to_string())),
+ "ts=5 loses to the union's winner ordering of 9"
+ );
+ }
+
+ /// EVENT_TIME, incoming wins: on a column BOTH partials carry, the
winner's
+ /// value survives. The mirror of the stale-loses tests above, which use
+ /// disjoint columns and so cannot see the overlay direction at all.
+ #[test]
+ fn test_partial_update_event_time_newer_wins_overlapping_column() {
+ let mut buffer = build_pu_event_buffer();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 2, 11), "k1")
+ .unwrap();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 9, 99), "k1")
+ .unwrap();
+ assert_eq!(
+ pu_event_drain(buffer),
+ (9, 99, Some("keep".to_string())),
+ "both partials carry `a`; the ts=9 winner's value must survive,
not the ts=2 loser's"
+ );
+ }
+
+ /// EVENT_TIME, incoming wins: the folded union carries the WINNER's
ordering
+ /// value, so a later write between the two loses. Mirrors
+ /// `test_partial_update_event_time_fold_keeps_winner_ordering`, which pins
+ /// the same property on the other arm.
+ #[test]
+ fn test_partial_update_event_time_newer_wins_fold_keeps_winner_ordering() {
+ let mut buffer = build_pu_event_buffer();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 2, 11), "k1")
+ .unwrap();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 9, 99), "k1")
+ .unwrap();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 5, 55), "k1")
+ .unwrap();
+ assert_eq!(
+ pu_event_drain(buffer),
+ (9, 99, Some("keep".to_string())),
+ "ts=5 loses to a union whose winner sat at ts=9, not to the ts=2
loser it folded"
+ );
+ }
+
+ /// COMMIT_TIME_ORDERING is last-writer-wins, so a later partial update
wins
+ /// even when its ordering value went DOWN. Without the short-circuit the
+ /// ordering comparison would hand it to the earlier record, which is
+ /// event-time behavior on a commit-time table.
+ #[test]
+ fn
test_partial_update_commit_time_later_write_wins_a_lower_ordering_value() {
+ let mut buffer = build_pu_buffer("COMMIT_TIME_ORDERING");
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 9, 99), "k1")
+ .unwrap();
+ buffer
+ .process_next_data_record(pu_event_partial("k1", 2, 11), "k1")
+ .unwrap();
+ assert_eq!(
+ pu_event_drain(buffer),
+ (2, 11, Some("keep".to_string())),
+ "last writer wins on a commit-time table, whichever way the
ordering value moved"
+ );
+ }
+
/// A partial column whose type disagrees with the table schema is a LOUD
error
/// (not a silent null) — a present value must never be dropped.
#[test]
diff --git a/crates/core/tests/gold_parity_tests.rs
b/crates/core/tests/gold_parity_tests.rs
index 56b11d13..3c45923b 100644
--- a/crates/core/tests/gold_parity_tests.rs
+++ b/crates/core/tests/gold_parity_tests.rs
@@ -566,6 +566,16 @@ const KNOWN: &[Known] = &[
reader_version: "1",
reason: "version 1 panics on a partial-update block",
},
+ // The same version 1 limitation as the fixture above, reached by the same
+ // route: the partial block's narrower schema is concatenated onto the base
+ // batch positionally, so the key column meets the ordering column.
Version 2
+ // folds both blocks correctly, which is what this fixture exists to pin.
+ Known {
+ fixture: "table_partial_update_event_time",
+ scope: CaseScope::Any,
+ reader_version: "1",
+ reason: "version 1 concatenates a partial-update block onto a wider
base batch",
+ },
Known {
fixture: "table_all_data_types",
scope: CaseScope::Any,
diff --git
a/crates/test/data/quickstart_trips_table/mor/avro/table_partial_update_event_time.sql
b/crates/test/data/quickstart_trips_table/mor/avro/table_partial_update_event_time.sql
new file mode 100644
index 00000000..460d9e46
--- /dev/null
+++
b/crates/test/data/quickstart_trips_table/mor/avro/table_partial_update_event_time.sql
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+-- Two PARTIAL-update log blocks on one key, where the second one loses the
+-- ordering. Generated with Spark 3.5.3 and Hudi 1.2.0-SNAPSHOT; gold_data is
+-- `spark.read.format("hudi").load(<table>)` written beside the table
directory.
+--
+-- MERGE INTO writes a partial block only when the table is MOR, the operation
is
+-- an upsert, the table version is at least 8, and the update touches a STRICT
+-- SUBSET of the columns (MergeIntoHoodieTableCommand). Hence four columns and
+-- two-column updates: touching all of them would silently fall back to a full
+-- update and the fixture would prove nothing.
+--
+-- `hoodie.spark.sql.merge.into.partial.updates` defaults to true and is set
here
+-- so the fixture does not depend on that default holding.
+
+CREATE TABLE table_partial_update_event_time (
+ id STRING,
+ ts BIGINT,
+ a STRING,
+ b STRING
+) USING HUDI
+TBLPROPERTIES (
+ type = 'mor',
+ primaryKey = 'id',
+ preCombineField = 'ts',
+ 'hoodie.write.table.version' = '9',
+ 'hoodie.record.merge.mode' = 'EVENT_TIME_ORDERING',
+ 'hoodie.metadata.enable' = 'false',
+ 'hoodie.parquet.small.file.limit' = '0',
+ 'hoodie.compact.inline' = 'false'
+)
+LOCATION 'FIXTURE_LOCATION';
+
+SET hoodie.merge.small.file.group.candidates.limit=0;
+SET hoodie.spark.sql.merge.into.partial.updates=true;
+
+INSERT INTO table_partial_update_event_time VALUES
+ ('k1', 100, 'a-init', 'b-init'),
+ ('k2', 100, 'a-init', 'b-init');
+
+-- Partial update of (ts, a) only. k1 goes high, k2 goes low.
+MERGE INTO table_partial_update_event_time t
+USING (SELECT 'k1' AS id, 300L AS ts, 'a-high' AS a
+ UNION ALL SELECT 'k2' AS id, 200L AS ts, 'a-low' AS a) s
+ON t.id = s.id
+WHEN MATCHED THEN UPDATE SET t.ts = s.ts, t.a = s.a;
+
+-- Partial update of (ts, b) only. k1 now arrives BELOW its own previous log
+-- record and must lose; k2 arrives above and must win. Each key therefore
folds
+-- in the opposite direction, and in both the column only the LOSING record
+-- carries has to survive: k1 keeps b-low, k2 keeps a-low. A fold that keeps
the
+-- winner whole reads b-init / a-init from the base instead.
+MERGE INTO table_partial_update_event_time t
+USING (SELECT 'k1' AS id, 200L AS ts, 'b-low' AS b
+ UNION ALL SELECT 'k2' AS id, 300L AS ts, 'b-high' AS b) s
+ON t.id = s.id
+WHEN MATCHED THEN UPDATE SET t.ts = s.ts, t.b = s.b;
diff --git
a/crates/test/data/quickstart_trips_table/mor/avro/table_partial_update_event_time.zip
b/crates/test/data/quickstart_trips_table/mor/avro/table_partial_update_event_time.zip
new file mode 100644
index 00000000..53c60a9b
Binary files /dev/null and
b/crates/test/data/quickstart_trips_table/mor/avro/table_partial_update_event_time.zip
differ
diff --git a/crates/test/src/lib.rs b/crates/test/src/lib.rs
index 2eabda68..2749b686 100644
--- a/crates/test/src/lib.rs
+++ b/crates/test/src/lib.rs
@@ -274,6 +274,27 @@ pub enum QuickstartTripsTable {
/// merge-correct truth the applied result must match).
#[strum(serialize = "table_partial_update")]
MorLayoutPartialUpdate,
+ /// v9 MOR non-partitioned, EVENT_TIME_ORDERING, where two PARTIAL-update
log
+ /// blocks touch the same key and the second one LOSES the ordering.
+ ///
+ /// Provenance: `table_partial_update_event_time.sql` beside this zip
+ /// (Spark 3.5.3 / Hudi 1.2.0-SNAPSHOT). MERGE INTO writes a partial block
+ /// when the table is MOR, the operation is an upsert, and the update
touches
+ /// a strict subset of the columns.
+ /// Schema: id INT, ts LONG, a STRING, b STRING (non-partitioned).
+ /// Layout: base .parquet (2 rows at ts 100) + log.1 (partial update of
+ /// `ts`,`a`) + log.1 (partial update of `ts`,`b`).
+ /// Semantics: each key folds in the opposite direction. `id=1` takes its
+ /// second update BELOW the first (200 < 300), so the earlier record wins
and
+ /// still absorbs `b` from the loser; `id=2` takes its second update above
+ /// (300 > 200), so the later record wins and absorbs `a`. Either way the
+ /// column only the LOSING record carries must survive, which is what
+ /// distinguishes a both-direction fold from one that keeps the winner
whole.
+ /// `table_partial_update` cannot show this: it is COMMIT_TIME_ORDERING,
+ /// where the incoming record always wins and the fold is one-directional.
+ /// gold_data = Spark `SELECT *` snapshot, 2 rows.
+ #[strum(serialize = "table_partial_update_event_time")]
+ MorPartialUpdateEventTime,
/// v9 MOR non-partitioned, base + 1 HFILE-format log file
/// (`HFILE_DATA_BLOCK`).
///