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 44cda804 [Core] Preserve captured snapshot metadata and empty read
views (#1098)
44cda804 is described below
commit 44cda80452c9624ba4af6bb174a2bbaed1349960
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 10:15:05 2026 +0800
[Core] Preserve captured snapshot metadata and empty read views (#1098)
---
.../python/python/pypaimon_rust/datafusion.pyi | 3 +
bindings/python/src/table.rs | 12 ++
.../datafusion/src/partition_count_pushdown.rs | 13 +-
.../datafusion/src/system_tables/manifests.rs | 9 +-
.../datafusion/src/system_tables/mod.rs | 94 +++++++++++++
.../datafusion/src/system_tables/partitions.rs | 2 +-
.../datafusion/src/system_tables/table_indexes.rs | 9 +-
crates/integrations/datafusion/src/table/mod.rs | 2 +-
.../datafusion/tests/partition_count_pushdown.rs | 32 +++++
crates/paimon/src/catalog/partition_listing.rs | 9 +-
crates/paimon/src/table/branch_write_tests.rs | 2 +-
crates/paimon/src/table/data_evolution_writer.rs | 4 +-
crates/paimon/src/table/de_vector_scan.rs | 2 +-
crates/paimon/src/table/de_vector_scan/tests.rs | 71 ++++++++++
.../src/table/full_text_search_builder/scan.rs | 2 +-
crates/paimon/src/table/hybrid_search_builder.rs | 4 +-
crates/paimon/src/table/mod.rs | 83 +++++++++--
crates/paimon/src/table/pk_vector_scan.rs | 151 ++++++++++++++++-----
crates/paimon/src/table/table_merge_input.rs | 13 +-
crates/paimon/src/table/table_merge_into.rs | 14 +-
crates/paimon/src/table/table_scan.rs | 3 +
crates/paimon/src/table/table_update_predicate.rs | 10 +-
crates/paimon/src/table/time_travel.rs | 130 ++++++++++++++++--
crates/paimon/src/table/vector_search_result.rs | 2 +-
24 files changed, 557 insertions(+), 119 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 15ca03ce..a0c81bfa 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -214,6 +214,9 @@ class Table:
def copy_with_resolved_schema(self, schema_json: str, *, branch:
Optional[str] = None) -> "Table":
"""Replace all fields/options, preserving FileIO, REST context and
branch."""
...
+ def copy_with_pinned_snapshot(self, snapshot_json: Optional[str]) ->
"Table":
+ """Preserve the read schema and pin Java Snapshot JSON, or an empty
view with None."""
+ ...
def identifier(self) -> str: ...
def branch(self) -> str: ...
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index 2c33c4b0..6eb49675 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -206,6 +206,18 @@ impl PyTable {
crate::hybrid_search::PyHybridSearchBuilder::new(Arc::clone(&self.inner))
}
+ /// Forward one captured Java-format snapshot, or an explicitly empty view.
+ #[pyo3(signature = (snapshot_json))]
+ fn copy_with_pinned_snapshot(&self, snapshot_json: Option<&str>) ->
PyResult<Self> {
+ let snapshot = snapshot_json
+ .map(serde_json::from_str::<paimon::spec::Snapshot>)
+ .transpose()
+ .map_err(|error| PyValueError::new_err(format!("Invalid snapshot
JSON: {error}")))?;
+ Ok(Self {
+ inner:
Arc::new(self.inner.copy_with_pinned_snapshot(snapshot.as_ref())),
+ })
+ }
+
fn new_vector_search_builder(&self) ->
crate::vector_search::PyVectorSearchBuilder {
crate::vector_search::PyVectorSearchBuilder::new(Arc::clone(&self.inner))
}
diff --git a/crates/integrations/datafusion/src/partition_count_pushdown.rs
b/crates/integrations/datafusion/src/partition_count_pushdown.rs
index 0dc73a53..f7fc0cbc 100644
--- a/crates/integrations/datafusion/src/partition_count_pushdown.rs
+++ b/crates/integrations/datafusion/src/partition_count_pushdown.rs
@@ -308,19 +308,12 @@ impl TableProvider for PartitionRowCountProvider {
return Ok(Some(table));
}
let selected =
table.copy_with_time_travel_strict(HashMap::new()).await?;
- let snapshot = match selected.travel_snapshot() {
- Some(snapshot) => snapshot.clone(),
- None => {
- let Some(snapshot) =
table.snapshot_manager().get_latest_snapshot().await?
- else {
- return Ok(None);
- };
- snapshot
- }
+ let Some(snapshot) = selected.resolve_read_snapshot().await? else {
+ return Ok(None);
};
// Pin data, not the snapshot's schema: DDL may have changed field
// positions since the latest data commit or since logical
planning.
- Ok(Some(table.copy_with_pinned_snapshot(&snapshot)))
+ Ok(Some(table.copy_with_pinned_snapshot(Some(&snapshot))))
})
.await
.map_err(to_datafusion_error)?;
diff --git a/crates/integrations/datafusion/src/system_tables/manifests.rs
b/crates/integrations/datafusion/src/system_tables/manifests.rs
index 9380b316..1acf7a52 100644
--- a/crates/integrations/datafusion/src/system_tables/manifests.rs
+++ b/crates/integrations/datafusion/src/system_tables/manifests.rs
@@ -160,15 +160,10 @@ impl TableProvider for ManifestsTable {
async fn collect_manifests(table: &Table) ->
paimon::Result<Vec<ManifestFileMeta>> {
let file_io = table.file_io();
- let snapshot_sm = table.snapshot_manager();
let manifest_sm =
paimon::table::SnapshotManager::new(file_io.clone(),
table.location().to_string());
- let snapshot = match table.travel_snapshot().cloned() {
- Some(snapshot) => snapshot,
- None => match snapshot_sm.get_latest_snapshot().await? {
- Some(snapshot) => snapshot,
- None => return Ok(Vec::new()),
- },
+ let Some(snapshot) = table.resolve_read_snapshot().await? else {
+ return Ok(Vec::new());
};
let base_path = manifest_sm.manifest_path(snapshot.base_manifest_list());
diff --git a/crates/integrations/datafusion/src/system_tables/mod.rs
b/crates/integrations/datafusion/src/system_tables/mod.rs
index f3fd36f9..7cbafb44 100644
--- a/crates/integrations/datafusion/src/system_tables/mod.rs
+++ b/crates/integrations/datafusion/src/system_tables/mod.rs
@@ -221,6 +221,100 @@ pub(crate) async fn load(
mod tests {
use super::{is_registered, parse_object_name_for_datafusion,
SYSTEM_TABLE_NAMES, TABLES};
+ #[tokio::test]
+ async fn pinned_empty_view_has_no_manifest_index_or_partition_rows() {
+ use std::sync::Arc;
+
+ use datafusion::arrow::array::{Int32Array, RecordBatch};
+ use datafusion::execution::context::SessionContext;
+ use paimon::catalog::Identifier;
+ use paimon::spec::{DataType, IntType, Schema};
+ use paimon::table::TableCommit;
+ use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
+
+ let temp = tempfile::tempdir().unwrap();
+ let mut options = Options::new();
+ options.set(
+ CatalogOptions::WAREHOUSE,
+ format!("file://{}", temp.path().display()),
+ );
+ let catalog = Arc::new(FileSystemCatalog::new(options).unwrap());
+ catalog
+ .create_database("db", true, Default::default())
+ .await
+ .unwrap();
+ let identifier = Identifier::new("db", "indexed");
+ catalog
+ .create_table(
+ &identifier,
+ Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("pt", DataType::Int(IntType::new()))
+ .partition_keys(["pt"])
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("global-index.enabled", "true")
+ .build()
+ .unwrap(),
+ false,
+ )
+ .await
+ .unwrap();
+ let table = catalog.get_table(&identifier).await.unwrap();
+ let batch = RecordBatch::try_from_iter(vec![
+ ("id", Arc::new(Int32Array::from(vec![1, 2])) as _),
+ ("pt", Arc::new(Int32Array::from(vec![0, 1])) as _),
+ ])
+ .unwrap();
+ let builder =
table.new_write_builder().with_commit_user("test").unwrap();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ TableCommit::new(table.clone(), "test".to_string())
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ table
+ .new_global_index_build_builder()
+ .with_index_column("id")
+ .with_index_type("btree")
+ .execute()
+ .await
+ .unwrap();
+
+ let ctx = SessionContext::new();
+ for (name, builder) in [
+ ("manifests", super::manifests::build as super::Builder),
+ (
+ "table_indexes",
+ super::table_indexes::build as super::Builder,
+ ),
+ ] {
+ for empty in [false, true] {
+ let view = if empty {
+ table.copy_with_pinned_snapshot(None)
+ } else {
+ table.clone()
+ };
+ let provider = builder(view).unwrap();
+ let batches =
ctx.read_table(provider).unwrap().collect().await.unwrap();
+ let count: usize =
batches.iter().map(RecordBatch::num_rows).sum();
+ assert_eq!(count == 0, empty, "{name}, empty={empty}");
+ }
+ }
+ for empty in [false, true] {
+ let view = if empty {
+ table.copy_with_pinned_snapshot(None)
+ } else {
+ table.clone()
+ };
+ let provider =
+ super::partitions::build(catalog.clone(), identifier.clone(),
view).unwrap();
+ let batches =
ctx.read_table(provider).unwrap().collect().await.unwrap();
+ let count: usize = batches.iter().map(RecordBatch::num_rows).sum();
+ assert_eq!(count, if empty { 0 } else { 2 });
+ }
+ }
+
/// Guards against the two registries drifting: anything in `TABLES` must
/// also be in `SYSTEM_TABLE_NAMES`, and the only name allowed to be in
/// `SYSTEM_TABLE_NAMES` but not `TABLES` is `partitions` (routed via the
diff --git a/crates/integrations/datafusion/src/system_tables/partitions.rs
b/crates/integrations/datafusion/src/system_tables/partitions.rs
index 749bb282..b87db4f8 100644
--- a/crates/integrations/datafusion/src/system_tables/partitions.rs
+++ b/crates/integrations/datafusion/src/system_tables/partitions.rs
@@ -122,7 +122,7 @@ impl TableProvider for PartitionsTable {
_limit: Option<usize>,
) -> DFResult<Arc<dyn ExecutionPlan>> {
let table = self.table.clone();
- let partitions = if table.travel_snapshot().is_some() {
+ let partitions = if table.travel_snapshot().is_some() ||
table.is_time_traveled() {
crate::runtime::await_with_runtime(async move {
list_partitions_from_file_system(&table).await
})
diff --git a/crates/integrations/datafusion/src/system_tables/table_indexes.rs
b/crates/integrations/datafusion/src/system_tables/table_indexes.rs
index cbd1c2c1..c51d3e55 100644
--- a/crates/integrations/datafusion/src/system_tables/table_indexes.rs
+++ b/crates/integrations/datafusion/src/system_tables/table_indexes.rs
@@ -187,15 +187,10 @@ impl TableProvider for TableIndexesTable {
async fn collect_index_entries(table: &Table) ->
paimon::Result<Vec<IndexManifestEntry>> {
let file_io = table.file_io();
- let snapshot_sm = table.snapshot_manager();
let manifest_sm =
paimon::table::SnapshotManager::new(file_io.clone(),
table.location().to_string());
- let snapshot = match table.travel_snapshot().cloned() {
- Some(snapshot) => snapshot,
- None => match snapshot_sm.get_latest_snapshot().await? {
- Some(snapshot) => snapshot,
- None => return Ok(Vec::new()),
- },
+ let Some(snapshot) = table.resolve_read_snapshot().await? else {
+ return Ok(Vec::new());
};
let Some(index_manifest_name) = snapshot.index_manifest() else {
return Ok(Vec::new());
diff --git a/crates/integrations/datafusion/src/table/mod.rs
b/crates/integrations/datafusion/src/table/mod.rs
index 5d04761e..0e296fcc 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -175,7 +175,7 @@ impl PaimonTableProvider {
}
pub(crate) fn with_pinned_snapshot(mut self, snapshot: &Snapshot) -> Self {
- self.table = self.table.copy_with_pinned_snapshot(snapshot);
+ self.table = self.table.copy_with_pinned_snapshot(Some(snapshot));
self
}
}
diff --git a/crates/integrations/datafusion/tests/partition_count_pushdown.rs
b/crates/integrations/datafusion/tests/partition_count_pushdown.rs
index f20674f2..0684a6bd 100644
--- a/crates/integrations/datafusion/tests/partition_count_pushdown.rs
+++ b/crates/integrations/datafusion/tests/partition_count_pushdown.rs
@@ -622,6 +622,38 @@ async fn
test_deletion_vector_zero_groups_and_unknown_cardinality_fallback() {
);
}
+#[tokio::test]
+async fn test_pinned_empty_view_is_used_by_count_pushdown_and_row_scan() {
+ let (_tmp, catalog, ctx) = setup().await;
+ let table = catalog
+ .get_table(&Identifier::new("test_db", "t"))
+ .await
+ .unwrap();
+ let pinned = table.copy_with_pinned_snapshot(None);
+ ctx.register_temp_table(
+ "paimon.test_db.pinned",
+
Arc::new(paimon_datafusion::PaimonTableProvider::try_new(pinned).unwrap()),
+ )
+ .unwrap();
+ assert_eq!(
+ rows(&ctx, "SELECT COUNT(*) FROM paimon.test_db.pinned").await,
+ vec![row("", 0)]
+ );
+ assert_eq!(
+ rows(&ctx, "SELECT COUNT(id) FROM paimon.test_db.pinned").await,
+ vec![row("", 0)]
+ );
+ let grouped = "SELECT dt, COUNT(*) FROM paimon.test_db.pinned GROUP BY dt";
+ assert!(!scans_table(&ctx, grouped).await);
+ assert!(rows(&ctx, grouped).await.is_empty());
+ assert!(!rows(
+ &ctx,
+ "SELECT dt, COUNT(*) FROM paimon.test_db.t GROUP BY dt"
+ )
+ .await
+ .is_empty());
+}
+
#[tokio::test]
async fn test_ungrouped_count_on_unpartitioned_and_empty_tables() {
let (_tmp, _catalog, ctx) = setup().await;
diff --git a/crates/paimon/src/catalog/partition_listing.rs
b/crates/paimon/src/catalog/partition_listing.rs
index 9bb28d30..dec7702b 100644
--- a/crates/paimon/src/catalog/partition_listing.rs
+++ b/crates/paimon/src/catalog/partition_listing.rs
@@ -35,14 +35,9 @@ pub async fn list_partitions_from_file_system(table: &Table)
-> Result<Vec<Parti
// Manifests carry partition values and per-column stats.
crate::spec::CoreOptions::new(table.schema().options()).ensure_read_authorized()?;
let file_io = table.file_io();
- let snapshot_sm = table.snapshot_manager();
let manifest_sm = SnapshotManager::new(file_io.clone(),
table.location().to_string());
- let snapshot = match table.travel_snapshot().cloned() {
- Some(snapshot) => snapshot,
- None => match snapshot_sm.get_latest_snapshot().await? {
- Some(snapshot) => snapshot,
- None => return Ok(Vec::new()),
- },
+ let Some(snapshot) = table.resolve_read_snapshot().await? else {
+ return Ok(Vec::new());
};
let base_path = manifest_sm.manifest_path(snapshot.base_manifest_list());
diff --git a/crates/paimon/src/table/branch_write_tests.rs
b/crates/paimon/src/table/branch_write_tests.rs
index 283705dd..612df694 100644
--- a/crates/paimon/src/table/branch_write_tests.rs
+++ b/crates/paimon/src/table/branch_write_tests.rs
@@ -467,7 +467,7 @@ async fn branch_write_historical_handles_remain_read_only()
{
append(&main, "seed", batch(vec![1], vec![10])).await;
let branch = tagged_branch(&main).await;
let snapshot = latest(&branch).await;
- let mut historical = branch.copy_with_pinned_snapshot(&snapshot);
+ let mut historical = branch.copy_with_pinned_snapshot(Some(&snapshot));
// The pinned snapshot remains read-only even with no selector in its
options.
historical.schema = historical
.schema
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 6c6a4bcd..05bf3ee5 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -611,7 +611,7 @@ impl DataEvolutionDeleteWriter {
message: "No files with row tracking found in target
table".into(),
source: None,
})?;
- let scan_table = self.table.copy_with_pinned_snapshot(&snapshot);
+ let scan_table = self.table.copy_with_pinned_snapshot(Some(&snapshot));
let scan = scan_table
.new_read_builder()
.new_scan()
@@ -1256,7 +1256,7 @@ impl RowIdFileIndex {
files: Vec::new(),
});
};
- let read_table = table.copy_with_pinned_snapshot(&snapshot);
+ let read_table = table.copy_with_pinned_snapshot(Some(&snapshot));
let plan = read_table.new_read_builder().new_scan().plan().await?;
Self::from_splits(read_table, plan.splits())
}
diff --git a/crates/paimon/src/table/de_vector_scan.rs
b/crates/paimon/src/table/de_vector_scan.rs
index 883dd077..1b2af332 100644
--- a/crates/paimon/src/table/de_vector_scan.rs
+++ b/crates/paimon/src/table/de_vector_scan.rs
@@ -63,7 +63,7 @@ impl Table {
CoreOptions::new(self.schema().options()).ensure_read_authorized()?;
let Some(snapshot) =
crate::table::time_travel::resolve_snapshot(self).await? else {
return Ok(PreparedVectorSearchFilter {
- table: self.clone(),
+ table: self.copy_with_pinned_snapshot(None),
include_row_ids: Arc::new(RoaringTreemap::new()),
});
};
diff --git a/crates/paimon/src/table/de_vector_scan/tests.rs
b/crates/paimon/src/table/de_vector_scan/tests.rs
index 5033ed9b..dc35c06f 100644
--- a/crates/paimon/src/table/de_vector_scan/tests.rs
+++ b/crates/paimon/src/table/de_vector_scan/tests.rs
@@ -32,6 +32,77 @@ use roaring::RoaringTreemap;
use std::collections::HashMap;
use std::sync::Arc;
+#[tokio::test]
+async fn prepared_empty_filter_keeps_its_read_table_empty_after_commit() {
+ let table = vector_test_table().copy_with_options(HashMap::from([
+ ("row-tracking.enabled".to_string(), "true".to_string()),
+ ("data-evolution.enabled".to_string(), "true".to_string()),
+ ("vector-index.search-mode".to_string(), "full".to_string()),
+ ]));
+ let prepared = table
+ .prepare_vector_search_filter(id_gt_filter(&table, 0))
+ .await
+ .unwrap();
+ let field = Arc::new(ArrowField::new("element", ArrowDataType::Float32,
true));
+ let mut vectors =
ListBuilder::new(Float32Builder::new()).with_field(field.clone());
+ vectors.values().append_value(1.0);
+ vectors.values().append_value(0.0);
+ vectors.append(true);
+ let batch = RecordBatch::try_new(
+ Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("embedding", ArrowDataType::List(field), true),
+ ])),
+ vec![
+ Arc::new(Int32Array::from(vec![1])),
+ Arc::new(vectors.finish()),
+ ],
+ )
+ .unwrap();
+ let mut writer = TableWrite::new(&table, "test".to_string()).unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ TableCommit::new(table.clone(), "test".to_string())
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+
+ assert!(table.resolve_read_snapshot().await.unwrap().is_some());
+ assert!(prepared
+ .table()
+ .resolve_read_snapshot()
+ .await
+ .unwrap()
+ .is_none());
+ assert!(prepared
+ .table()
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()
+ .is_empty());
+ assert!(prepared
+ .table()
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0, 0.0])
+ .with_limit(2)
+ .execute()
+ .await
+ .unwrap()
+ .is_empty());
+ assert!(!table
+ .new_vector_search_builder()
+ .with_vector_column("embedding")
+ .with_query_vector(vec![1.0, 0.0])
+ .with_limit(2)
+ .execute()
+ .await
+ .unwrap()
+ .is_empty());
+}
+
#[tokio::test]
async fn reader_uses_planned_snapshot_after_snapshot_files_are_removed() {
let table = de_vector_table().await;
diff --git a/crates/paimon/src/table/full_text_search_builder/scan.rs
b/crates/paimon/src/table/full_text_search_builder/scan.rs
index 8e87a3cd..68e94f22 100644
--- a/crates/paimon/src/table/full_text_search_builder/scan.rs
+++ b/crates/paimon/src/table/full_text_search_builder/scan.rs
@@ -164,7 +164,7 @@ impl FullTextScan {
plan.next_row_id = snapshot.next_row_id();
// Pin the data without changing the schema already bound by the query.
// A schema-only rename does not create a new data snapshot.
- plan.table = self.table.copy_with_pinned_snapshot(&snapshot);
+ plan.table = self.table.copy_with_pinned_snapshot(Some(&snapshot));
if let Some(name) = snapshot.index_manifest() {
let path = self.table.snapshot_manager().manifest_path(name);
plan.entries = IndexManifest::read(self.table.file_io(),
&path).await?;
diff --git a/crates/paimon/src/table/hybrid_search_builder.rs
b/crates/paimon/src/table/hybrid_search_builder.rs
index e8838214..d6edb496 100644
--- a/crates/paimon/src/table/hybrid_search_builder.rs
+++ b/crates/paimon/src/table/hybrid_search_builder.rs
@@ -629,11 +629,11 @@ impl<'a> HybridSearchBuilder<'a> {
if let Some(snapshot) = &self.pinned_snapshot {
return Ok(snapshot
.as_ref()
- .map(|snapshot|
self.table.copy_with_pinned_snapshot(snapshot)));
+ .map(|snapshot|
self.table.copy_with_pinned_snapshot(Some(snapshot))));
}
Ok(super::time_travel::resolve_snapshot(self.table)
.await?
- .map(|snapshot| self.table.copy_with_pinned_snapshot(&snapshot)))
+ .map(|snapshot|
self.table.copy_with_pinned_snapshot(Some(&snapshot))))
}
/// Consume the vector search's scored positions, retaining its source
files
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 11a902ed..ce1b01f9 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -246,6 +246,23 @@ use crate::spec::{
SCAN_WATERMARK_OPTION,
};
use std::collections::{HashMap, HashSet};
+use std::sync::Arc;
+
+/// A captured read view. Absence of this enum means that the view is
unresolved.
+#[derive(Debug, Clone)]
+enum ReadSnapshot {
+ Empty,
+ Snapshot(Arc<Snapshot>),
+}
+
+impl ReadSnapshot {
+ fn snapshot(&self) -> Option<&Snapshot> {
+ match self {
+ Self::Empty => None,
+ Self::Snapshot(snapshot) => Some(snapshot),
+ }
+ }
+}
/// Table represents a table in the catalog.
#[derive(Debug, Clone)]
@@ -264,10 +281,9 @@ pub struct Table {
/// True when this table copy was switched to a historical schema by
/// [`Table::copy_with_time_travel`]. Such a copy is read-only.
time_traveled: bool,
- /// Snapshot resolved by [`Table::copy_with_time_travel`] from this copy's
- /// options, so scans don't have to resolve the same selector again.
+ /// Captured snapshot or empty view, so scans don't resolve latest again.
/// Cleared when [`Table::copy_with_options`] changes the selector.
- travel_snapshot: Option<Snapshot>,
+ travel_snapshot: Option<ReadSnapshot>,
/// Explicit options remain overrides across later time-travel copies.
applied_dynamic_option_keys: HashSet<String>,
}
@@ -594,6 +610,8 @@ impl Table {
|| k == crate::spec::SCAN_WATERMARK_OPTION
|| k == crate::spec::SCAN_SNAPSHOT_ID_OPTION
|| k == crate::spec::SCAN_TAG_NAME_OPTION
+ || k == "scan.mode"
+ || k == "incremental-between-timestamp"
});
Self {
file_io: self.file_io.clone(),
@@ -653,7 +671,7 @@ impl Table {
/// same snapshot. The snapshot's schema is loaded when it differs from the
/// current table schema.
pub(crate) async fn copy_with_resolved_snapshot(&self, snapshot:
&Snapshot) -> Result<Self> {
- let mut table = self.copy_with_pinned_snapshot(snapshot);
+ let mut table = self.copy_with_pinned_snapshot(Some(snapshot));
if snapshot.schema_id() != self.schema.id() {
let schema =
self.schema_manager.schema(snapshot.schema_id()).await?;
let options = table.historical_field_options(&schema);
@@ -667,7 +685,8 @@ impl Table {
///
/// Unlike time travel, pinning must not change the fields used by an
already
/// planned query. The resolved snapshot is cached without additional I/O.
- pub fn copy_with_pinned_snapshot(&self, snapshot: &Snapshot) -> Self {
+ /// `None` captures an empty view, which remains empty after later commits.
+ pub fn copy_with_pinned_snapshot(&self, snapshot: Option<&Snapshot>) ->
Self {
let mut options = self.schema.options().clone();
for selector in [
SCAN_TIMESTAMP_MILLIS_OPTION,
@@ -676,18 +695,35 @@ impl Table {
SCAN_VERSION_OPTION,
SCAN_SNAPSHOT_ID_OPTION,
SCAN_TAG_NAME_OPTION,
+ "incremental-between-timestamp",
+ "scan.file-creation-time-millis",
+ "scan.creation-time-millis",
] {
options.remove(selector);
}
options.insert(
- SCAN_SNAPSHOT_ID_OPTION.to_string(),
- snapshot.id().to_string(),
+ "scan.mode".to_string(),
+ if snapshot.is_some() {
+ "from-snapshot"
+ } else {
+ "default"
+ }
+ .to_string(),
);
+ if let Some(snapshot) = snapshot {
+ options.insert(
+ SCAN_SNAPSHOT_ID_OPTION.to_string(),
+ snapshot.id().to_string(),
+ );
+ }
Self {
schema: self.schema.copy_with_replaced_options(options),
time_traveled: true,
- travel_snapshot: Some(snapshot.clone()),
+ travel_snapshot: Some(match snapshot {
+ Some(snapshot) =>
ReadSnapshot::Snapshot(Arc::new(snapshot.clone())),
+ None => ReadSnapshot::Empty,
+ }),
..self.clone()
}
}
@@ -807,7 +843,7 @@ impl Table {
table.schema =
snapshot_schema.copy_with_replaced_options(options);
table.time_traveled = true;
}
- table.travel_snapshot = Some(snapshot);
+ table.travel_snapshot =
Some(ReadSnapshot::Snapshot(Arc::new(snapshot)));
}
Ok(table)
}
@@ -911,8 +947,8 @@ impl Table {
})
}
- /// Whether this table copy reads a historical snapshot with its
- /// historical schema (see [`Table::copy_with_time_travel`]).
+ /// Whether this copy is read-only because its schema was time-traveled or
+ /// its read view was explicitly pinned (including an empty view).
pub fn is_time_traveled(&self) -> bool {
self.time_traveled
}
@@ -922,13 +958,34 @@ impl Table {
/// "selector set but unresolved" (silent fallback to latest) from a real
/// travelled read, so they can reject the former instead of reading
latest.
pub fn has_resolved_travel_snapshot(&self) -> bool {
- self.travel_snapshot.is_some()
+ self.travel_snapshot().is_some()
}
/// The snapshot resolved by [`Table::copy_with_time_travel`] from this
/// copy's options, if any. Lets scans skip re-resolving the selector.
pub fn travel_snapshot(&self) -> Option<&Snapshot> {
- self.travel_snapshot.as_ref()
+ self.travel_snapshot
+ .as_ref()
+ .and_then(ReadSnapshot::snapshot)
+ }
+
+ /// Resolve the selected read view, reusing complete cached metadata or an
+ /// explicitly empty view. Read latest only when no view has been captured.
+ pub async fn resolve_read_snapshot(&self) -> Result<Option<Snapshot>> {
+ time_travel::resolve_snapshot(self).await
+ }
+
+ /// Reject a captured schema whose read selection was subsequently changed.
+ pub(crate) fn ensure_read_snapshot_current(&self) -> Result<()> {
+ if self.is_time_traveled() && self.travel_snapshot.is_none() {
+ return Err(crate::Error::DataInvalid {
+ message: "Table options changed after time travel; \
+ use copy_with_time_travel to re-resolve the snapshot
and schema"
+ .to_string(),
+ source: None,
+ });
+ }
+ Ok(())
}
}
diff --git a/crates/paimon/src/table/pk_vector_scan.rs
b/crates/paimon/src/table/pk_vector_scan.rs
index 36f460ea..3a17c807 100644
--- a/crates/paimon/src/table/pk_vector_scan.rs
+++ b/crates/paimon/src/table/pk_vector_scan.rs
@@ -304,48 +304,55 @@ impl Scan for PkVectorScan {
async fn plan(&self) -> crate::Result<PkVectorScanPlan> {
let snapshot_manager = self.table.snapshot_manager();
- // Data splits first, via the table's own scan resolution (which honors
- // time travel / scan.snapshot-id). Deriving the snapshot from the
scan's
- // own output — rather than resolving `get_latest_snapshot()`
separately —
- // keeps the index manifest and the data splits on ONE snapshot,
matching
- // Java `PrimaryKeyVectorScan` (resolve one snapshot up front, read
data and
- // index from it). It also avoids a time-travel mismatch (data from the
- // travelled snapshot, index from latest) and a TOCTOU where a
concurrent
- // commit lands between two independent resolutions.
- //
- // The residual scalar filter, when set, is pushed into the read
builder so
- // scan planning drops files whose stats cannot match the predicate,
mirroring
- // Java `PrimaryKeyVectorScan` applying the filter at scan time. Files
that
- // survive are still residual-filtered per row downstream; this only
avoids
- // re-reading files the predicate already excludes.
- let mut read_builder = self.table.new_read_builder();
+ let options = self.table.schema().core_options();
+
options.ensure_type_paimon_served(&self.table.identifier().full_name())?;
+ options.ensure_read_authorized()?;
+ options.validate_scan_options()?;
+ options.validate_data_file_path_directory()?;
+ self.table.ensure_read_snapshot_current()?;
+ // Point reads reuse one complete snapshot as Java PrimaryKeyVectorScan
+ // does, including tags whose original snapshot JSON has expired.
+ let incremental = options.incremental_timestamp_window()?.is_some();
+ let snapshot = if incremental {
+ None
+ } else {
+ self.table.resolve_read_snapshot().await?
+ };
+ let table = if incremental {
+ self.table.clone()
+ } else {
+ self.table.copy_with_pinned_snapshot(snapshot.as_ref())
+ };
+ // Residual filtering prunes files at scan time and checks each
+ // surviving candidate downstream.
+ let mut read_builder = table.new_read_builder();
if let Some(filter) = &self.filter {
read_builder.with_filter(filter.clone());
}
- // Plan the data splits and capture the snapshot the scan pinned in one
- // pass. The trace carries the resolved snapshot id even when the scan
- // yields zero data splits, so the plan reports its real snapshot id
- // (required by the cross-route snapshot-consistency guard) instead of
- // deriving it from a first split that may not exist.
- let (data_plan, trace) = read_builder
- .new_scan()
- .with_scan_all_files()
- .plan_with_trace()
- .await?;
+ let data_plan =
read_builder.new_scan().with_scan_all_files().plan().await?;
let data_splits = data_plan.splits().to_vec();
-
- // No snapshot at all (table never written): nothing to search and no
- // snapshot to pin. The empty split list makes every downstream
consumer
- // treat this as "no candidates", so this is the only plan without a
real
- // snapshot id.
- let Some(snapshot_id) = trace.snapshot_id else {
+ // Java accepts only a snapshot scan plan, or an empty incremental
plan.
+ // Never widen an incremental selection into a full-snapshot search.
+ if incremental && !data_splits.is_empty() {
+ return Err(data_invalid(
+ "Primary-key vector search requires a snapshot scan, not an
incremental scan",
+ ));
+ }
+ if incremental {
+ return Ok(PkVectorScanPlan {
+ snapshot_id: 0,
+ splits: Vec::new(),
+ physical_row_ranges_by_split: None,
+ });
+ }
+ let Some(snapshot) = snapshot else {
return Ok(PkVectorScanPlan {
snapshot_id: 0,
splits: Vec::new(),
physical_row_ranges_by_split: None,
});
};
- let snapshot = snapshot_manager.get_snapshot(snapshot_id).await?;
+ let snapshot_id = snapshot.id();
// Index-manifest scan into filtered ANN payload tuples.
let table_path = self.table.location().trim_end_matches('/');
@@ -1161,6 +1168,86 @@ mod tests {
.unwrap()
}
+ #[tokio::test]
+ async fn
plan_validates_original_scan_mode_and_rejects_nonempty_incremental_search() {
+ let (_tmp, table) = build_pruning_test_table(false).await;
+ for extra in [
+ HashMap::from([("scan.mode".to_string(),
"latest-full".to_string())]),
+ HashMap::from([(
+ "incremental-between-timestamp".to_string(),
+ "0,9999999999999".to_string(),
+ )]),
+ ] {
+ let view = table.copy_with_options(extra);
+ assert!(PkVectorScan::new(
+ &view,
+ prune_vector_field_id(&view),
+ PRUNE_INDEX_TYPE.to_string(),
+ None
+ )
+ .plan()
+ .await
+ .is_err());
+ }
+ let empty_window = table.copy_with_options(HashMap::from([(
+ "incremental-between-timestamp".to_string(),
+ "0,1".to_string(),
+ )]));
+ assert!(PkVectorScan::new(
+ &empty_window,
+ prune_vector_field_id(&empty_window),
+ PRUNE_INDEX_TYPE.to_string(),
+ None
+ )
+ .plan()
+ .await
+ .unwrap()
+ .splits
+ .is_empty());
+ }
+
+ #[tokio::test]
+ async fn
plan_uses_complete_retained_tag_metadata_and_explicit_empty_view() {
+ let (_tmp, table) = build_pruning_test_table(false).await;
+ let snapshot =
table.resolve_read_snapshot().await.unwrap().unwrap();
+ table.tag_manager().create("kept", &snapshot).await.unwrap();
+ let pinned = table.copy_with_pinned_snapshot(Some(&snapshot));
+ let tagged = table.copy_with_options(HashMap::from([(
+ "scan.tag-name".to_string(),
+ "kept".to_string(),
+ )]));
+ table
+ .file_io()
+
.delete_file(&table.snapshot_manager().snapshot_path(snapshot.id()))
+ .await
+ .unwrap();
+ for view in [pinned, tagged] {
+ let plan = PkVectorScan::new(
+ &view,
+ prune_vector_field_id(&view),
+ PRUNE_INDEX_TYPE.to_string(),
+ None,
+ )
+ .plan()
+ .await
+ .unwrap();
+ assert_eq!(plan.snapshot_id, snapshot.id());
+ assert_eq!(plan.splits.len(), 1);
+ }
+ let empty = table.copy_with_pinned_snapshot(None);
+ let plan = PkVectorScan::new(
+ &empty,
+ prune_vector_field_id(&empty),
+ PRUNE_INDEX_TYPE.to_string(),
+ None,
+ )
+ .plan()
+ .await
+ .unwrap();
+ assert_eq!(plan.snapshot_id, 0);
+ assert!(plan.splits.is_empty());
+ }
+
#[tokio::test]
async fn plan_prunes_rust_pk_file_using_key_stats() {
// Real PK table, one data file with id in [0, PRUNE_ROWS). A
predicate
diff --git a/crates/paimon/src/table/table_merge_input.rs
b/crates/paimon/src/table/table_merge_input.rs
index 67b910d6..7512718b 100644
--- a/crates/paimon/src/table/table_merge_input.rs
+++ b/crates/paimon/src/table/table_merge_input.rs
@@ -120,14 +120,11 @@ impl MergeInput {
};
// 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(
- || table.clone(),
- |snapshot| {
- table
- .copy_with_pinned_snapshot(snapshot)
- .copy_with_options(HashMap::from([("scan.mode".into(),
"default".into())]))
- },
- );
+ let table = if incremental {
+ table.clone()
+ } else {
+ table.copy_with_pinned_snapshot(snapshot.as_ref())
+ };
let mut builder = table.new_read_builder();
builder.with_projection(&names)?;
let plan = if incremental || snapshot.is_some() {
diff --git a/crates/paimon/src/table/table_merge_into.rs
b/crates/paimon/src/table/table_merge_into.rs
index a37ed906..0004e3b7 100644
--- a/crates/paimon/src/table/table_merge_into.rs
+++ b/crates/paimon/src/table/table_merge_into.rs
@@ -275,14 +275,12 @@ pub(super) async fn merge_into(
}
let _ = DataEvolutionWriter::new(table, update_columns.clone())?;
let snapshot = super::time_travel::resolve_snapshot(table).await?;
- let scan_table = match &snapshot {
- Some(snapshot) => table.copy_with_pinned_snapshot(snapshot),
- None => table.clone(),
- }
- .copy_with_options(HashMap::from([
- ("scalar-index.search-mode".into(), "FULL".into()),
- ("scan.mode".into(), "default".into()),
- ]));
+ let scan_table = table
+ .copy_with_options(HashMap::from([(
+ "scalar-index.search-mode".into(),
+ "FULL".into(),
+ )]))
+ .copy_with_pinned_snapshot(snapshot.as_ref());
let self_merge = matches!(source, MergeSource::SelfTable);
if self_merge {
if on != [(ROW_ID.into(), ROW_ID.into())] || !not_matched.is_empty() {
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index dd2fc4bb..fa84bf5e 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1939,6 +1939,9 @@ impl<'a> PaimonTableScan<'a> {
core_options.ensure_type_paimon_served(&self.table.identifier().full_name())?;
core_options.validate_scan_options()?;
core_options.validate_data_file_path_directory()?;
+ // Timestamp windows bypass point-snapshot resolution but must still
+ // reject a schema whose captured selection was invalidated by a copy.
+ self.table.ensure_read_snapshot_current()?;
// File paths and stats, not table columns: the endpoint cannot rule
on them.
let query_auth =
CoreOptions::new(self.table.schema().options()).query_auth_enabled();
diff --git a/crates/paimon/src/table/table_update_predicate.rs
b/crates/paimon/src/table/table_update_predicate.rs
index 675db998..13c22bb0 100644
--- a/crates/paimon/src/table/table_update_predicate.rs
+++ b/crates/paimon/src/table/table_update_predicate.rs
@@ -114,11 +114,11 @@ pub(super) async fn update(
// Updates must include unindexed rows even when normal reads request the
// partial/detail scalar-index search mode.
let scan_table = table
- .copy_with_options(HashMap::from([
- ("scalar-index.search-mode".into(), "FULL".into()),
- ("scan.mode".into(), "default".into()),
- ]))
- .copy_with_pinned_snapshot(&snapshot);
+ .copy_with_options(HashMap::from([(
+ "scalar-index.search-mode".into(),
+ "FULL".into(),
+ )]))
+ .copy_with_pinned_snapshot(Some(&snapshot));
let mut read_builder = scan_table.new_read_builder();
let mut projection = Vec::new();
for name in &read_columns {
diff --git a/crates/paimon/src/table/time_travel.rs
b/crates/paimon/src/table/time_travel.rs
index fadb1715..e2604cef 100644
--- a/crates/paimon/src/table/time_travel.rs
+++ b/crates/paimon/src/table/time_travel.rs
@@ -134,17 +134,10 @@ async fn resolve_watermark(
/// subsequently changed is rejected instead of mixing that stale schema with a
/// different snapshot.
pub(crate) async fn resolve_snapshot(table: &Table) ->
crate::Result<Option<Snapshot>> {
- if let Some(snapshot) = table.travel_snapshot() {
- return Ok(Some(snapshot.clone()));
- }
- if table.is_time_traveled() {
- return Err(Error::DataInvalid {
- message: "Table options changed after time travel; \
- use copy_with_time_travel to re-resolve the snapshot and
schema"
- .to_string(),
- source: None,
- });
+ if let Some(view) = &table.travel_snapshot {
+ return Ok(view.snapshot().cloned());
}
+ table.ensure_read_snapshot_current()?;
match travel_to_snapshot(
&table.snapshot_manager(),
@@ -451,7 +444,7 @@ mod tests {
assert_eq!(resolved.travel_snapshot().unwrap().id(), 2);
assert_eq!(resolved.schema().id(), 1);
- let pinned =
traveled.copy_with_pinned_snapshot(traveled.travel_snapshot().unwrap());
+ let pinned =
traveled.copy_with_pinned_snapshot(traveled.travel_snapshot());
assert!(!pinned.schema().options().contains_key("scan.timestamp"));
}
for timestamp in ["2024-01-02 12:00:01", "2024-01-02 12:00:02"] {
@@ -513,7 +506,7 @@ mod tests {
.delete_file(&manager.snapshot_path(1))
.await
.unwrap();
- let pinned = table.copy_with_pinned_snapshot(&snapshot);
+ let pinned = table.copy_with_pinned_snapshot(Some(&snapshot));
assert_eq!(pinned.schema().id(), table.schema().id());
assert_eq!(pinned.schema().fields(), table.schema().fields());
assert_eq!(pinned.schema().options().get("custom").unwrap(), "value");
@@ -530,6 +523,119 @@ mod tests {
assert!(table.travel_snapshot().is_none());
}
+ #[tokio::test]
+ async fn test_pinning_replaces_prior_supported_scan_modes() {
+ use futures::TryStreamExt;
+ let (io, path) = setup_evolved_table().await;
+ let table = latest_table(&io, &path);
+ let snapshot = table.resolve_read_snapshot().await.unwrap().unwrap();
+ for extra in [
+ options(&[("scan.mode", "from-snapshot"), ("scan.snapshot-id",
"1")]),
+ options(&[
+ ("scan.mode", "from-timestamp"),
+ ("scan.timestamp-millis", "0"),
+ ]),
+ options(&[
+ ("scan.mode", "incremental"),
+ ("incremental-between-timestamp", "0,9999999999999"),
+ ]),
+ ] {
+ let configured = table.copy_with_options(extra);
+ for source in [None, Some(&snapshot)] {
+ let view = configured.copy_with_pinned_snapshot(source);
+ let plan =
view.new_read_builder().new_scan().plan().await.unwrap();
+ let builder = view.new_read_builder();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let rows: usize =
batches.iter().map(RecordBatch::num_rows).sum();
+ assert_eq!(rows, if source.is_some() { 5 } else { 0 });
+ assert!(!view
+ .schema()
+ .options()
+ .contains_key("incremental-between-timestamp"));
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn test_pinned_empty_view_survives_commits_and_option_copies() {
+ let io = FileIOBuilder::new("memory").build().unwrap();
+ let path = "memory:/pinned_empty";
+ write_schema_file(&io, path, &schema_v0()).await;
+ let table = make_table(&io, path, schema_v0());
+ let pinned = table.copy_with_pinned_snapshot(None);
+ write_and_commit(&table, &batch_v0(vec![1, 2], vec![10, 20])).await;
+
+ assert_eq!(
+ table.resolve_read_snapshot().await.unwrap().unwrap().id(),
+ 1
+ );
+ for view in [
+ pinned.clone(),
+ pinned.copy_with_options(options(&[("read.batch-size", "1")])),
+ ] {
+ assert!(view.resolve_read_snapshot().await.unwrap().is_none());
+ assert!(view
+ .new_read_builder()
+ .new_scan()
+ .plan()
+ .await
+ .unwrap()
+ .splits()
+ .is_empty());
+ assert!(view.new_write_builder().new_write().is_err());
+ assert!(view.is_time_traveled());
+ assert!(!view.has_resolved_travel_snapshot());
+ assert!(crate::catalog::list_partitions_from_file_system(&view)
+ .await
+ .unwrap()
+ .is_empty());
+ assert!(view.partition_row_counts().await.unwrap().is_empty());
+ }
+ for extra in [
+ options(&[("scan.mode", "default")]),
+ options(&[("incremental-between-timestamp", "0,9999999999999")]),
+ ] {
+ let changed = pinned.copy_with_options(extra);
+ assert!(changed.resolve_read_snapshot().await.is_err());
+
assert!(changed.new_read_builder().new_scan().plan().await.is_err());
+ assert!(changed
+ .new_read_builder()
+ .new_scan()
+ .plan_with_trace()
+ .await
+ .is_err());
+ }
+ // Changing the selector must not silently reuse the empty view or the
+ // schema captured by that view. Explicitly re-resolve the pair.
+ let changed = pinned.copy_with_options(options(&[("scan.snapshot-id",
"1")]));
+ assert!(changed.resolve_read_snapshot().await.is_err());
+ let selected =
changed.copy_with_time_travel(HashMap::new()).await.unwrap();
+ assert_eq!(
+ selected
+ .resolve_read_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id(),
+ 1
+ );
+ let reset = pinned
+ .copy_with_resolved_schema(schema_v0(), "main")
+ .unwrap();
+ assert_eq!(
+ reset.resolve_read_snapshot().await.unwrap().unwrap().id(),
+ 1
+ );
+ assert!(!reset.is_time_traveled());
+ }
+
#[tokio::test]
async fn test_copy_with_time_travel_same_schema_still_rejects_write() {
let (file_io, table_path) = setup_evolved_table().await;
diff --git a/crates/paimon/src/table/vector_search_result.rs
b/crates/paimon/src/table/vector_search_result.rs
index b2fb1f6b..01610637 100644
--- a/crates/paimon/src/table/vector_search_result.rs
+++ b/crates/paimon/src/table/vector_search_result.rs
@@ -73,7 +73,7 @@ impl SearchResult {
vector_column: String,
hits: ScoredRowIds,
) -> Self {
- let snapshot_id = table.travel_snapshot.as_ref().map(|snapshot|
snapshot.id());
+ let snapshot_id = table.travel_snapshot().map(|snapshot|
snapshot.id());
Self {
table,
snapshot_id,