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 3fd4e88d fix(core): gate the log-block scan on instant state (#678)
3fd4e88d is described below
commit 3fd4e88d9b5413ca65cd1ddf2913e45635faf6a6
Author: Lin Liu <[email protected]>
AuthorDate: Tue Sep 1 17:14:23 2026 -0700
fix(core): gate the log-block scan on instant state (#678)
---
crates/core/src/file_group/reader.rs | 25 +++
crates/core/src/file_group/reader_v2/adapter.rs | 10 ++
.../src/file_group/reader_v2/log_record_reader.rs | 128 +++++++++++++---
.../reader_v2/merged_log_record_reader.rs | 7 +-
.../src/file_group/reader_v2/reader_context.rs | 22 ++-
crates/core/src/table/mod.rs | 170 ++++++++++++++++++++-
crates/core/src/timeline/mod.rs | 94 +++++++++++-
crates/core/tests/gold_parity_tests.rs | 22 +++
.../mor/avro/table_uncommitted_log.sql | 78 ++++++++++
.../mor/avro/table_uncommitted_log_v6.zip | Bin 0 -> 24847 bytes
.../mor/avro/table_uncommitted_log_v9.zip | Bin 0 -> 30414 bytes
crates/test/src/lib.rs | 46 ++++++
12 files changed, 565 insertions(+), 37 deletions(-)
diff --git a/crates/core/src/file_group/reader.rs
b/crates/core/src/file_group/reader.rs
index f3a8e06c..5e49461a 100644
--- a/crates/core/src/file_group/reader.rs
+++ b/crates/core/src/file_group/reader.rs
@@ -31,6 +31,7 @@ use crate::file_group::base_file::reader::{
};
use crate::file_group::file_slice::FileSlice;
use crate::file_group::log_file::scanner::{LogFileScanner, ScanResult};
+use crate::file_group::reader_v2::reader_context::CompletionGateInputs;
use crate::file_group::record_batches::RecordBatches;
use crate::merge::record_merger::RecordMerger;
use crate::metadata::meta_field::MetaField;
@@ -64,6 +65,15 @@ pub struct FileGroupReader {
/// base types. A caller holding the timeline knows better; one reading
from
/// paths alone (the cxx bridge) does not, and falls back to the base file.
data_schema_override: Option<arrow_schema::SchemaRef>,
+ /// The committed/inflight sets the log-block scan gates on.
+ ///
+ /// A log file is admitted to a slice on its own instant, but its blocks
+ /// carry theirs — including a writer still inflight when a later one
+ /// committed. Without these the scan merges such a block, because it sorts
+ /// below the latest instant and passes every other gate. Only a caller
+ /// holding the timeline can supply them; one reading from paths alone (the
+ /// cxx bridge) leaves the gate off, as it always was.
+ completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
}
impl std::fmt::Debug for FileGroupReader {
@@ -105,6 +115,7 @@ impl FileGroupReader {
base_file_format: format,
base_file_reader,
data_schema_override: None,
+ completion_gate_inputs: None,
})
}
@@ -135,6 +146,7 @@ impl FileGroupReader {
base_file_format: format,
base_file_reader,
data_schema_override: None,
+ completion_gate_inputs: None,
})
}
@@ -214,6 +226,17 @@ impl FileGroupReader {
))
}
+ /// Whether this reader can gate the log scan on instant state.
+ #[cfg(test)]
+ pub(crate) fn has_completion_gate_inputs(&self) -> bool {
+ self.completion_gate_inputs.is_some()
+ }
+
+ /// Gate the log-block scan on these committed/inflight sets.
+ pub(crate) fn set_completion_gate_inputs(&mut self, inputs:
CompletionGateInputs) {
+ self.completion_gate_inputs = Some(Arc::new(inputs));
+ }
+
/// Read slices with `schema` rather than whatever the base file carries.
pub(crate) fn set_data_schema(&mut self, schema: arrow_schema::SchemaRef) {
self.data_schema_override = Some(schema);
@@ -341,6 +364,7 @@ impl FileGroupReader {
log_file_paths,
partition_path,
Some(data_schema),
+ self.completion_gate_inputs.clone(),
)
.await?;
@@ -789,6 +813,7 @@ impl FileGroupReader {
log_file_paths,
partition_path,
Some(data_schema),
+ self.completion_gate_inputs.clone(),
)
.await?;
diff --git a/crates/core/src/file_group/reader_v2/adapter.rs
b/crates/core/src/file_group/reader_v2/adapter.rs
index 3d53ba5b..3524ee05 100644
--- a/crates/core/src/file_group/reader_v2/adapter.rs
+++ b/crates/core/src/file_group/reader_v2/adapter.rs
@@ -36,6 +36,7 @@ use crate::error::CoreError;
use crate::file_group::reader_v2::MAX_INSTANT_TIME;
use crate::file_group::reader_v2::engine::HoodieFileGroupReader;
use crate::file_group::reader_v2::input_split::InputSplit;
+use crate::file_group::reader_v2::reader_context::CompletionGateInputs;
use crate::file_group::reader_v2::reader_parameters::ReaderParameters;
use crate::file_group::reader_v2::resolver::resolve_reader_context;
use crate::storage::Storage;
@@ -56,6 +57,7 @@ pub(crate) async fn read_file_slice(
log_file_paths: Vec<String>,
partition_path: String,
data_schema: Option<SchemaRef>,
+ completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
) -> Result<RecordBatch> {
let mut reader = build_reader(
hudi_configs,
@@ -64,6 +66,7 @@ pub(crate) async fn read_file_slice(
log_file_paths,
partition_path,
data_schema,
+ completion_gate_inputs,
)?;
reader.read().await
@@ -81,6 +84,7 @@ pub(crate) async fn read_file_slice_stream(
log_file_paths: Vec<String>,
partition_path: String,
data_schema: Option<SchemaRef>,
+ completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
) -> Result<futures::stream::BoxStream<'static, Result<RecordBatch>>> {
let mut reader = build_reader(
hudi_configs,
@@ -89,6 +93,7 @@ pub(crate) async fn read_file_slice_stream(
log_file_paths,
partition_path,
data_schema,
+ completion_gate_inputs,
)?;
reader.open_blocking_stream().await
@@ -103,10 +108,15 @@ fn build_reader(
log_file_paths: Vec<String>,
partition_path: String,
data_schema: Option<SchemaRef>,
+ completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
) -> Result<HoodieFileGroupReader> {
let has_log_files = !log_file_paths.is_empty();
let hudi_configs = with_unbounded_end_timestamp(hudi_configs);
let mut context = resolve_reader_context(&hudi_configs, has_log_files)?;
+ // The scan's committed/inflight gate. Supplied by a caller holding the
+ // timeline; `None` leaves the gate a no-op, which is what a caller reading
+ // from paths alone (the cxx bridge) can offer.
+ context.completion_gate_inputs = completion_gate_inputs;
context.rebuild_record_context(partition_path.clone());
// A slice with no base file reports an empty path; the engine keys its
diff --git a/crates/core/src/file_group/reader_v2/log_record_reader.rs
b/crates/core/src/file_group/reader_v2/log_record_reader.rs
index 453b4254..63013f29 100644
--- a/crates/core/src/file_group/reader_v2/log_record_reader.rs
+++ b/crates/core/src/file_group/reader_v2/log_record_reader.rs
@@ -97,8 +97,8 @@ impl<'a> CompletionGate<'a> {
/// committedness for any instant, so it admits everything (a no-op) rather
/// than excluding all blocks. Java always builds the gate from a non-empty
/// active timeline (`filterCompletedInstants()`), so an empty set here
means
- /// the inputs were not populated by the caller (e.g. the FFI bridge
forwarded
- /// an empty list). Excluding on that basis would silently drop EVERY log
delta
+ /// the inputs were not populated by the caller — a caller that supplied
the
+ /// struct but left the sets empty. Excluding on that basis would silently
drop EVERY log delta
/// — including committed ones — and return base-file-only data (the
/// C-INFLIGHT silent-wrong: a committed later delta wrongly dropped).
Deferring
/// to the other gates preserves the pre-gate behavior for a mis-wired
gate; a
@@ -118,12 +118,12 @@ impl<'a> CompletionGate<'a> {
/// `completed_instants` set AND no `archived_boundary`. Such a gate
cannot establish
/// committedness for any instant, so [`admits`](Self::admits) degrades to
admit-all.
///
- /// When the gate was supplied deliberately (the caller passes `Some` only
for a table
- /// version < 8 snapshot), being unpopulated is a mis-wire signal, not a
genuine empty
+ /// When the gate was supplied deliberately (the caller passes `Some`
whenever it has a
+ /// timeline to build it from), being unpopulated is a mis-wire signal,
not a genuine empty
/// timeline: Java always builds the gate from a non-empty active timeline
- /// (`filterCompletedInstants()`), and a v1 snapshot always has >= 1
completed instant.
+ /// (`filterCompletedInstants()`), and a readable table always has >= 1
completed instant.
/// `forward_scan_pass1` warns once when this holds so a gate whose inputs
were dropped
- /// across the FFI boundary is diagnosable rather than a silent no-op.
+ /// before reaching the scan is diagnosable rather than a silent no-op.
fn is_unpopulated(&self) -> bool {
self.completed_instants.is_empty() && self.archived_boundary.is_none()
}
@@ -164,17 +164,16 @@ pub fn forward_scan_pass1(
instant_range.is_some(),
);
- // The completion gate was supplied (table version < 8 snapshot) but
carries no positive
- // completion info — its inputs were not populated across the FFI
boundary. Rather than
- // silently degrade to admit-all (Gate 3 becomes a no-op, so a straddling
uncommitted delta
- // could be merged), warn once so the mis-wire is diagnosable. Behavior is
unchanged (still
- // fail-open); this only makes the condition visible.
+ // The completion gate was supplied but carries no positive completion
info — the caller
+ // passed the struct with empty sets. Rather than silently degrade to
admit-all (Gate 3
+ // becomes a no-op, so a straddling uncommitted delta could be merged),
warn so the mis-wire
+ // is diagnosable. Behavior is unchanged (still fail-open); this only
makes it visible.
if completion_gate.is_some_and(CompletionGate::is_unpopulated) {
log::warn!(
"[Pass1] completion gate supplied but unpopulated (empty completed
set, no archived \
- boundary): Gate 3 (C-INFLIGHT) admits all delta blocks. A table
version < 8 snapshot \
- always has >= 1 completed instant, so this indicates the gate
inputs were dropped \
- across the FFI boundary.
latest_instant_time={latest_instant_time}"
+ boundary): Gate 3 admits all delta blocks. A readable table
always has >= 1 \
+ completed instant, so this indicates the gate inputs were dropped
between the \
+ timeline and this scan. latest_instant_time={latest_instant_time}"
);
}
@@ -449,9 +448,9 @@ pub struct BaseHoodieLogRecordReader {
pub allow_inflight_instants: bool,
/// Inputs for the Gate-3 completed/inflight check. `Some` only for
/// table version < 8 (v1 timeline layout); `None` for v8+ and when no
timeline is available,
- /// in which case Gate 3 is a no-op. Populated by the builder / FFI wiring
from the active
- /// timeline (completed + inflight instant sets + the first active
instant).
- pub completion_gate_inputs: Option<CompletionGateInputs>,
+ /// in which case Gate 3 is a no-op. Populated by `Table` from the active
timeline
+ /// (completed + pending instant sets + the first active instant).
+ pub completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
// ── Stats / state (mirrors Java's AtomicLong counters + progress) ──
pub valid_block_instants: Vec<String>,
@@ -536,10 +535,11 @@ impl BaseHoodieLogRecordReader {
});
// Pass 1: Forward scan with 5 gates. The completed/inflight gate
(Gate 3)
- // is applied only when the timeline sets were supplied (table version
< 8).
+ // is applied when the caller supplied the timeline sets — a `Table`
read of a
+ // table below version 8. A v8+ read, or a caller with no timeline,
leaves it a no-op.
let completion_gate = self
.completion_gate_inputs
- .as_ref()
+ .as_deref()
.map(CompletionGate::new);
let mut pass1 = forward_scan_pass1(
all_blocks,
@@ -855,7 +855,7 @@ mod tests {
assert_eq!(result.ordered_instants_list, vec!["20250101000000000"]);
}
- /// Table version < 8: an UNCOMMITTED "straddling" instant -- one whose
+ /// An UNCOMMITTED "straddling" instant -- one whose
/// time is BELOW the latest completed instant (the high-watermark) and
which has NO in-log
/// rollback command block (a pending/failed write, or a concurrent
writer's inflight commit) --
/// must be EXCLUDED (not merged) once the completed/inflight sets are
supplied.
@@ -901,10 +901,10 @@ mod tests {
);
}
- /// Without the completion gate (v8+ tables, or no timeline available)
Gate 3 is a no-op, so the
- /// straddling instant is still admitted -- proving the fix is scoped to
table version < 8 and is
- /// purely additive (prior callers pass `None` and see unchanged
behavior). v8+ safety is
- /// enforced elsewhere: per-delta-commit log files are excluded at the
completion-time file-slice
+ /// Without the completion gate -- a v8+ read, or a caller with no
timeline (the cxx bridge)
+ /// -- Gate 3 is a no-op, so the straddling instant is still admitted,
proving the gate is
+ /// purely additive and that such callers see unchanged behavior. On v8+
that is safe for a
+ /// separate reason: per-delta-commit log files are dropped at the
completion-time file-slice
/// level, so the same block never reaches Pass 1.
#[test]
fn test_pass1_gate3_absent_gate_admits_straddling_instant() {
@@ -923,6 +923,44 @@ mod tests {
);
}
+ /// Where the gate is supplied it applies to incremental reads too, not
just snapshots, so it
+ /// has to COMPOSE with Gate 4's window rather than widen it. Two claims
are pinned here: a
+ /// pending instant inside the incremental window is still excluded (Gate
3 applies even though
+ /// a range is set), and a committed instant outside the window stays
excluded (Gate 3 admitting
+ /// it cannot override Gate 4). Together they show the gate only ever
subtracts, so arming it
+ /// on a read never costs rows that read should have seen.
+ #[test]
+ fn test_pass1_gate3_and_the_incremental_window_only_ever_subtract() {
+ let blocks = vec![
+ make_data_block("20250101000000000"), // committed, inside the
window
+ make_data_block("20250102000000000"), // PENDING, inside the window
+ make_data_block("20250104000000000"), // committed, outside the
window
+ ];
+ let range = Some(InstantRange::up_to("20250103000000000", "utc"));
+
+ let gate_inputs = CompletionGateInputs {
+ completed_instants: [
+ "20250101000000000".to_string(),
+ "20250104000000000".to_string(),
+ ]
+ .into_iter()
+ .collect(),
+ inflight_instants:
["20250102000000000".to_string()].into_iter().collect(),
+ archived_boundary: None,
+ };
+ let gate = CompletionGate::new(&gate_inputs);
+
+ let result =
+ forward_scan_pass1(blocks, MAX_INSTANT_TIME, &range, "utc",
Some(&gate)).unwrap();
+
+ assert_eq!(
+ result.ordered_instants_list,
+ vec!["20250101000000000"],
+ "pending 20250102 excluded by Gate 3 despite being in range; \
+ committed 20250104 excluded by Gate 4 despite Gate 3 admitting it"
+ );
+ }
+
/// Gate 3 treats an instant older than the archived boundary as committed
(archival only removes
/// completed instants), so an old archived data block is admitted while a
genuinely inflight one
/// is still excluded.
@@ -1789,4 +1827,46 @@ mod tests {
"inflight straddling delta t1 (id=2) must be excluded"
);
}
+
+ /// REGRESSION: a block from an instant that never completed is admitted
when
+ /// no completion gate is supplied, and skipped when one is.
+ ///
+ /// This is the "straddling" case: writer A opens an instant at T1 and is
+ /// still inflight when writer B commits T2 > T1. `latest_instant_time` is
+ /// T2, so A's blocks pass the future gate and the instant-range gate, and
+ /// nothing else looks at whether A ever committed. The log file itself was
+ /// admitted at listing time on its own (committed) instant, so the
+ /// file-level check does not see these blocks either.
+ ///
+ /// The pair is the point: same blocks, same bounds, gate absent vs
present.
+ #[test]
+ fn test_pass1_admits_an_uncommitted_instant_without_the_completion_gate() {
+ let blocks = vec![make_data_block("T1_inflight"),
make_data_block("T2_done")];
+
+ // No gate — what every production read did before the gate was wired.
+ let ungated = forward_scan_pass1(blocks.clone(), "T2_done", &None,
"UTC", None).unwrap();
+ assert!(
+ ungated.instant_to_blocks_map.contains_key("T1_inflight"),
+ "without a gate an inflight instant's blocks are merged: {:?}",
+ ungated.ordered_instants_list
+ );
+
+ // With the timeline's own sets, the inflight instant is excluded.
+ let inputs = CompletionGateInputs {
+ completed_instants: HashSet::from(["T2_done".to_string()]),
+ inflight_instants: HashSet::from(["T1_inflight".to_string()]),
+ archived_boundary: Some("T0".to_string()),
+ };
+ let gate = CompletionGate::new(&inputs);
+ let gated = forward_scan_pass1(blocks, "T2_done", &None, "UTC",
Some(&gate)).unwrap();
+
+ assert!(
+ !gated.instant_to_blocks_map.contains_key("T1_inflight"),
+ "the gate must skip an instant that never completed"
+ );
+ assert!(
+ gated.instant_to_blocks_map.contains_key("T2_done"),
+ "the committed instant must still be merged"
+ );
+ }
}
diff --git a/crates/core/src/file_group/reader_v2/merged_log_record_reader.rs
b/crates/core/src/file_group/reader_v2/merged_log_record_reader.rs
index a5c7aef3..667bf1f4 100644
--- a/crates/core/src/file_group/reader_v2/merged_log_record_reader.rs
+++ b/crates/core/src/file_group/reader_v2/merged_log_record_reader.rs
@@ -274,7 +274,7 @@ pub struct Builder {
allow_inflight_instants: bool,
/// Inputs for the Gate-3 completed/inflight check; `Some` only for
/// table version < 8. `None` (default) leaves Gate 3 a no-op.
- completion_gate_inputs: Option<CompletionGateInputs>,
+ completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
}
impl Default for Builder {
@@ -347,7 +347,10 @@ impl Builder {
/// Supply the Gate-3 completed/inflight sets. Callers pass `Some` only for
/// table version < 8; `None` (the default) leaves Gate 3 disabled.
- pub fn with_completion_gate_inputs(mut self, inputs:
Option<CompletionGateInputs>) -> Self {
+ pub fn with_completion_gate_inputs(
+ mut self,
+ inputs: Option<Arc<CompletionGateInputs>>,
+ ) -> Self {
self.completion_gate_inputs = inputs;
self
}
diff --git a/crates/core/src/file_group/reader_v2/reader_context.rs
b/crates/core/src/file_group/reader_v2/reader_context.rs
index fec6b770..9acf81bd 100644
--- a/crates/core/src/file_group/reader_v2/reader_context.rs
+++ b/crates/core/src/file_group/reader_v2/reader_context.rs
@@ -31,6 +31,7 @@ use crate::config::table::HudiTableConfig;
use crate::storage::RowFilterBuilder;
use crate::timeline::selector::InstantRange;
use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
/// Owned inputs for the Gate-3 completed/inflight check in the log scan.
///
@@ -51,6 +52,12 @@ pub struct CompletionGateInputs {
/// Active completed-commit instant times (Java
`filterCompletedInstants()`).
pub completed_instants: HashSet<String>,
/// Active inflight/requested instant times (Java `filterInflights()`).
+ ///
+ /// Carried for parity with Java's two-set check; it cannot change an
outcome for the
+ /// producer in this crate. `Table` subtracts the completed set when
building this one, and
+ /// the archived boundary is the minimum over *all* active instants
including these — so an
+ /// instant listed here fails the committed test already, and the
`!inflight` term never
+ /// decides anything. A different producer, supplying sets that overlap,
would need it.
pub inflight_instants: HashSet<String>,
/// First active-timeline instant. An instant strictly before it is
archived; archival only
/// removes completed instants, so an archived instant is committed.
`None` disables the
@@ -149,12 +156,17 @@ pub struct ReaderContext {
/// the FG reader).
pub mor_pk_safe: bool,
/// Gate-3 completed/inflight inputs (completed/inflight/archived sets).
- /// Carried here mirroring [`Self::instant_range`]; `Some` only for table
- /// version < 8 snapshot reads (set by the FFI bridge when the planner
- /// enabled the gate), `None` otherwise (v8+/incremental/no-timeline) which
- /// leaves Gate 3 a no-op, preserving prior behavior. Consumed by the log
+ /// Carried here mirroring [`Self::instant_range`]. `Some` only when the
caller holds a
+ /// timeline *and* the table version is below 8 — see
+ /// [`crate::table::Table::completion_gate_inputs`]. `None` otherwise: for
v8+, where the
+ /// whole log file is dropped at slice-build time, and for a caller handed
bare paths with
+ /// no timeline (the cxx bridge). Either way Gate 3 becomes a no-op.
Consumed by the log
/// scan builder in `buffer::loader::scan_log_files`.
- pub completion_gate_inputs: Option<CompletionGateInputs>,
+ ///
+ /// Not scoped to snapshot reads, though: the gate only ever EXCLUDES
blocks whose instant
+ /// is pending, so it holds for incremental reads too and cannot admit a
block another gate
+ /// rejected.
+ pub completion_gate_inputs: Option<Arc<CompletionGateInputs>>,
}
// Manual Debug — RowFilterBuilder is a closure (Arc<dyn Fn ...>), not Debug.
diff --git a/crates/core/src/table/mod.rs b/crates/core/src/table/mod.rs
index e07c9180..e4cc9b04 100644
--- a/crates/core/src/table/mod.rs
+++ b/crates/core/src/table/mod.rs
@@ -107,6 +107,7 @@ use crate::error::CoreError;
use crate::expr::filter::{Filter, validate_fields_against_schemas};
use crate::file_group::file_slice::FileSlice;
use crate::file_group::reader::FileGroupReader;
+use crate::file_group::reader_v2::reader_context::CompletionGateInputs;
use crate::keygen::is_timestamp_based_keygen;
use crate::metadata::METADATA_TABLE_PARTITION_FIELD;
use crate::metadata::commit::HoodieCommitMetadata;
@@ -669,11 +670,58 @@ impl Table {
Some(opts) => self.prepare_reader_options(opts)?.hudi_options,
None => HashMap::new(),
};
- let mut reader = self.build_file_group_reader(hudi_opts,
extra_storage_overrides)?;
+ let reader = self.build_file_group_reader(hudi_opts,
extra_storage_overrides)?;
+ self.finish_reader(reader).await
+ }
+
+ /// Carry this table's per-read state onto a freshly built reader.
+ ///
+ /// Every read path needs the same two steps, and a path performing only
some
+ /// of them fails silently rather than loudly: without the data schema an
+ /// evolved table reads against the base file's, and without the gate
inputs
+ /// the log scan admits uncommitted blocks. Both return plausible rows. So
the
+ /// steps live here and each path calls this rather than repeating them.
+ async fn finish_reader(&self, mut reader: FileGroupReader) ->
Result<FileGroupReader> {
reader.set_data_schema(std::sync::Arc::new(self.data_schema_for_read().await?));
+ // This table holds the timeline, so it can tell a committed instant
from
+ // one still inflight — the log-block scan cannot work that out from
the
+ // slice alone.
+ if let Some(inputs) = self.completion_gate_inputs()? {
+ reader.set_completion_gate_inputs(inputs);
+ }
Ok(reader)
}
+ /// Inputs for the log-block scan's completed/inflight gate, or `None` when
+ /// this table does not need one.
+ ///
+ /// Mirrors Java `BaseHoodieLogRecordReader`, which runs the check only
below
+ /// table version 8 (`tableVersion.lesserThan(HoodieTableVersion.EIGHT)`).
+ /// From version 8 the timeline records completion times, so a log file
whose
+ /// delta commit never completed is already dropped when the file slice is
+ /// built, and asking again per block would be redundant. Below version 8
+ /// there are no completion times to build a slice from, which leaves the
+ /// block scan as the only place the question can be asked.
+ ///
+ /// So from version 8 the exclusion rests entirely on the log file's *name*
+ /// carrying the delta commit that wrote it (`file_group::builder`). A
table
+ /// upgraded from version 6 would seem to break that, since a version-6
+ /// writer names log files on the base instant — but the writer's upgrade
+ /// closes it from the other side, two ways: it rolls back failed writes
and
+ /// compacts every log file into a new base file before bumping the
version,
+ /// and where the pending commit has completed commits after it, rollback
is
+ /// refused and the upgrade aborts rather than proceeding. Either way no
+ /// version-6-named log file reaches a version-8 slice. Verified by running
+ /// both paths on Spark 3.5.3 with Hudi 1.2.0-SNAPSHOT.
+ fn completion_gate_inputs(&self) -> Result<Option<CompletionGateInputs>> {
+ let table_version: isize = self
+ .hudi_configs
+ .try_get(HudiTableConfig::TableVersion)?
+ .map(|v| v.into())
+ .unwrap_or(6);
+ Ok((table_version < 8).then(|| self.timeline.completion_gate_inputs()))
+ }
+
/// Build a reader for one of this table's own read paths, carrying the
/// table's current data schema.
///
@@ -681,12 +729,11 @@ impl Table {
/// performed only the first would read an evolved table with a stale
schema —
/// so they share this rather than repeating it.
async fn reader_for_read_path(&self, prepared: &ReadOptions) ->
Result<FileGroupReader> {
- let mut reader = self.build_file_group_reader(
+ let reader = self.build_file_group_reader(
prepared.hudi_options.clone(),
std::iter::empty::<(&str, &str)>(),
)?;
-
reader.set_data_schema(std::sync::Arc::new(self.data_schema_for_read().await?));
- Ok(reader)
+ self.finish_reader(reader).await
}
/// Convert caller-facing [`ReadOptions`] into the form that
@@ -1801,6 +1848,57 @@ mod tests {
Ok(())
}
+ /// The streaming read carries the completion gate too.
+ ///
+ /// It reaches the gate through its own pass-through, separate from the
eager read's, and
+ /// it is the path DataFusion and the Python binding use. The gold sweep
only exercises
+ /// the eager one, so without this a regression that disarmed the gate for
every streaming
+ /// read would leave the whole suite green — which is exactly what a
mutation of the
+ /// streaming pass-through did before this test existed.
+ ///
+ /// The fixture's orphaned delta commit sets `rider = 'ORPHANED-B'` at `ts
= 300`, above
+ /// every other row's ordering value, so an admitted orphan wins the row
outright rather
+ /// than losing the merge for an unrelated reason.
+ #[tokio::test]
+ async fn hudi_table_read_stream_excludes_an_uncommitted_instants_blocks()
-> Result<()> {
+ use arrow_array::Array;
+ use futures::TryStreamExt;
+ use hudi_test::QuickstartTripsTable;
+
+ let base_url =
QuickstartTripsTable::MorUncommittedLogV6.url_to_mor_avro();
+ let hudi_table = Table::new(base_url.path()).await.unwrap();
+
+ let stream =
hudi_table.read_stream(&ReadOptions::new()).await.unwrap();
+ let batches = stream.try_collect::<Vec<_>>().await.unwrap();
+
+ let riders: Vec<String> = batches
+ .iter()
+ .filter_map(|b| b.column_by_name("rider").cloned())
+ .flat_map(|c| {
+ let arr = c
+ .as_any()
+ .downcast_ref::<arrow_array::StringArray>()
+ .expect("rider is a string column")
+ .clone();
+ (0..arr.len()).map(move |i| arr.value(i).to_string())
+ })
+ .collect();
+
+ assert!(
+ !riders.is_empty(),
+ "the streaming read returned no rows, so it cannot show the gate
ran"
+ );
+ assert!(
+ !riders.iter().any(|r| r == "ORPHANED-B"),
+ "the streaming read merged a block from an instant that never
completed: {riders:?}"
+ );
+ assert!(
+ riders.iter().any(|r| r == "rider-B"),
+ "the base row the orphan would have overwritten must survive:
{riders:?}"
+ );
+ Ok(())
+ }
+
#[tokio::test]
async fn
hudi_table_read_snapshot_stream_returns_empty_when_no_file_slices_match_filters()
-> Result<()> {
@@ -2395,6 +2493,70 @@ mod tests {
/// fixture rather than a reproduction of the one input that broke: the
bound
/// is an instant the timeline actually contains, so it parses by
/// construction.
+ /// Regression test: a table-built reader carries the timeline's
+ /// committed/inflight sets, so the log-block scan can gate on them.
+ ///
+ /// The gate exists to skip blocks from an instant that never completed —
the
+ /// straddling case where a writer is still inflight when a later one
+ /// commits, so its blocks sort below the latest instant and pass every
other
+ /// gate. It was inert for every read through this crate because nothing
+ /// populated its inputs.
+ #[tokio::test]
+ async fn test_table_reader_carries_the_completion_gate_inputs() {
+ use hudi_test::SampleTable;
+
+ let base_url = SampleTable::V6Nonpartitioned.url_to_mor_parquet();
+ let table = Table::new(base_url.path()).await.unwrap();
+
+ let inputs = table.timeline.completion_gate_inputs();
+ assert!(
+ !inputs.completed_instants.is_empty(),
+ "the fixture's completed commits must reach the gate"
+ );
+ assert!(
+ inputs.archived_boundary.is_some(),
+ "the archival boundary is the gate's second half — an archived \
+ instant is committed by definition"
+ );
+
+ // And the reader the read paths use actually receives them.
+ let reader = table
+ .create_file_group_reader_with_options(None, empty_options())
+ .await
+ .unwrap();
+ assert!(
+ reader.has_completion_gate_inputs(),
+ "a reader built from a table must be able to gate the log scan"
+ );
+ }
+
+ /// From table version 8 the timeline records completion times, so a log
file
+ /// whose delta commit never completed is already dropped when the file
slice
+ /// is built. Java stops applying the per-block gate there
+ /// (`BaseHoodieLogRecordReader`, `tableVersion.lesserThan(EIGHT)`), and so
+ /// does this. Paired with the version-6 test above, the two pin the
+ /// condition rather than only the armed case.
+ #[tokio::test]
+ async fn test_completion_gate_is_not_armed_from_table_version_eight() {
+ use hudi_test::QuickstartTripsTable;
+
+ let table_path =
QuickstartTripsTable::V9MorNonpart3Commits.path_to_mor_avro();
+ let table = Table::new(&table_path).await.unwrap();
+ assert!(
+ table.completion_gate_inputs().unwrap().is_none(),
+ "a version 9 table must not arm the per-block gate"
+ );
+
+ let reader = table
+ .create_file_group_reader_with_options(None, empty_options())
+ .await
+ .unwrap();
+ assert!(
+ !reader.has_completion_gate_inputs(),
+ "the slice already excludes an uncommitted log file on this layout"
+ );
+ }
+
#[tokio::test]
async fn test_resolve_incremental_window_start_bound_is_a_real_instant() {
use crate::config::internal::HudiInternalConfig;
diff --git a/crates/core/src/timeline/mod.rs b/crates/core/src/timeline/mod.rs
index 5e08df1f..42c4ba0c 100644
--- a/crates/core/src/timeline/mod.rs
+++ b/crates/core/src/timeline/mod.rs
@@ -30,6 +30,7 @@ use crate::config::HudiConfigs;
use crate::error::CoreError;
use crate::file_group::FileGroup;
use crate::file_group::builder::replaced_file_groups_from_replace_commit;
+use crate::file_group::reader_v2::reader_context::CompletionGateInputs;
use crate::schema::resolver::{
resolve_avro_schema_from_commit_metadata,
resolve_data_schema_from_commit_metadata,
};
@@ -69,9 +70,28 @@ pub struct Timeline {
/// (`BaseHoodieTimeline.java:494`). `None` means the active timeline is
/// empty, so nothing can be treated as archived.
pub(crate) earliest_active_instant: Option<String>,
+ /// Request timestamps of the active instants that have NOT completed —
+ /// requested or inflight.
+ ///
+ /// Read from the same listing that yields [`Self::completed_commits`] and
+ /// the archival boundary, so retaining it costs nothing. The log-block
scan
+ /// needs it: a log file admitted at listing time on its own committed
+ /// instant can still carry blocks appended by a later instant that never
+ /// completed, and only the instant's state can tell.
+ pub(crate) pending_instants: HashSet<String>,
}
pub const EARLIEST_START_TIMESTAMP: &str = "19700101000000000";
+
+/// The actions a read loads from the timeline.
+///
+/// Narrowing this is no longer only a listing decision:
[`Timeline::completion_gate_inputs`]
+/// derives the log scan's committed set from these, so an action that
completes outside this
+/// list and wrote data or delete log blocks would have its rows dropped from
a merge-on-read
+/// read — silently, since the gate excludes rather than errors. The three
here cover every
+/// action that writes such blocks (compaction completes as `commit`,
clustering as
+/// `replacecommit`, log compaction as `deltacommit`), which is also the set
Java's gate is
+/// built from via `getCommitsTimeline()`.
pub const DEFAULT_LOADING_ACTIONS: &[Action] =
&[Action::Commit, Action::DeltaCommit, Action::ReplaceCommit];
@@ -89,6 +109,7 @@ impl Timeline {
archived_loader,
completed_commits: Vec::new(),
earliest_active_instant: None,
+ pending_instants: HashSet::new(),
}
}
@@ -114,13 +135,47 @@ impl Timeline {
.iter()
.map(|instant| instant.timestamp.clone())
.min();
- timeline.completed_commits = all_active
+ let (completed, pending): (Vec<Instant>, Vec<Instant>) = all_active
+ .into_iter()
+ .partition(|instant| instant.state == State::Completed);
+ // An instant is listed once per state file it has, so a completed one
+ // also appears as requested and inflight. Pending means it reached NO
+ // completed state — subtracting is what makes that true, and without
the
+ // subtraction every committed instant reads as inflight and the gate
+ // rejects the whole timeline.
+ let completed_times: HashSet<String> = completed
+ .iter()
+ .map(|instant| instant.timestamp.clone())
+ .collect();
+ timeline.pending_instants = pending
.into_iter()
- .filter(|instant| instant.state == State::Completed)
+ .map(|instant| instant.timestamp)
+ .filter(|timestamp| !completed_times.contains(timestamp))
.collect();
+ timeline.completed_commits = completed;
Ok(timeline)
}
+ /// The inputs the log-block scan needs to tell a committed instant from
one
+ /// that never finished.
+ ///
+ /// A log file is admitted to a slice on its own instant, but blocks inside
+ /// it carry their own — including a writer that was still inflight when a
+ /// later writer committed. Without these sets that block merges as if it
+ /// were committed, because it sorts below the latest instant and so passes
+ /// every other gate.
+ pub(crate) fn completion_gate_inputs(&self) -> CompletionGateInputs {
+ CompletionGateInputs {
+ completed_instants: self
+ .completed_commits
+ .iter()
+ .map(|instant| instant.timestamp.clone())
+ .collect(),
+ inflight_instants: self.pending_instants.clone(),
+ archived_boundary: self.earliest_active_instant.clone(),
+ }
+ }
+
/// Load instants from the timeline based on the selector criteria.
///
/// # Archived Timeline Loading
@@ -898,4 +953,39 @@ mod tests {
assert!(timestamp.is_some());
assert!(!timestamp.unwrap().is_empty());
}
+
+ /// Regression test: a completed instant is not also reported as pending.
+ ///
+ /// The active timeline lists an instant once per state file it has, so a
+ /// completed one appears as requested and inflight too. Reading pending
+ /// straight off the non-completed rows therefore marks every committed
+ /// instant inflight, and the log-scan gate — which admits only
+ /// `committed && !inflight` — then rejects the whole timeline and returns
+ /// base-file-only data. That is silent: no error, just missing log deltas.
+ #[tokio::test]
+ async fn
test_completion_gate_inputs_do_not_report_completed_instants_as_pending() {
+ let base_url = SampleTable::V6Nonpartitioned.url_to_mor_parquet();
+ let hudi_configs = Arc::new(HudiConfigs::new([(
+ HudiTableConfig::BasePath,
+ base_url.to_string(),
+ )]));
+ let timeline = Timeline::new_from_storage(hudi_configs,
Arc::new(HashMap::new()))
+ .await
+ .unwrap();
+
+ let inputs = timeline.completion_gate_inputs();
+ assert!(
+ !inputs.completed_instants.is_empty(),
+ "the fixture has completed commits"
+ );
+ let both: Vec<&String> = inputs
+ .inflight_instants
+ .iter()
+ .filter(|t| inputs.completed_instants.contains(*t))
+ .collect();
+ assert!(
+ both.is_empty(),
+ "an instant cannot be both completed and pending, got {both:?}"
+ );
+ }
}
diff --git a/crates/core/tests/gold_parity_tests.rs
b/crates/core/tests/gold_parity_tests.rs
index a1f4dfd7..56b11d13 100644
--- a/crates/core/tests/gold_parity_tests.rs
+++ b/crates/core/tests/gold_parity_tests.rs
@@ -115,6 +115,8 @@ const EXPECTED_OPTION_FIXTURES: &[&str] = &[
"table_null_containers [MorAvro]",
"table_parquet_log_block [MorAvro]",
"table_partial_update [MorAvro]",
+ "table_uncommitted_log_v6 [MorAvro]",
+ "table_uncommitted_log_v9 [MorAvro]",
"v6_trips_8i1u [MorAvro]",
"v6_trips_8i3d [MorAvro]",
"v8_mor_boundary_windows [MorAvro]",
@@ -503,6 +505,26 @@ impl Known {
/// disagreement fails the build, and one that starts passing has to be removed
/// from here, so this list cannot quietly go stale.
const KNOWN: &[Known] = &[
+ // Version 1 only: it applies no completed/inflight check to log blocks, so
+ // the blocks of a delta commit that never completed still reach the merge.
+ // The fixture exists to pin that version 2 does check; this records that
+ // version 1 does not, which is the divergence itself rather than a fixture
+ // flaw. Only the version 6 fixture separates the two readers: on version 9
+ // the log file is dropped when the slice is built, which both of them
share.
+ //
+ // Unlike the other version 1 entries here, which record a narrower or
+ // differently-shaped answer, this one records a wrong one: rows that were
+ // never committed. It is accepted rather than fixed because version 2 is
the
+ // default and version 1 is on its way out. Two things would reopen that: a
+ // version 1 read reachable without asking for it, or version 1 outliving
the
+ // migration. Fixing it means giving `LogFileScanner` the instant state its
+ // `scan` has no notion of, which reaches the metadata table reader too.
+ Known {
+ fixture: "table_uncommitted_log_v6",
+ scope: CaseScope::Any,
+ reader_version: "1",
+ reason: "version 1 admits blocks from an instant that never completed",
+ },
// Version 1 only: it now reads a log-only slice but returns it without the
// base columns. Nothing it is asked to project changes that, so the
// fixture is blanketed.
diff --git
a/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log.sql
b/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log.sql
new file mode 100644
index 00000000..f89dedb0
--- /dev/null
+++ b/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log.sql
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+
+-- Source for BOTH `table_uncommitted_log_v6.zip` and
`table_uncommitted_log_v9.zip`:
+-- a delta commit that wrote log blocks and never completed.
+--
+-- Run once per table version, substituting 6 and 9 for WRITE_TABLE_VERSION and
+-- naming the table table_uncommitted_log_v6 / table_uncommitted_log_v9.
+-- Generated with Spark 3.5.3 and Hudi 1.2.0-SNAPSHOT.
+--
+-- Two steps follow the SQL and cannot be expressed in it:
+--
+-- 1. Delete the SECOND delta commit's completed timeline file, leaving its
+-- `.inflight` and `.requested` and its log file in place. That is what a
+-- writer killed mid-commit leaves behind. Layout v1 (version 6) keeps the
+-- completed file at `.hoodie/<instant>.deltacommit`; layout v2 (version
9)
+-- at `.hoodie/timeline/<requested>_<completion>.deltacommit`.
+--
+-- 2. Read the table with Spark afterwards and write that to `gold_data`, so
+-- the gold is Hudi's own answer to the doctored layout.
+--
+-- Why the orphan is the middle commit and not the last: an orphan at the end
+-- sorts above the latest committed instant, so the future-block gate discards
it
+-- and the completed/inflight gate is never reached.
+--
+-- Why both table versions: on version 6 every log file carries the base
instant
+-- in its name, so the file cannot be attributed to a delta commit and only the
+-- per-block check can exclude the orphaned blocks. From version 8 each log
file
+-- carries its own delta commit, so the whole file is dropped when the file
slice
+-- is built and the per-block check is redundant, which is the condition Java
+-- applies in `BaseHoodieLogRecordReader`.
+
+CREATE TABLE table_uncommitted_log_vN (
+ ts BIGINT,
+ uuid STRING,
+ rider STRING,
+ fare DOUBLE
+) USING HUDI
+TBLPROPERTIES (
+ type = 'mor',
+ primaryKey = 'uuid',
+ preCombineField = 'ts',
+ 'hoodie.write.table.version' = 'WRITE_TABLE_VERSION',
+ 'hoodie.metadata.enable' = 'false',
+ 'hoodie.parquet.small.file.limit' = '0',
+ 'hoodie.compact.inline' = 'false'
+)
+LOCATION 'FIXTURE_LOCATION';
+
+INSERT INTO table_uncommitted_log_vN VALUES
+ (100, 'a', 'rider-A', 10.0),
+ (100, 'b', 'rider-B', 20.0),
+ (100, 'c', 'rider-C', 30.0),
+ (100, 'd', 'rider-D', 40.0);
+
+-- This commit is the one whose completed timeline file is deleted afterwards.
+-- Its blocks must not reach the merge, so `b` keeps the base row.
+UPDATE table_uncommitted_log_vN SET fare = 99.0, rider = 'ORPHANED-B', ts =
300 WHERE uuid = 'b';
+
+-- Committed, and at a later instant, so the orphan above sits below the latest
+-- committed instant rather than above it.
+UPDATE table_uncommitted_log_vN SET fare = 22.0, rider = 'committed-A', ts =
200 WHERE uuid = 'a';
diff --git
a/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log_v6.zip
b/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log_v6.zip
new file mode 100644
index 00000000..be987586
Binary files /dev/null and
b/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log_v6.zip
differ
diff --git
a/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log_v9.zip
b/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log_v9.zip
new file mode 100644
index 00000000..9a09aa0c
Binary files /dev/null and
b/crates/test/data/quickstart_trips_table/mor/avro/table_uncommitted_log_v9.zip
differ
diff --git a/crates/test/src/lib.rs b/crates/test/src/lib.rs
index 21d4116c..2eabda68 100644
--- a/crates/test/src/lib.rs
+++ b/crates/test/src/lib.rs
@@ -329,6 +329,52 @@ pub enum QuickstartTripsTable {
#[strum(serialize = "table_event_time_stale")]
MorEventTimeStale,
+ //
-------------------------------------------------------------------------
+ // A delta commit that wrote log blocks and never completed: what a writer
+ // killed mid-commit leaves behind. Its blocks must not reach the merge.
+ //
+ // The two versions take different routes to that answer, which is why both
+ // are here. Below table version 8 the timeline records no completion
times,
+ // so the log-block scan itself has to check the instant's state. From
+ // version 8 the completion times exist and the log file is dropped when
the
+ // file slice is built, so the per-block check is redundant and Java skips
it
+ // (`BaseHoodieLogRecordReader`, `tableVersion.lesserThan(EIGHT)`). A
fixture
+ // for only one version would leave the other route unexercised.
+ //
+ // Provenance: `table_uncommitted_log.sql` beside these zips (Spark 3.5.3 /
+ // Hudi 1.2.0-SNAPSHOT). Generated normally — a base write then two
updates —
+ // after which the *middle* delta commit's completed timeline file was
+ // deleted, leaving its `.inflight` and `.requested` and its log file
behind.
+ // gold_data is Spark reading the table in that state, so it is Hudi's own
+ // answer to the doctored layout.
+ //
+ // The middle one, not the last: an orphan at the end sorts above the
latest
+ // committed instant, so the future-block gate discards it and the
+ // completed/inflight gate is never reached. The first attempt at this
+ // fixture orphaned the last commit and passed with the gate disarmed —
+ // regenerate it that way and the sweep goes green proving nothing.
+ //
+ // Schema: ts LONG, uuid STRING, rider STRING, fare DOUBLE
(non-partitioned).
+ // Layout: base .parquet, 4 rows at ts 100, then two log files — the
orphaned
+ // update of `b` at ts 300, then the committed update of `a` at ts 200. The
+ // versions name those files differently, which is the whole point: on v6
+ // both are `.log.1`/`.log.2` carrying the *base* instant, so no file-level
+ // check can attribute either to a delta commit and only the per-block gate
+ // can exclude the orphan; on v9 each is a `.log.1` carrying its own delta
+ // commit, so the orphan's file is dropped when the slice is built.
+ // Semantics: `a` takes its update, `b` keeps the base row. gold_data = 4
rows.
+ // The orphan's ts 300 outranks every other row, so an admitted orphan
would
+ // win under `preCombineField = 'ts'` — the fixture fails loudly, not
silently.
+ //
-------------------------------------------------------------------------
+ /// Table version 6: the orphaned blocks are excluded by the log-block
scan's
+ /// completed/inflight gate.
+ #[strum(serialize = "table_uncommitted_log_v6")]
+ MorUncommittedLogV6,
+ /// Table version 9: the orphaned blocks are excluded when the file slice
is
+ /// built, with no per-block gate involved.
+ #[strum(serialize = "table_uncommitted_log_v9")]
+ MorUncommittedLogV9,
+
//
-------------------------------------------------------------------------
// Delete-block orderingVal wrapper-type fixtures (Task 7).
// Each table: v9 MOR, COMMIT_TIME_ORDERING, NON_PARTITIONED, 4 rows
inserted