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 ae62495d feat(table): honor numeric incremental timestamp scan options
(#1074)
ae62495d is described below
commit ae62495d275ccf3811c38128788be8cf7e144177
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 8 14:46:45 2026 +0800
feat(table): honor numeric incremental timestamp scan options (#1074)
---
bindings/python/README.md | 7 +
.../python/python/pypaimon_rust/datafusion.pyi | 8 +-
bindings/python/tests/test_read.py | 29 +-
crates/paimon/src/spec/core_options.rs | 157 +++++++-
crates/paimon/src/table/format_table_scan.rs | 1 +
crates/paimon/src/table/incremental_scan.rs | 103 +++++-
crates/paimon/src/table/scan_trace.rs | 34 ++
crates/paimon/src/table/source.rs | 5 +
crates/paimon/src/table/table_merge_input.rs | 13 +-
crates/paimon/src/table/table_merge_into.rs | 2 +-
crates/paimon/src/table/table_scan.rs | 264 +++++---------
.../tests/incremental_timestamp_scan_test.rs | 396 +++++++++++++++++++++
12 files changed, 803 insertions(+), 216 deletions(-)
diff --git a/bindings/python/README.md b/bindings/python/README.md
index 3effeb7f..6de276a0 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -129,6 +129,13 @@ logical rows through the ordinary reader, including
primary-key merge engines.
The source does not require data evolution or row tracking. Arrow batches keep
their chunk boundaries; matching gathers only the contributing source chunks.
+Numeric `incremental-between-timestamp` options select `(start, end]` in core,
+including for table-source MERGE. AUTO reads APPEND deltas without a changelog
+producer and physical changelogs otherwise; OVERWRITE snapshots are skipped.
+Equal bounds return an empty plan. Window plans and all their splits identify
+the ending snapshot, including when no selected files remain. The existing
+`new_scan()` honors these options; no new Python API is required.
+
### Custom Blob URI sources
Before the first Arrow write, call
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 52b26b71..adfa1c75 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -268,8 +268,8 @@ class BatchTableUpdate:
) -> List[CommitMessage]:
"""Prepare MERGE against one target snapshot. None selects self-merge
on _ROW_ID.
- A Table source reads its selected full snapshot in core, independently
- of the target. Arrow input preserves batches and source row order.
+ A Table source reads its selected snapshot or numeric timestamp window
+ in core, independently of the target. Arrow input preserves batches
and source row order.
Clauses carry assignments as (column, source/target/literal, value)
tuples, a delete flag, and optional SQL condition metadata.
@@ -310,8 +310,8 @@ class StreamTableUpdate:
) -> List[CommitMessage]:
"""Prepare MERGE against one target snapshot. None selects self-merge
on _ROW_ID.
- A Table source reads its selected full snapshot in core, independently
- of the target. Arrow input preserves batches and source row order.
+ A Table source reads its selected snapshot or numeric timestamp window
+ in core, independently of the target. Arrow input preserves batches
and source row order.
Clauses carry assignments as (column, source/target/literal, value)
tuples, a delete flag, and optional SQL condition metadata.
diff --git a/bindings/python/tests/test_read.py
b/bindings/python/tests/test_read.py
index 55d91b46..1a88cbe2 100644
--- a/bindings/python/tests/test_read.py
+++ b/bindings/python/tests/test_read.py
@@ -1315,11 +1315,11 @@ def test_explicit_scan_mode_without_selector_raises():
table.new_read_builder({"scan.mode": "from-snapshot"})
-def test_unimplemented_scan_mode_raises_not_implemented():
+def test_incremental_scan_mode_requires_timestamp_window():
with tempfile.TemporaryDirectory() as warehouse:
_make_two_snapshot_table(warehouse)
table = PaimonCatalog({"warehouse": warehouse}).get_table("tdb.t")
- with pytest.raises(NotImplementedError):
+ with pytest.raises(ValueError, match="incremental-between-timestamp"):
table.new_read_builder({"scan.mode": "incremental"})
@@ -1730,3 +1730,28 @@ def
test_incremental_row_positions_use_combined_delta_batch():
builder.with_row_ranges([(1, 3)]).with_limit(2)
plan = builder.new_incremental_scan(0, 2).with_row_position_slice(2,
5).plan()
assert
pa.Table.from_batches(builder.new_read().read(plan.splits())).column("id").to_pylist()
== [2, 3]
+
+
[email protected]("window,snapshot,expected", [
+ ("0,100", 1, [dict(id=1, name="a")]),
+ ("100,200", 2, [dict(id=2, name="b"), dict(id=3, name="c")]),
+ ("100,150", 1, []),
+ ("100,100", None, []),
+])
[email protected]("mode", ["default", "incremental"])
+def test_numeric_timestamp_window_options_use_core_scan(tmp_path, window,
snapshot, expected, mode):
+ from pathlib import Path
+
+ _make_two_snapshot_table(str(tmp_path))
+ table = PaimonCatalog({"warehouse": str(tmp_path)}).get_table("tdb.t")
+ for identifier, timestamp in [(1, 100), (2, 200)]:
+ path = Path(table.location()) / "snapshot" / ("snapshot-%s" %
identifier)
+ data = json.loads(path.read_text())
+ data["timeMillis"] = timestamp
+ path.write_text(json.dumps(data))
+ builder = table.new_read_builder({"scan.mode": mode,
"incremental-between-timestamp": window})
+ plan = builder.new_scan().plan()
+ assert plan.snapshot_id() == snapshot
+ batches = list(builder.new_read().read(plan.splits()))
+ actual = [row for batch in batches for row in batch.to_pylist()]
+ assert sorted(actual, key=lambda row: row["id"]) == expected
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 3bacbaa3..d740b08e 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -527,11 +527,11 @@ impl<'a> CoreOptions<'a> {
/// that selector is present (the batch-read semantics are identical to
/// leaving the mode at `default`); an explicit mode without its selector
/// is malformed input (`Error::DataInvalid`), mirroring Java's
- /// `SchemaValidation`. All other non-default modes are unimplemented.
+ /// `SchemaValidation`. Incremental timestamp windows reuse the batch
+ /// Delta/Changelog scanner; snapshot/tag ranges remain unimplemented here.
pub fn validate_scan_options(&self) -> crate::Result<()> {
for key in [
INCREMENTAL_BETWEEN_OPTION,
- INCREMENTAL_BETWEEN_TIMESTAMP_OPTION,
INCREMENTAL_BETWEEN_SCAN_MODE_OPTION,
] {
if self.options.contains_key(key) {
@@ -540,9 +540,47 @@ impl<'a> CoreOptions<'a> {
});
}
}
+ let incremental = self.incremental_timestamp_window()?.is_some();
+ if incremental {
+ self.try_changelog_producer()?;
+ if self.is_format_table() {
+ return Err(crate::Error::Unsupported {
+ message: "Incremental timestamp scans require a Paimon
table".into(),
+ });
+ }
+ let selectors = self.configured_time_travel_selectors();
+ if !selectors.is_empty()
+ || self.options.contains_key("scan.file-creation-time-millis")
+ || self.options.contains_key("scan.creation-time-millis")
+ {
+ return Err(crate::Error::DataInvalid {
+ message: "incremental-between-timestamp cannot be used
with point-in-time scan options".into(),
+ source: None,
+ });
+ }
+ }
if let Some(mode) = self.options.get(SCAN_MODE_OPTION) {
+ if incremental
+ && !mode.eq_ignore_ascii_case("default")
+ && !mode.eq_ignore_ascii_case("incremental")
+ {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "incremental-between-timestamp cannot be used with
scan.mode={mode}"
+ ),
+ source: None,
+ });
+ }
let selector_keys: &[&str] = if
mode.eq_ignore_ascii_case("default") {
return Ok(());
+ } else if mode.eq_ignore_ascii_case("incremental") {
+ if incremental {
+ return Ok(());
+ }
+ return Err(crate::Error::DataInvalid {
+ message: "scan.mode=incremental requires
incremental-between-timestamp".into(),
+ source: None,
+ });
} else if mode.eq_ignore_ascii_case("from-snapshot") {
&[
SCAN_SNAPSHOT_ID_OPTION,
@@ -575,6 +613,22 @@ impl<'a> CoreOptions<'a> {
Ok(())
}
+ /// The numeric timestamp window supported by PyPaimon. Snapshot resolution
+ /// and ordering checks belong to the batch scanner, as in Java.
+ pub(crate) fn incremental_timestamp_window(&self) ->
crate::Result<Option<(i64, i64)>> {
+ let Some(raw) = self.options.get(INCREMENTAL_BETWEEN_TIMESTAMP_OPTION)
else {
+ return Ok(None);
+ };
+ let parse = || {
+ let (start, end) = raw.split_once(',')?;
+ Some((start.parse::<i64>().ok()?, end.parse::<i64>().ok()?))
+ };
+ parse().map(Some).ok_or_else(|| crate::Error::DataInvalid {
+ message: format!("incremental-between-timestamp requires
start(exclusive),end(inclusive) in milliseconds, got: {raw}"),
+ source: None,
+ })
+ }
+
pub fn deletion_vectors_enabled(&self) -> bool {
self.options
.get(DELETION_VECTORS_ENABLED_OPTION)
@@ -1286,7 +1340,7 @@ impl<'a> CoreOptions<'a> {
pub fn ensure_engine_can_serve(&self, full_name: &str) ->
crate::Result<()> {
self.ensure_query_auth_absent()?;
self.validate_scan_options()?;
- if self.has_time_travel_selector() {
+ if self.has_time_travel_selector() ||
self.incremental_timestamp_window()?.is_some() {
return Err(crate::Error::Unsupported {
message: format!(
"time travel is not supported for engine-served table
'{full_name}'"
@@ -3715,11 +3769,7 @@ mod tests {
#[test]
fn test_validate_scan_options_rejects_unsupported() {
- for key in [
- "incremental-between",
- "incremental-between-timestamp",
- "incremental-between-scan-mode",
- ] {
+ for key in ["incremental-between", "incremental-between-scan-mode"] {
let options = HashMap::from([(key.to_string(), "x".to_string())]);
let err = CoreOptions::new(&options)
.validate_scan_options()
@@ -3728,6 +3778,95 @@ mod tests {
}
}
+ #[test]
+ fn test_numeric_incremental_timestamp_options_match_java_long_rules() {
+ for value in [
+ "0,100",
+ "100,100",
+ "200,100",
+ "+0,+100",
+ "-100,100",
+ "-9223372036854775808,9223372036854775807",
+ ] {
+ for mode in [None, Some("default"), Some("incremental")] {
+ let mut options =
+ HashMap::from([("incremental-between-timestamp".into(),
value.into())]);
+ if let Some(mode) = mode {
+ options.insert("scan.mode".into(), mode.into());
+ }
+ assert!(
+ CoreOptions::new(&options).validate_scan_options().is_ok(),
+ "{options:?}"
+ );
+ }
+ }
+ for value in [
+ "100",
+ "one,200",
+ "100,200,300",
+ "100, 200",
+ "1_00,200",
+ "0,9223372036854775808",
+ "-9223372036854775809,100",
+ ] {
+ let options =
HashMap::from([("incremental-between-timestamp".into(), value.into())]);
+ assert!(
+ matches!(
+ CoreOptions::new(&options).validate_scan_options(),
+ Err(crate::Error::DataInvalid { .. })
+ ),
+ "{value}"
+ );
+ }
+ }
+
+ #[test]
+ fn
test_incremental_timestamp_options_reject_conflicting_and_unservable_routes() {
+ for selector in [
+ "scan.snapshot-id",
+ "scan.tag-name",
+ "scan.version",
+ "scan.watermark",
+ "scan.timestamp",
+ "scan.timestamp-millis",
+ "scan.file-creation-time-millis",
+ "scan.creation-time-millis",
+ ] {
+ let options = HashMap::from([
+ ("incremental-between-timestamp".into(), "0,100".into()),
+ (selector.into(), "1".into()),
+ ]);
+ assert!(
+ matches!(
+ CoreOptions::new(&options).validate_scan_options(),
+ Err(crate::Error::DataInvalid { .. })
+ ),
+ "{selector}"
+ );
+ }
+ let options = HashMap::from([("scan.mode".into(),
"incremental".into())]);
+ assert!(matches!(
+ CoreOptions::new(&options).validate_scan_options(),
+ Err(crate::Error::DataInvalid { .. })
+ ));
+ let options = HashMap::from([("incremental-between-timestamp".into(),
"0,100".into())]);
+ assert!(matches!(
+ CoreOptions::new(&options).ensure_engine_can_serve("db.t"),
+ Err(crate::Error::Unsupported { .. })
+ ));
+ let mut format_options = options.clone();
+ format_options.insert("type".into(), "format-table".into());
+ assert!(matches!(
+ CoreOptions::new(&format_options).validate_scan_options(),
+ Err(crate::Error::Unsupported { .. })
+ ));
+ let mut producer_options = options;
+ producer_options.insert("changelog-producer".into(), "invalid".into());
+ assert!(CoreOptions::new(&producer_options)
+ .validate_scan_options()
+ .is_err());
+ }
+
#[test]
fn test_validate_scan_options_scan_mode_whitelist() {
// absent OK
@@ -3738,7 +3877,7 @@ mod tests {
let ok = HashMap::from([("scan.mode".to_string(),
"default".to_string())]);
assert!(CoreOptions::new(&ok).validate_scan_options().is_ok());
// unimplemented modes Unsupported
- for mode in ["compacted-full", "incremental", "latest", "latest-full"]
{
+ for mode in ["compacted-full", "latest", "latest-full"] {
let bad = HashMap::from([("scan.mode".to_string(),
mode.to_string())]);
let err =
CoreOptions::new(&bad).validate_scan_options().unwrap_err();
assert!(
diff --git a/crates/paimon/src/table/format_table_scan.rs
b/crates/paimon/src/table/format_table_scan.rs
index 6af2c158..b5a1d1a0 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -78,6 +78,7 @@ impl<'a> FormatTableScan<'a> {
}
fn ensure_query_auth_allowed(&self) -> crate::Result<()> {
+
CoreOptions::new(self.table.schema().options()).validate_scan_options()?;
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()
}
diff --git a/crates/paimon/src/table/incremental_scan.rs
b/crates/paimon/src/table/incremental_scan.rs
index e2e82e04..7d30fd73 100644
--- a/crates/paimon/src/table/incremental_scan.rs
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -15,9 +15,43 @@
// specific language governing permissions and limitations
// under the License.
-use super::{DataSplit, Plan, SnapshotManager, Table, TableScan};
+use super::{DataSplit, Plan, ScanTrace, SnapshotManager, Table, TableScan};
use crate::spec::{CommitKind, CoreOptions};
+/// Java IncrementalDeltaStartingScanner.betweenTimestamps. Resolve both bounds
+/// once; readers consume the resulting plan and never reselect a source
window.
+pub(super) async fn timestamp_snapshot_range(
+ manager: &SnapshotManager,
+ start: i64,
+ end: i64,
+) -> crate::Result<Option<(i64, i64)>> {
+ let Some(earliest_id) = manager.earliest_snapshot_id().await? else {
+ return Ok(None);
+ };
+ let earliest = manager.get_snapshot(earliest_id).await?;
+ let Some(latest) = manager.get_latest_snapshot().await? else {
+ return Ok(None);
+ };
+ if end < start {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Ending timestamp {end} should be >= starting
timestamp {start}"),
+ source: None,
+ });
+ }
+ if start == end || start > latest.time_millis() as i64 || end <
earliest.time_millis() as i64 {
+ return Ok(None);
+ }
+ let start_id = manager
+ .earlier_or_equal_time_millis(start)
+ .await?
+ .map_or(earliest.id() - 1, |snapshot| snapshot.id());
+ let end_id = manager
+ .earlier_or_equal_time_millis(end)
+ .await?
+ .map_or(latest.id(), |snapshot| snapshot.id());
+ Ok(Some((start_id, end_id)))
+}
+
/// Batch incremental scan mode.
///
/// Range semantics: `(start_exclusive, end_inclusive]` — start is exclusive
and
@@ -273,6 +307,20 @@ impl<'a> IncrementalScan<'a> {
}
pub async fn plan(&self) -> crate::Result<IncrementalPlan> {
+ let mode = self.resolve_mode();
+ self.validate_per_snapshot_plan(mode).await?;
+ if self.start_exclusive == self.end_inclusive {
+ return Ok(IncrementalPlan::new(mode, Vec::new()));
+ }
+ match mode {
+ IncrementalScanMode::Delta => self.plan_delta(mode).await,
+ IncrementalScanMode::Changelog => self.plan_changelog(mode,
None).await,
+ IncrementalScanMode::Auto => unreachable!("Auto must resolve
before planning"),
+ IncrementalScanMode::Diff => self.plan_diff(mode).await,
+ }
+ }
+
+ async fn validate_per_snapshot_plan(&self, mode: IncrementalScanMode) ->
crate::Result<()> {
crate::spec::CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
if self.scan.has_row_position_selection()
|| self.scan.has_chunk_shuffle()
@@ -283,17 +331,7 @@ impl<'a> IncrementalScan<'a> {
.into(),
});
}
- let mode = self.resolve_mode();
- self.validate_snapshot_range(mode).await?;
- if self.start_exclusive == self.end_inclusive {
- return Ok(IncrementalPlan::new(mode, Vec::new()));
- }
- match mode {
- IncrementalScanMode::Delta => self.plan_delta(mode).await,
- IncrementalScanMode::Changelog => self.plan_changelog(mode).await,
- IncrementalScanMode::Auto => unreachable!("Auto must resolve
before planning"),
- IncrementalScanMode::Diff => self.plan_diff(mode).await,
- }
+ self.validate_snapshot_range(mode).await
}
/// Plan a Delta or Changelog range as one ordinary [`Plan`].
@@ -305,14 +343,22 @@ impl<'a> IncrementalScan<'a> {
/// represented by an ordinary split list because each unit contains a
/// before/after pair.
pub async fn plan_combined(&self) -> crate::Result<Plan> {
+ self.plan_combined_with_trace(None).await
+ }
+
+ pub(crate) async fn plan_combined_with_trace(
+ &self,
+ trace: Option<&mut ScanTrace>,
+ ) -> crate::Result<Plan> {
match self.resolve_mode() {
- IncrementalScanMode::Delta => self.plan_combined_delta().await,
+ IncrementalScanMode::Delta =>
self.plan_combined_delta_with_trace(trace).await,
IncrementalScanMode::Changelog => {
- let incremental = self.plan().await?;
+
self.validate_per_snapshot_plan(IncrementalScanMode::Changelog).await?;
+ let incremental =
self.plan_changelog(IncrementalScanMode::Changelog, trace).await?;
let mut splits =
Vec::with_capacity(incremental.splits().len());
for split in incremental.splits() {
match split {
- IncrementalSplit::Data(split) =>
splits.push(split.clone()),
+ IncrementalSplit::Data(split) =>
splits.push(split.clone().with_snapshot_id(self.end_inclusive)),
IncrementalSplit::DiffPair { .. } => {
return Err(crate::Error::UnexpectedError {
message: "DiffPair appeared in a Changelog
incremental plan"
@@ -341,6 +387,13 @@ impl<'a> IncrementalScan<'a> {
/// must exist and supplies snapshot metadata. Snapshot deletion vectors
and
/// automatic global-index pruning do not apply to these historical events.
pub async fn plan_combined_delta(&self) -> crate::Result<Plan> {
+ self.plan_combined_delta_with_trace(None).await
+ }
+
+ async fn plan_combined_delta_with_trace(
+ &self,
+ trace: Option<&mut ScanTrace>,
+ ) -> crate::Result<Plan> {
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
let mode = self.resolve_mode();
if mode != IncrementalScanMode::Delta {
@@ -366,7 +419,7 @@ impl<'a> IncrementalScan<'a> {
snapshots.push(end_snapshot.clone());
}
self.scan
- .plan_snapshot_deltas(&snapshots, &end_snapshot)
+ .plan_snapshot_deltas_with_trace(&snapshots, &end_snapshot, trace)
.await
}
@@ -435,7 +488,11 @@ impl<'a> IncrementalScan<'a> {
IncrementalPlan::try_new(mode, splits)
}
- async fn plan_changelog(&self, mode: IncrementalScanMode) ->
crate::Result<IncrementalPlan> {
+ async fn plan_changelog(
+ &self,
+ mode: IncrementalScanMode,
+ mut trace: Option<&mut ScanTrace>,
+ ) -> crate::Result<IncrementalPlan> {
let mut splits = Vec::new();
for snapshot_id in (self.start_exclusive + 1)..=self.end_inclusive {
let snapshot =
self.snapshot_manager.get_snapshot(snapshot_id).await?;
@@ -447,7 +504,17 @@ impl<'a> IncrementalScan<'a> {
if snapshot.changelog_manifest_list().is_none() {
continue;
}
- let plan = self.scan.plan_snapshot_changelog(&snapshot).await?;
+ let mut snapshot_trace = ScanTrace::default();
+ let plan = self
+ .scan
+ .plan_snapshot_changelog_with_trace(
+ &snapshot,
+ trace.as_ref().map(|_| &mut snapshot_trace),
+ )
+ .await?;
+ if let Some(trace) = trace.as_deref_mut() {
+ trace.accumulate(&snapshot_trace);
+ }
splits.extend(plan.splits().iter().cloned().map(IncrementalSplit::Data));
}
IncrementalPlan::try_new(mode, splits)
diff --git a/crates/paimon/src/table/scan_trace.rs
b/crates/paimon/src/table/scan_trace.rs
index 45063800..8cd8aa25 100644
--- a/crates/paimon/src/table/scan_trace.rs
+++ b/crates/paimon/src/table/scan_trace.rs
@@ -59,6 +59,40 @@ pub struct ScanTrace {
}
impl ScanTrace {
+ /// Add counters from another independently planned changelog snapshot.
+ pub(crate) fn accumulate(&mut self, other: &Self) {
+ self.base_manifest_files += other.base_manifest_files;
+ self.delta_manifest_files += other.delta_manifest_files;
+ self.manifest_files_before_partition_pruning +=
+ other.manifest_files_before_partition_pruning;
+ self.manifest_files_after_partition_pruning +=
other.manifest_files_after_partition_pruning;
+ self.manifest_files_pruned_by_row_ranges +=
other.manifest_files_pruned_by_row_ranges;
+ self.manifest_entries_read += other.manifest_entries_read;
+ self.manifest_entries_pruned_by_bucket +=
other.manifest_entries_pruned_by_bucket;
+ self.manifest_entries_pruned_by_partition +=
other.manifest_entries_pruned_by_partition;
+ self.manifest_entries_after_entry_pruning +=
other.manifest_entries_after_entry_pruning;
+ self.manifest_entries_pruned_by_level +=
other.manifest_entries_pruned_by_level;
+ self.manifest_entries_pruned_by_row_ranges +=
other.manifest_entries_pruned_by_row_ranges;
+ self.manifest_entries_pruned_by_data_stats +=
other.manifest_entries_pruned_by_data_stats;
+ self.manifest_entries_after_manifest_filters +=
+ other.manifest_entries_after_manifest_filters;
+ self.manifest_entries_after_merge +=
other.manifest_entries_after_merge;
+ self.manifest_entries_pruned_by_cross_schema_stats +=
+ other.manifest_entries_pruned_by_cross_schema_stats;
+ self.manifest_entries_after_cross_schema_stats +=
+ other.manifest_entries_after_cross_schema_stats;
+ self.data_evolution_groups_before_stats +=
other.data_evolution_groups_before_stats;
+ self.data_evolution_groups_pruned_by_stats +=
other.data_evolution_groups_pruned_by_stats;
+ self.data_evolution_groups_pruned_by_row_ranges +=
+ other.data_evolution_groups_pruned_by_row_ranges;
+ self.split_candidates_built += other.split_candidates_built;
+ self.splits_before_limit += other.splits_before_limit;
+ self.splits_after_limit += other.splits_after_limit;
+ self.final_splits += other.final_splits;
+ self.final_files += other.final_files;
+ self.limit_early_stopped |= other.limit_early_stopped;
+ }
+
pub(crate) fn record_manifest_lists(&mut self, base_count: usize,
delta_count: usize) {
self.base_manifest_files = base_count;
self.delta_manifest_files = delta_count;
diff --git a/crates/paimon/src/table/source.rs
b/crates/paimon/src/table/source.rs
index 90e12e37..fd7a8d13 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -552,6 +552,11 @@ impl DataSplit {
pub fn snapshot_id(&self) -> i64 {
self.snapshot_id
}
+
+ pub(crate) fn with_snapshot_id(mut self, snapshot_id: i64) -> Self {
+ self.snapshot_id = snapshot_id;
+ self
+ }
pub fn partition(&self) -> &BinaryRow {
&self.partition
}
diff --git a/crates/paimon/src/table/table_merge_input.rs
b/crates/paimon/src/table/table_merge_input.rs
index a0a03d4c..67b910d6 100644
--- a/crates/paimon/src/table/table_merge_input.rs
+++ b/crates/paimon/src/table/table_merge_input.rs
@@ -108,7 +108,16 @@ impl MergeInput {
.iter()
.map(|field| field.name().as_str())
.collect::<Vec<_>>();
- let snapshot = super::time_travel::resolve_snapshot(table).await?;
+ let incremental = table
+ .schema()
+ .core_options()
+ .incremental_timestamp_window()?
+ .is_some();
+ let snapshot = if incremental {
+ None
+ } else {
+ super::time_travel::resolve_snapshot(table).await?
+ };
// Pin the source independently, keeping branch/time-travel resolution
// and the caller's resolved schema. Include L0 as Python
plan_for_write.
let table = snapshot.as_ref().map_or_else(
@@ -121,7 +130,7 @@ impl MergeInput {
);
let mut builder = table.new_read_builder();
builder.with_projection(&names)?;
- let plan = if snapshot.is_some() {
+ let plan = if incremental || snapshot.is_some() {
builder.new_scan().with_scan_all_files().plan().await?
} else {
// Run the normal reader's policy checks even with no snapshot, but
diff --git a/crates/paimon/src/table/table_merge_into.rs
b/crates/paimon/src/table/table_merge_into.rs
index 1eaae977..47ae2147 100644
--- a/crates/paimon/src/table/table_merge_into.rs
+++ b/crates/paimon/src/table/table_merge_into.rs
@@ -72,7 +72,7 @@ pub struct WhenNotMatched {
/// Materialized input, a Paimon source table, or the target joined on its row
ID.
pub enum MergeSource {
Batches(Vec<RecordBatch>),
- /// Read a Paimon table at one independently pinned source snapshot.
+ /// Read a Paimon table at an independently selected snapshot or timestamp
window.
Table(Arc<Table>),
SelfTable,
}
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index edd67a42..e728c730 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -213,6 +213,7 @@ enum IncrementalSplitMode {
enum ManifestListSource<'a> {
Snapshot(&'a Snapshot),
AppendDeltas(&'a [Snapshot]),
+ IncrementalList(&'a str),
}
/// Reads all manifest entries for a snapshot (base + delta manifest lists,
then each manifest file).
@@ -244,12 +245,16 @@ async fn read_all_manifest_entries(
manifest_parallelism: usize,
trace: Option<&mut ScanTrace>,
) -> crate::Result<Vec<ManifestEntry>> {
- let incremental = matches!(&source, ManifestListSource::AppendDeltas(_));
+ let incremental = !matches!(&source, ManifestListSource::Snapshot(_));
let (mut manifest_files, delta) = match source {
ManifestListSource::Snapshot(snapshot) => futures::try_join!(
read_manifest_list(file_io, table_path,
snapshot.base_manifest_list()),
read_manifest_list(file_io, table_path,
snapshot.delta_manifest_list()),
)?,
+ ManifestListSource::IncrementalList(name) => (
+ Vec::new(),
+ read_manifest_list(file_io, table_path, name).await?,
+ ),
ManifestListSource::AppendDeltas(snapshots) => {
let names = snapshots
.iter()
@@ -1319,13 +1324,17 @@ impl<'a> TableScan<'a> {
}
}
- pub(crate) async fn plan_snapshot_deltas(
+ pub(crate) async fn plan_snapshot_deltas_with_trace(
&self,
snapshots: &[Snapshot],
end_snapshot: &Snapshot,
+ trace: Option<&mut ScanTrace>,
) -> crate::Result<Plan> {
match &self.0 {
- TableScanKind::Paimon(scan) =>
scan.plan_snapshot_deltas(snapshots, end_snapshot).await,
+ TableScanKind::Paimon(scan) => {
+ scan.plan_snapshot_deltas(snapshots, end_snapshot, trace)
+ .await
+ }
TableScanKind::Format(_) => Err(crate::Error::Unsupported {
message: "Format tables do not support incremental delta
scan".to_string(),
}),
@@ -1355,9 +1364,13 @@ impl<'a> TableScan<'a> {
}
/// Plan data splits from a snapshot's changelog manifest list only.
- pub(crate) async fn plan_snapshot_changelog(&self, snapshot: &Snapshot) ->
crate::Result<Plan> {
+ pub(crate) async fn plan_snapshot_changelog_with_trace(
+ &self,
+ snapshot: &Snapshot,
+ trace: Option<&mut ScanTrace>,
+ ) -> crate::Result<Plan> {
match &self.0 {
- TableScanKind::Paimon(scan) =>
scan.plan_snapshot_changelog(snapshot).await,
+ TableScanKind::Paimon(scan) =>
scan.plan_snapshot_changelog(snapshot, trace).await,
TableScanKind::Format(_) => Err(crate::Error::Unsupported {
message: "Format tables do not support incremental changelog
scan".to_string(),
}),
@@ -1543,6 +1556,16 @@ impl<'a> PaimonTableScan<'a> {
.filter(|grant| !grant.is_unrestricted())
.map(|grant| self.restricted_by(grant));
let scan = restricted.as_ref().unwrap_or(self);
+ if let Some((start, end)) = scan
+ .table
+ .schema()
+ .core_options()
+ .incremental_timestamp_window()?
+ {
+ let plan = scan.plan_timestamp_window(start, end, None).await?;
+ scan.check_planned_files(&plan, grant.is_some()).await?;
+ return Ok(plan.planned(grant));
+ }
let data_evolution_read_field_ids = scan.projected_read_field_ids()?;
let plan = match
super::time_travel::resolve_snapshot(scan.table).await? {
Some(snapshot) => {
@@ -1567,6 +1590,20 @@ impl<'a> PaimonTableScan<'a> {
limit: scan.limit,
..Default::default()
};
+ if let Some((start, end)) = scan
+ .table
+ .schema()
+ .core_options()
+ .incremental_timestamp_window()?
+ {
+ let plan = scan
+ .plan_timestamp_window(start, end, Some(&mut trace))
+ .await?;
+ trace.snapshot_id = plan.snapshot_id();
+ trace.planned_data_file_bytes = plan.planned_data_file_bytes();
+ scan.check_planned_files(&plan, grant.is_some()).await?;
+ return Ok((plan.planned(grant), trace));
+ }
let data_evolution_read_field_ids = scan.projected_read_field_ids()?;
let plan = match
super::time_travel::resolve_snapshot(scan.table).await? {
Some(snapshot) => {
@@ -1644,6 +1681,32 @@ impl<'a> PaimonTableScan<'a> {
}
}
+ async fn plan_timestamp_window(
+ &self,
+ start: i64,
+ end: i64,
+ trace: Option<&mut ScanTrace>,
+ ) -> crate::Result<Plan> {
+ let Some((start, end)) =
super::incremental_scan::timestamp_snapshot_range(
+ &self.table.snapshot_manager(),
+ start,
+ end,
+ )
+ .await?
+ else {
+ return Ok(Plan::new(Vec::new()));
+ };
+ super::IncrementalScan::new(
+ self.table,
+ TableScan(TableScanKind::Paimon(self.clone())),
+ super::IncrementalScanMode::Auto,
+ start,
+ end,
+ )
+ .plan_combined_with_trace(trace)
+ .await
+ }
+
/// The grant predates the manifest read, so the table can have been
/// re-created in between. Also refuses stats the schema no longer covers.
async fn check_planned_files(&self, plan: &Plan, query_auth: bool) ->
crate::Result<()> {
@@ -1670,6 +1733,7 @@ impl<'a> PaimonTableScan<'a> {
let core_options = CoreOptions::new(self.table.schema().options());
// Unconditional: unrelated to query-auth.
core_options.ensure_type_paimon_served(&self.table.identifier().full_name())?;
+ core_options.validate_scan_options()?;
core_options.validate_data_file_path_directory()?;
// File paths and stats, not table columns: the endpoint cannot rule
on them.
@@ -2095,6 +2159,7 @@ impl<'a> PaimonTableScan<'a> {
snapshot,
snapshot.delta_manifest_list(),
data_evolution_read_field_ids.as_ref(),
+ None,
)
.await
}
@@ -2105,6 +2170,7 @@ impl<'a> PaimonTableScan<'a> {
&self,
snapshots: &[Snapshot],
end_snapshot: &Snapshot,
+ trace: Option<&mut ScanTrace>,
) -> crate::Result<Plan> {
self.validate_read_options()?;
let data_evolution_read_field_ids = self.projected_read_field_ids()?;
@@ -2114,7 +2180,7 @@ impl<'a> PaimonTableScan<'a> {
end_snapshot,
ManifestListSource::AppendDeltas(snapshots),
data_evolution_read_field_ids.as_ref(),
- None,
+ trace,
)
.await
}
@@ -2124,7 +2190,11 @@ impl<'a> PaimonTableScan<'a> {
/// Reuses the same split-building path as a full snapshot plan, but only
/// reads the changelog manifest list and keeps ADD entries. Snapshots
/// without a changelog list yield an empty plan.
- pub(crate) async fn plan_snapshot_changelog(&self, snapshot: &Snapshot) ->
crate::Result<Plan> {
+ pub(crate) async fn plan_snapshot_changelog(
+ &self,
+ snapshot: &Snapshot,
+ trace: Option<&mut ScanTrace>,
+ ) -> crate::Result<Plan> {
self.validate_read_options()?;
let Some(list_name) = snapshot.changelog_manifest_list() else {
return Ok(Plan::new(Vec::new()).with_snapshot_id(snapshot.id()));
@@ -2136,6 +2206,7 @@ impl<'a> PaimonTableScan<'a> {
snapshot,
list_name,
data_evolution_read_field_ids.as_ref(),
+ trace,
)
.await
}
@@ -2145,54 +2216,13 @@ impl<'a> PaimonTableScan<'a> {
snapshot: &Snapshot,
manifest_list_name: &str,
data_evolution_read_field_ids: Option<&HashSet<i32>>,
+ trace: Option<&mut ScanTrace>,
) -> crate::Result<Plan> {
- if matches!(self.limit, Some(0)) {
- return Ok(Plan::new(Vec::new()).with_snapshot_id(snapshot.id()));
- }
- let core_options = CoreOptions::new(self.table.schema().options());
- let data_evolution_enabled = core_options.data_evolution_enabled();
- let global_index_settings =
- self.global_index_scan_settings(&core_options,
data_evolution_enabled)?;
- let index_entries = self
- .read_index_manifest_entries(
- snapshot,
- global_index_settings.is_some(),
- core_options.deletion_vectors_enabled() &&
!self.is_streaming(),
- )
- .await?;
- let manifest_row_ranges = self
- .manifest_row_ranges(snapshot, index_entries.as_deref(),
global_index_settings)
- .await?;
- if manifest_row_ranges.as_ref().is_some_and(Vec::is_empty) {
- return Ok(Plan::new(Vec::new()).with_snapshot_id(snapshot.id()));
- }
- // Positional scans count all candidate row IDs. Pruning a preceding
- // manifest or file here would renumber the surviving rows; intersect
- // explicit/global-index ranges after assigning positions instead.
- let row_range_index = if data_evolution_enabled &&
self.row_position_selection().is_none() {
- manifest_row_ranges.clone().map(RowRangeIndex::create)
- } else {
- None
- };
- let entries = self
- .plan_manifest_list_entries(manifest_list_name,
row_range_index.as_ref())
- .await?;
- let effective_row_ranges = self
- .effective_row_ranges(
- snapshot,
- &entries,
- index_entries.as_deref(),
- global_index_settings,
- manifest_row_ranges,
- )
- .await?;
- self.plan_snapshot_from_entries(
- snapshot.clone(),
- entries,
+ self.plan_snapshot_from_lists(
+ snapshot,
+ ManifestListSource::IncrementalList(manifest_list_name),
data_evolution_read_field_ids,
- index_entries,
- effective_row_ranges,
- None,
+ trace,
)
.await
}
@@ -2289,132 +2319,6 @@ impl<'a> PaimonTableScan<'a> {
Ok(())
}
- /// Read entries from a single manifest list (delta or changelog) with
- /// partition / bucket filter pushdown matching the full scan path.
- async fn plan_manifest_list_entries(
- &self,
- manifest_list_name: &str,
- row_range_index: Option<&RowRangeIndex>,
- ) -> crate::Result<Vec<ManifestEntry>> {
- let file_io = self.table.file_io();
- let table_path = self.table.location();
- let core_options = CoreOptions::new(self.table.schema().options());
- let has_primary_keys = !self.table.schema().primary_keys().is_empty();
- let partition_fields = self.table.schema().partition_fields();
-
- let mut manifest_metas =
- read_manifest_list(file_io, table_path, manifest_list_name).await?;
-
- if let Some(pf) = self.partition_filter.as_ref() {
- manifest_metas.retain(|meta| pf.matches_manifest(meta,
&partition_fields));
- }
- if let Some(index) = row_range_index {
- retain_manifest_row_ranges(&mut manifest_metas, index);
- }
-
- let bucket_key_fields: Vec<DataField> = if
self.bucket_predicate.is_none() {
- Vec::new()
- } else {
- let bucket_keys = core_options.bucket_key().unwrap_or_else(|| {
- if has_primary_keys {
- self.table.schema().trimmed_primary_keys()
- } else {
- Vec::new()
- }
- });
- bucket_keys
- .iter()
- .filter_map(|key| {
- self.table
- .schema()
- .fields()
- .iter()
- .find(|f| f.name() == key)
- .cloned()
- })
- .collect::<Vec<_>>()
- };
- let bucket_function_type = core_options.bucket_function_type()?;
-
- retain_manifest_buckets(
- &mut manifest_metas,
- has_primary_keys && !self.scan_all_files,
- self.bucket_predicate.as_ref(),
- &bucket_key_fields,
- bucket_function_type,
- );
-
- let base_path = format!("{}/{}", table_path.trim_end_matches('/'),
MANIFEST_DIR);
- let shared_cache = SharedSchemaCache::new();
- let partition_filter = self.partition_filter.as_ref();
- let bucket_predicate = self.bucket_predicate.as_ref();
- let scan_all_files = self.scan_all_files;
-
- let mut entries = Vec::new();
- for meta in manifest_metas {
- let path = format!("{base_path}/{}", meta.file_name());
- // This path validates incremental/changelog manifests before its
- // existing post-read row-range pruning, so do not hide DELETEs at
- // the block layer.
- let bytes = read_manifest_bytes_with_sidecar(
- file_io,
- &path,
- &meta,
- core_options.manifest_sidecar_enabled(),
- ManifestSidecarPruning {
- row_range_index: None,
- partition_filter,
- partition_arity: partition_fields.len(),
- bucket_predicate,
- bucket_key_fields: &bucket_key_fields,
- bucket_function_type,
- },
- )
- .await?;
- let mut bucket_cache: HashMap<i32, Option<HashSet<i32>>> =
HashMap::new();
- let manifest_entries =
crate::spec::avro::from_manifest_bytes_filtered_shared(
- &bytes,
- &shared_cache,
- &mut |_kind, partition_bytes, bucket, total_buckets| {
- if has_primary_keys && !scan_all_files && bucket < 0 {
- return false;
- }
- if let Some(pred) = bucket_predicate {
- let targets =
bucket_cache.entry(total_buckets).or_insert_with(|| {
- compute_target_buckets(
- pred,
- &bucket_key_fields,
- bucket_function_type,
- total_buckets,
- )
- });
- if let Some(targets) = targets {
- if !targets.contains(&bucket) {
- return false;
- }
- }
- }
- if let Some(pf) = partition_filter {
- match pf.matches_entry(partition_bytes) {
- Ok(false) => return false,
- Ok(true) => {}
- Err(_) => {}
- }
- }
- true
- },
- )?;
- entries.extend(manifest_entries);
- }
- validate_incremental_entries(&entries)?;
- let entries = if let Some(index) = row_range_index {
- retain_manifest_entry_row_ranges(entries, index)
- } else {
- entries
- };
- Ok(entries)
- }
-
async fn plan_snapshot(
&self,
snapshot: Snapshot,
@@ -6927,7 +6831,7 @@ mod tests {
assert_eq!(delta.snapshot_id(), Some(1));
let changelog = builder
.new_scan()
- .plan_snapshot_changelog(&snapshot)
+ .plan_snapshot_changelog_with_trace(&snapshot, None)
.await
.unwrap();
assert!(changelog.splits().is_empty());
diff --git a/crates/paimon/tests/incremental_timestamp_scan_test.rs
b/crates/paimon/tests/incremental_timestamp_scan_test.rs
new file mode 100644
index 00000000..3d3dac33
--- /dev/null
+++ b/crates/paimon/tests/incremental_timestamp_scan_test.rs
@@ -0,0 +1,396 @@
+// 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 arrow_array::RecordBatch;
+use common::incremental_helpers::{
+ make_batch, make_batch_with_kinds, memory_table, persist_table_schema,
setup_dirs, write_batch,
+};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, IntType, Schema, TableSchema};
+use paimon::table::{MergeAssignment, MergeSource, Table, WhenMatched,
WhenNotMatched};
+use std::collections::HashMap;
+use std::sync::Arc;
+
+async fn table(name: &str, mode: &str, producer: &str) -> Table {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("changelog-producer", producer);
+ if mode == "pk" {
+ schema = schema.primary_key(["id"]).option("bucket", "1");
+ } else if mode == "evolution" {
+ schema = schema
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ }
+ let path = format!("memory:/timestamp/{name}");
+ let (io, table) = memory_table(&path, TableSchema::new(0,
&schema.build().unwrap()));
+ setup_dirs(&io, &path).await;
+ persist_table_schema(&io, &path, table.schema()).await;
+ table
+}
+
+async fn set_time(table: &Table, time: i64) {
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ let mut json = serde_json::to_value(&snapshot).unwrap();
+ json["timeMillis"] = time.into();
+ table
+ .file_io()
+ .new_output(&table.snapshot_manager().snapshot_path(snapshot.id()))
+ .unwrap()
+ .write(serde_json::to_vec(&json).unwrap().into())
+ .await
+ .unwrap();
+}
+
+async fn append(table: &Table, time: i64, ids: Vec<i32>, values: Vec<i32>) {
+ write_batch(table, &make_batch(ids, values)).await;
+ set_time(table, time).await;
+}
+
+fn window(table: &Table, start: i64, end: i64) -> Table {
+ table.copy_with_options(HashMap::from([
+ ("scan.mode".into(), "incremental".into()),
+ (
+ "incremental-between-timestamp".into(),
+ format!("{start},{end}"),
+ ),
+ ]))
+}
+
+async fn read(table: &Table, all_files: bool) -> (Option<i64>, Vec<(i32,
i32)>) {
+ let builder = table.new_read_builder();
+ let scan = builder.new_scan();
+ let scan = if all_files {
+ scan.with_scan_all_files()
+ } else {
+ scan
+ };
+ let plan = scan.plan().await.unwrap();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in batches {
+ let ids = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<arrow_array::Int32Array>()
+ .unwrap();
+ let values = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<arrow_array::Int32Array>()
+ .unwrap();
+ rows.extend(
+ ids.values()
+ .iter()
+ .copied()
+ .zip(values.values().iter().copied()),
+ );
+ }
+ rows.sort_unstable();
+ (plan.snapshot_id(), rows)
+}
+
+#[tokio::test]
+async fn timestamp_options_select_events_and_preserve_end_snapshot() {
+ for mode in ["append", "pk", "evolution"] {
+ let table = table(mode, mode, "none").await;
+ append(&table, 100, vec![1, 2], vec![10, 20]).await;
+ append(&table, 200, vec![1, 3], vec![11, 30]).await;
+ append(&table, 200, vec![1, 4], vec![12, 40]).await;
+ let builder = table.new_write_builder().with_overwrite();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![9], vec![90]))
+ .await
+ .unwrap();
+ builder
+ .new_commit()
+ .overwrite(writer.prepare_commit().await.unwrap(), None)
+ .await
+ .unwrap();
+ writer.close().await;
+ set_time(&table, 300).await;
+ append(&table, 400, vec![5], vec![50]).await;
+ for (start, end, snapshot, expected) in [
+ (100, 200, Some(3), vec![(1, 11), (1, 12), (3, 30), (4, 40)]),
+ (200, 300, Some(4), vec![]),
+ (100, 300, Some(4), vec![(1, 11), (1, 12), (3, 30), (4, 40)]),
+ (100, 150, Some(1), vec![]),
+ (0, 100, Some(1), vec![(1, 10), (2, 20)]),
+ (200, 400, Some(5), vec![(5, 50)]),
+ (100, 100, None, vec![]),
+ (500, 600, None, vec![]),
+ (0, 50, None, vec![]),
+ ] {
+ let selected = window(&table, start, end);
+ for all_files in [false, true] {
+ let (actual_snapshot, rows) = read(&selected, all_files).await;
+ assert_eq!(actual_snapshot, snapshot, "{mode}:
({start},{end}]");
+ assert_eq!(rows, expected, "{mode}: ({start},{end}]");
+ }
+ let (plan, trace) = selected
+ .new_read_builder()
+ .new_scan()
+ .plan_with_trace()
+ .await
+ .unwrap();
+ assert_eq!(trace.snapshot_id, snapshot);
+ assert_eq!(trace.final_splits, plan.splits().len());
+ assert_eq!(
+ trace.planned_data_file_bytes,
+ plan.planned_data_file_bytes()
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn timestamp_auto_uses_physical_changelog_and_preserves_retracts() {
+ let table = table("changelog", "pk", "input").await;
+ append(&table, 100, vec![1], vec![10]).await;
+ write_batch(
+ &table,
+ &make_batch_with_kinds(vec![1, 1, 2], vec![10, 11, 20], vec![1, 2, 0]),
+ )
+ .await;
+ set_time(&table, 200).await;
+ append(&table, 300, vec![3], vec![30]).await;
+ let selected = window(&table, 100, 300);
+ let builder = selected.new_read_builder();
+ let (plan, trace) = builder.new_scan().plan_with_trace().await.unwrap();
+ assert_eq!(plan.snapshot_id(), Some(3));
+ assert!(plan.splits().iter().all(|split| split.snapshot_id() == 3));
+ assert!(trace.manifest_entries_read >= 2);
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow_with_row_kind(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut kinds = Vec::new();
+ for batch in batches {
+ kinds.extend(
+ batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<arrow_array::StringArray>()
+ .unwrap()
+ .iter()
+ .map(|kind| kind.unwrap().to_owned()),
+ );
+ }
+ kinds.sort();
+ assert_eq!(kinds, vec!["+I", "+I", "+U", "-U"]);
+ assert_eq!(
+ read(&window(&table, 100, 200), false).await,
+ (Some(2), vec![(1, 10), (1, 11), (2, 20)])
+ );
+ assert_eq!(read(&table, false).await.1, vec![(1, 11), (2, 20), (3, 30)]);
+}
+
+#[tokio::test]
+async fn timestamp_windows_are_validated_before_reading_current_data() {
+ let table = table("validation", "append", "none").await;
+ append(&table, 100, vec![1], vec![10]).await;
+ for options in [
+ HashMap::from([("incremental-between-timestamp".into(),
"200,100".into())]),
+ HashMap::from([("incremental-between-timestamp".into(),
"one,200".into())]),
+ HashMap::from([("incremental-between-timestamp".into(),
"100,200,300".into())]),
+ HashMap::from([("scan.mode".into(), "incremental".into())]),
+ HashMap::from([
+ ("incremental-between-timestamp".into(), "0,200".into()),
+ ("scan.snapshot-id".into(), "1".into()),
+ ]),
+ HashMap::from([
+ ("incremental-between-timestamp".into(), "0,200".into()),
+ ("scan.mode".into(), "from-timestamp".into()),
+ ]),
+ ] {
+ let selected = table.copy_with_options(options);
+ let error = selected
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap_err();
+ assert!(matches!(error, paimon::Error::DataInvalid { .. }));
+ }
+ let selected = table
+ .copy_with_time_travel(HashMap::from([(
+ "incremental-between-timestamp".into(),
+ "0,100".into(),
+ )]))
+ .await
+ .unwrap();
+ assert_eq!(read(&selected, false).await.1, vec![(1, 10)]);
+}
+
+#[tokio::test]
+async fn empty_and_equal_windows_do_not_select_latest_data() {
+ let table = table("empty", "append", "none").await;
+ for (start, end) in [(100, 100), (0, 100), (200, 100)] {
+ assert_eq!(
+ read(&window(&table, start, end), false).await,
+ (None, vec![])
+ );
+ }
+ append(&table, 100, vec![1], vec![10]).await;
+ assert_eq!(read(&window(&table, 100, 100), false).await, (None, vec![]));
+ let selected = window(&table, 100, 100).copy_with_options(HashMap::from([(
+ "query-auth.enabled".into(),
+ "true".into(),
+ )]));
+ assert!(selected.new_read_builder().new_scan().plan().await.is_err());
+}
+
+#[tokio::test]
+async fn
core_merge_reads_only_the_timestamp_window_and_keeps_source_independent() {
+ let target = table("merge_target", "evolution", "none").await;
+ let source = table("merge_source", "append", "none").await;
+ append(&target, 100, vec![1], vec![10]).await;
+ append(&source, 100, vec![1], vec![-1]).await;
+ append(&source, 200, vec![1, 2], vec![11, 20]).await;
+ append(&source, 300, vec![3], vec![30]).await;
+ let builder = target.new_write_builder();
+ let messages = builder
+ .new_update()
+ .unwrap()
+ .merge_into(
+ MergeSource::Table(Arc::new(window(&source, 100, 200))),
+ vec![("id".into(), "id".into())],
+ vec![WhenMatched {
+ condition: None,
+ delete: false,
+ assignments: vec![(
+ "value".into(),
+ MergeAssignment::SourceColumn("value".into()),
+ )],
+ }],
+ vec![WhenNotMatched {
+ condition: None,
+ assignments: vec![
+ ("id".into(), MergeAssignment::SourceColumn("id".into())),
+ (
+ "value".into(),
+ MergeAssignment::SourceColumn("value".into()),
+ ),
+ ],
+ }],
+ )
+ .await
+ .unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(read(&target, false).await.1, vec![(1, 11), (2, 20)]);
+ assert_eq!(
+ read(&source, false).await.1,
+ vec![(1, -1), (1, 11), (2, 20), (3, 30)]
+ );
+}
+
+#[tokio::test]
+async fn expired_prefix_is_excluded_from_timestamp_history() {
+ let table = table("expired_prefix", "append", "none").await;
+ for (time, id) in [(100, 1), (200, 2), (300, 3)] {
+ append(&table, time, vec![id], vec![id * 10]).await;
+ }
+ table
+ .file_io()
+ .delete_file(&table.snapshot_manager().snapshot_path(1))
+ .await
+ .unwrap();
+ assert_eq!(
+ read(&window(&table, 0, 250), false).await,
+ (Some(2), vec![(2, 20)])
+ );
+}
+
+#[tokio::test]
+async fn invalid_source_window_fails_before_target_file_staging() {
+ let target = table("failed_merge_target", "evolution", "none").await;
+ let source = table("failed_merge_source", "append", "none").await;
+ append(&target, 100, vec![1], vec![10]).await;
+ append(&source, 100, vec![1], vec![11]).await;
+ let builder = target.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let bucket_path = plan.splits()[0].bucket_path();
+ let before_files = target
+ .file_io()
+ .list_status(bucket_path)
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|s| s.path)
+ .collect::<std::collections::HashSet<_>>();
+ let error = target
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .merge_into(
+ MergeSource::Table(Arc::new(window(&source, 200, 100))),
+ vec![("id".into(), "id".into())],
+ vec![WhenMatched {
+ condition: None,
+ delete: false,
+ assignments: vec![(
+ "value".into(),
+ MergeAssignment::SourceColumn("value".into()),
+ )],
+ }],
+ Vec::new(),
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(error, paimon::Error::DataInvalid { message, .. } if
message.contains("Ending timestamp"))
+ );
+ assert_eq!(
+ target
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap(),
+ Some(1)
+ );
+ assert_eq!(
+ target
+ .file_io()
+ .list_status(bucket_path)
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|s| s.path)
+ .collect::<std::collections::HashSet<_>>(),
+ before_files
+ );
+}