Skip to content

feat(table): support scan.watermark batch time travel - #677

Open
u70b3 wants to merge 5 commits into
apache:mainfrom
u70b3:feat/scan-watermark-time-travel
Open

feat(table): support scan.watermark batch time travel#677
u70b3 wants to merge 5 commits into
apache:mainfrom
u70b3:feat/scan-watermark-time-travel

Conversation

@u70b3

@u70b3 u70b3 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Purpose

Linked issue: close #676

Support watermark-based batch time travel, mirroring Java's StaticFromWatermarkStartingScanner and TimeTravelUtil.adaptScanVersion: direct scan.watermark and VERSION AS OF 'watermark-<value>' resolve the earliest snapshot whose watermark is greater than or equal to the requested value and scan it in full. Today scan.watermark is on the validate_scan_options blocklist, so Java-written tables carrying it cannot be read from Rust at all.

Brief change log

  • SnapshotManager::later_or_equal_watermark: binary search over the actual snapshot id list (gap-tolerant, same pattern as later_or_equal_time_millis). Snapshots without a watermark are skipped — both None and Some(i64::MIN), since Flink writers use Long.MIN_VALUE as the no-watermark sentinel.
  • CoreOptions: scan.watermark becomes a first-class TimeTravelSelector (mutual exclusion with the other selectors, strict i64 parsing); removed from the unsupported scan-option blocklist; accepted under scan.mode=from-snapshot (Java's startupMode() maps it to FROM_SNAPSHOT).
  • scan.version: mirror Java's tag-first resolution order — existing tag → watermark-<value> → snapshot id — enabling VERSION AS OF 'watermark-<value>' in DataFusion SQL.
  • travel_to_snapshot: direct and version-prefixed watermark selectors share one resolver; no match fails at scan planning with Java's message, while copy_with_time_travel keeps Java's silent-fallback behavior.
  • Table::copy_with_options: changing scan.watermark invalidates the cached resolved snapshot.
  • C bindings: the unsupported_scan_option_is_rejected test now uses incremental-between as its example (scan.watermark is supported now).
  • docs/src/sql.md: new "By Watermark" subsection documenting both VERSION AS OF 'watermark-<value>' and the dynamic option.

Assumptions / deviations to be aware of (per the AI-assisted PR policy):

  • Deliberate difference from the current Java implementation: Java's binary search can retain the raw midpoint snapshot after walking backward over missing watermark metadata. This implementation only returns a snapshot whose own effective watermark satisfies watermark >= requested, preserving the selector contract when watermark metadata is sparse. Documented in the method's doc comment.
  • Watermarks are assumed non-decreasing in snapshot order (guaranteed by Flink/Java writers); the binary search relies on this.
  • Rust's own commit path never writes watermarks, so the new tests commit Snapshots with watermarks directly through SnapshotManager; no write-path changes are included.

Tests

  • cargo fmt --all -- --check
  • cargo clippy --locked --all-targets -p paimon -p paimon-datafusion -- -D warnings
  • cargo test --locked -p paimon --lib — 2101 passed, 0 failed, 1 ignored (11 new watermark tests: sentinel/missing/duplicate watermarks, exact/between/out-of-range matches, snapshot-id gaps, selector mutual exclusion, Java watermark-<value> resolution with tag precedence, cache invalidation, and scan-time errors)
  • cargo test --locked -p paimon-datafusion --test time_travel_schema_tests — 6 passed, including an end-to-end VERSION AS OF 'watermark-<value>' test matching Java's 1/9/10/no-match cases
  • cargo test --locked -p paimon-c unsupported_scan_option — 1 passed

API and Format

No storage format changes. The public Rust API gains SnapshotManager::later_or_equal_watermark, and SCAN_WATERMARK_OPTION becomes public alongside the other selector constants.

Documentation

docs/src/sql.md Time Travel section gains a "By Watermark" subsection documenting both VERSION AS OF 'watermark-<value>' and the session-scoped SET 'paimon.scan.watermark' dynamic option.

🤖 Generated with Claude Code

u70b3 added 5 commits August 4, 2026 19:35
Add scan.watermark as a batch time-travel selector, mirroring Java's
StaticFromWatermarkStartingScanner: resolve the earliest snapshot whose
watermark is greater than or equal to the requested value and scan it in
full. Snapshots without a watermark (None or the Flink Long.MIN_VALUE
sentinel) are skipped.

- SnapshotManager::later_or_equal_watermark: binary search over the
  actual snapshot id list (gap-tolerant), returning the earliest match.
- CoreOptions: parse scan.watermark as a first-class TimeTravelSelector
  (mutual exclusion with other selectors, strict i64 parsing), drop it
  from the unsupported scan-option blocklist, and accept it for
  scan.mode=from-snapshot like Java's startupMode mapping.
- travel_to_snapshot: resolve the selector, erroring at scan planning
  with Java's message when no snapshot matches.
- Table::copy_with_options: changing scan.watermark invalidates the
  cached travel snapshot like the other selectors.
- docs: sql.md Time Travel section gains a By Watermark subsection.
The C and Python read-builder gates enumerate the time-travel selectors
to reject conflicts up front and to error when a set selector resolves
to no snapshot. Without scan.watermark in those lists, an unresolvable
watermark fell through the strict gate and silently read latest, and a
watermark plus another selector went undetected (the core swallows the
conflict via its Java-parity silent fallback).
@JingsongLi

Copy link
Copy Markdown
Contributor

The watermark lookup logic differs from the Java implementation. When a snapshot's watermark is Long.MIN_VALUE, the Java implementation returns null if the latest snapshot hits this sentinel value, whereas the Rust implementation skips it and returns an earlier snapshot. The error—returning snapshot 1—has been reproduced by querying for 100 with the range [100, MIN_VALUE].

@u70b3

u70b3 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for checking this case — I can confirm the reproduction: for [100, Long.MIN_VALUE] querying 100, this implementation returns snapshot 1 while released Java returns null. This is the deliberate difference documented in the PR description, so let me explain the reasoning and how I'd propose to sequence it.

The Rust behavior implements the semantics of apache/paimon#9037 rather than released Java: null and Long.MIN_VALUE are treated uniformly as "missing", and there is no early return based on the latest snapshot's value. A latest-value check is unsound for mixed histories — [100, Long.MIN_VALUE] is exactly that shape: the latest snapshot carries no watermark, yet an earlier real watermark satisfies the query, so the selector contract requires snapshot 1 to be found. This is the same conclusion as your review on #9037: "[100, null] must still find the earlier valid watermark." The sentinel case follows identically once both are treated as missing. (For [100, null] there is in fact no released-Java behavior to mirror at all — the fast path unboxes snapshot(latest).watermark() and NPEs.)

So the two PRs are a pair: #9037 fixes the Java searches, and this PR implements the same semantics in Rust from the start, so no released Rust version ever disagrees with Java on this lookup. The other documented deviation (returning the walked-back watermark-bearing snapshot instead of the raw midpoint, which may carry no watermark at all) is likewise aligned with #9037.

My proposal: hold this PR until #9037 is merged — at that point the Rust behavior matches the Java implementation by construction, including this exact case. If you'd prefer not to gate Rust on the Java fix, I can instead reinstate the latest-sentinel early return here to mirror released Java, and drop it in a follow-up once #9037 lands. Which sequencing would you prefer?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support watermark-based batch time travel (scan.watermark)

2 participants