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 c33f79d5 [core] Support authorized raw file planning for native scans
(#1197)
c33f79d5 is described below
commit c33f79d5b1c2e8234d4c1408405e53a9a0807e5f
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 22 16:34:29 2026 +0800
[core] Support authorized raw file planning for native scans (#1197)
---
.../python/python/pypaimon_rust/datafusion.pyi | 19 +-
bindings/python/src/commit_callback.rs | 6 +
bindings/python/src/context.rs | 1 +
bindings/python/src/read.rs | 260 ++++++++++++++++++++-
crates/paimon/src/table/authorized_update_tests.rs | 12 +-
crates/paimon/src/table/data_evolution_writer.rs | 6 +-
crates/paimon/src/table/incremental_scan.rs | 26 ++-
crates/paimon/src/table/mod.rs | 2 +-
crates/paimon/src/table/shard_table_updater.rs | 2 +-
crates/paimon/src/table/snapshot_scan_tests.rs | 235 +++++++++++++++++++
crates/paimon/src/table/table_merge_input.rs | 2 +-
crates/paimon/src/table/table_merge_into.rs | 2 +-
crates/paimon/src/table/table_scan.rs | 243 ++++++++++++++++++-
crates/paimon/src/table/table_scan/file_scan.rs | 234 +++++++++++++++++++
crates/paimon/src/table/table_upsert.rs | 2 +-
.../tests/incremental_timestamp_scan_test.rs | 41 ++++
crates/paimon/tests/nullable_primary_key_test.rs | 2 +-
crates/paimon/tests/rest_catalog/merge_sources.rs | 2 +-
.../tests/rest_catalog/read_path_completion.rs | 118 +++++++++-
crates/paimon/tests/rest_catalog_test.rs | 2 +-
20 files changed, 1175 insertions(+), 42 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 81770d15..ad573e03 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -164,6 +164,11 @@ class GlobalIndexResult:
...
class TableScan:
+ def with_file_filters(self, skip_level0: bool, only_read_real_buckets:
bool) -> TableScan:
+ """Set independent level-zero and pending-bucket policies for
+ plan() and plan_files(). plan_all() includes L0 and retains the
+ configured bucket policy."""
+ ...
def with_global_index_result(self, result: GlobalIndexResult) ->
"TableScan":
"""Reuse an index selection. Authorized scans ignore supplied index
selections."""
...
@@ -179,10 +184,20 @@ class TableScan:
ranges of shuffled chunks. Row-position sharding is a separate API."""
...
def plan(self) -> Plan: ...
- def plan_all_files(self) -> Plan:
- """Include L0 and all column files, retaining scan filters and limits.
Requires unrestricted query authorization."""
+ def plan_all(self) -> Plan:
+ """Split Plan including L0 and all matching data-evolution column
files.
+ Retains scan filters and limits. Requires unrestricted query
authorization."""
+ ...
+ def plan_files(self) -> FileScanPlan:
+ """Filtered file entries before projection, LIMIT, row selection and
index refinement.
+ Without explicit file filters, includes level zero and pending buckets.
+ Requires unrestricted whole-table query authorization."""
...
+class FileScanPlan:
+ def snapshot_id(self) -> Optional[int]: ...
+ def files(self) -> List[ManifestEntry]: ...
+
class StreamTableScan:
"""Stateful continuous scan; checkpoint is the next snapshot to consume."""
diff --git a/bindings/python/src/commit_callback.rs
b/bindings/python/src/commit_callback.rs
index 7bb744c3..0dbe0a71 100644
--- a/bindings/python/src/commit_callback.rs
+++ b/bindings/python/src/commit_callback.rs
@@ -80,6 +80,12 @@ pub struct PyManifestEntry {
inner: ManifestEntry,
}
+impl From<ManifestEntry> for PyManifestEntry {
+ fn from(inner: ManifestEntry) -> Self {
+ Self { inner }
+ }
+}
+
#[pymethods]
impl PyManifestEntry {
#[getter]
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index 102a3a4a..7e56ceda 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -1019,6 +1019,7 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<crate::read::PyTableScan>()?;
this.add_class::<crate::read::PyStreamTableScan>()?;
this.add_class::<crate::read::PyPlan>()?;
+ this.add_class::<crate::read::PyFileScanPlan>()?;
this.add_class::<crate::read::PyScanStats>()?;
this.add_class::<crate::read::PyTableRead>()?;
this.add_class::<crate::read::PyRecordBatchReader>()?;
diff --git a/bindings/python/src/read.rs b/bindings/python/src/read.rs
index 30ffdf52..05996167 100644
--- a/bindings/python/src/read.rs
+++ b/bindings/python/src/read.rs
@@ -351,6 +351,7 @@ impl PyReadBuilder {
row_position_shard: None,
chunk_shuffle: None,
shard: None,
+ file_filters: None,
}
}
@@ -415,6 +416,7 @@ pub struct PyTableScan {
row_position_shard: Option<(u64, u64)>,
chunk_shuffle: Option<PyChunkShuffle>,
shard: Option<(usize, usize)>,
+ file_filters: Option<(bool, bool)>,
}
#[derive(Clone)]
@@ -470,6 +472,11 @@ impl PyTableScan {
if let Some((index, count)) = self.shard {
scan = scan.with_shard(index, count).map_err(to_py_err)?;
}
+ if let Some((skip_level0, only_read_real_buckets)) = self.file_filters
{
+ scan = scan
+ .with_file_filters(skip_level0, only_read_real_buckets)
+ .map_err(to_py_err)?;
+ }
Ok(scan)
}
@@ -691,6 +698,25 @@ impl PyStreamTableScan {
#[pymethods]
impl PyTableScan {
+ /// Configure independent file filters for plan() and plan_files().
+ /// plan_all() includes L0 while retaining the configured bucket policy.
+ fn with_file_filters(
+ mut slf: PyRefMut<'_, Self>,
+ skip_level0: bool,
+ only_read_real_buckets: bool,
+ ) -> PyResult<PyRefMut<'_, Self>> {
+ if slf.incremental_scan.is_some() {
+ return Err(PyValueError::new_err(
+ "file filters use table snapshot or
incremental-between-timestamp options",
+ ));
+ }
+ slf.core_scan()?
+ .with_file_filters(skip_level0, only_read_real_buckets)
+ .map_err(to_py_err)?;
+ slf.file_filters = Some((skip_level0, only_read_real_buckets));
+ Ok(slf)
+ }
+
/// Supply an evaluated selection; validation and authorization stay in
core.
fn with_global_index_result<'py>(
mut slf: PyRefMut<'py, Self>,
@@ -769,11 +795,13 @@ impl PyTableScan {
Ok(slf)
}
- fn plan_all_files(&self, py: Python<'_>) -> PyResult<PyPlan> {
+ /// Return a split Plan including L0 and all matching data-evolution
column files.
+ /// Scan filters and limits remain in effect; requires unrestricted query
authorization.
+ fn plan_all(&self, py: Python<'_>) -> PyResult<PyPlan> {
py.detach(|| {
runtime().block_on(async {
self.core_scan()?
- .plan_all_files()
+ .plan_all()
.await
.map(PyPlan::from)
.map_err(to_py_err)
@@ -781,6 +809,29 @@ impl PyTableScan {
})
}
+ /// Return filtered file metadata before split packing or row selection.
+ fn plan_files(&self, py: Python<'_>) -> PyResult<PyFileScanPlan> {
+ if self.incremental_scan.is_some() {
+ return Err(PyValueError::new_err(
+ "plan_files uses table snapshot or
incremental-between-timestamp options",
+ ));
+ }
+ py.detach(|| {
+ runtime().block_on(async {
+ let mut scan = self.read_builder()?.new_scan();
+ if let Some((skip_level0, only_read_real_buckets)) =
self.file_filters {
+ scan = scan
+ .with_file_filters(skip_level0, only_read_real_buckets)
+ .map_err(to_py_err)?;
+ }
+ scan.plan_files()
+ .await
+ .map(|inner| PyFileScanPlan { inner })
+ .map_err(to_py_err)
+ })
+ })
+ }
+
fn plan(&self, py: Python<'_>) -> PyResult<PyPlan> {
py.detach(|| {
runtime().block_on(async {
@@ -1122,6 +1173,22 @@ pub struct PyPlan {
inner: paimon::table::Plan,
}
+#[pyclass(name = "FileScanPlan", module = "pypaimon_rust.datafusion", frozen)]
+pub struct PyFileScanPlan {
+ inner: paimon::table::FileScanPlan,
+}
+
+#[pymethods]
+impl PyFileScanPlan {
+ fn snapshot_id(&self) -> Option<i64> {
+ self.inner.snapshot_id()
+ }
+
+ fn files(&self) -> Vec<crate::commit_callback::PyManifestEntry> {
+ self.inner.files().iter().cloned().map(Into::into).collect()
+ }
+}
+
impl From<paimon::table::Plan> for PyPlan {
fn from(plan: paimon::table::Plan) -> Self {
Self { inner: plan }
@@ -1242,6 +1309,195 @@ mod tests {
use super::*;
+ #[test]
+ fn
file_planning_binding_keeps_complete_entries_and_independent_query_filters() {
+ Python::attach(|py| {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("merge-engine", "first-row")
+ .build()
+ .unwrap();
+ let table = Arc::new(Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "file_binding"),
+ "memory:/file-binding".into(),
+ TableSchema::new(0, &schema),
+ None,
+ ));
+ py.detach(|| {
+ runtime().block_on(async {
+ table
+ .file_io()
+ .mkdirs("memory:/file-binding/schema")
+ .await
+ .unwrap();
+ table
+ .file_io()
+ .mkdirs("memory:/file-binding/snapshot")
+ .await
+ .unwrap();
+ table
+ .file_io()
+ .new_output("memory:/file-binding/schema/schema-0")
+ .unwrap()
+
.write(serde_json::to_vec(table.schema()).unwrap().into())
+ .await
+ .unwrap();
+ for id in [1, 1] {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let batch = RecordBatch::try_from_iter(vec![(
+ "id",
+ Arc::new(arrow::array::Int32Array::from(vec![id]))
+ as arrow::array::ArrayRef,
+ )])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ builder
+ .new_commit()
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ writer.close().await;
+ }
+ })
+ });
+ let mut builder = PyReadBuilder::new(Arc::clone(&table));
+ builder.limit = Some(0);
+ let scan = Py::new(py, builder.new_scan()).unwrap();
+ for name in ["skip_level0", "only_read_real_buckets"] {
+ let kwargs = PyDict::new(py);
+ kwargs.set_item(name, true).unwrap();
+ let error = scan
+ .bind(py)
+ .call_method("plan_files", (), Some(&kwargs))
+ .unwrap_err();
+
assert!(error.is_instance_of::<pyo3::exceptions::PyTypeError>(py));
+ }
+ assert_eq!(
+ scan.bind(py)
+ .call_method0("plan_all")
+ .unwrap()
+ .len()
+ .unwrap(),
+ 0,
+ "plan_all retains row LIMIT"
+ );
+ let files = scan.bind(py).call_method0("plan_files").unwrap();
+ assert_eq!(
+ files
+ .call_method0("snapshot_id")
+ .unwrap()
+ .extract::<i64>()
+ .unwrap(),
+ 2
+ );
+ let entries = files.call_method0("files").unwrap();
+ assert_eq!(
+ entries.len().unwrap(),
+ 2,
+ "metadata planning ignores row LIMIT"
+ );
+ let first = entries.get_item(0).unwrap();
+
assert_eq!(first.getattr("kind").unwrap().extract::<i32>().unwrap(), 0);
+ assert_eq!(
+ first
+ .getattr("total_buckets")
+ .unwrap()
+ .extract::<i32>()
+ .unwrap(),
+ 1
+ );
+ let file = first.getattr("file").unwrap();
+ assert_eq!(
+ file.getattr("row_count").unwrap().extract::<i64>().unwrap(),
+ 1
+ );
+ assert!(file
+ .getattr("file_name")
+ .unwrap()
+ .extract::<String>()
+ .unwrap()
+ .ends_with(".parquet"));
+ let encoded = first
+ .call_method0("_to_commit_message")
+ .unwrap()
+ .call_method0("serialize")
+ .unwrap();
+ let encoded = encoded.cast::<PyBytes>().unwrap();
+ let restored = paimon::table::CommitMessage::deserialize(
+ paimon::table::COMMIT_MESSAGE_SERIALIZER_VERSION,
+ encoded.as_bytes(),
+ )
+ .unwrap();
+ let expected = py.detach(|| {
+ runtime().block_on(async {
+ table
+ .new_read_builder()
+ .new_scan()
+ .plan_files()
+ .await
+ .unwrap()
+ })
+ });
+ assert_eq!(restored.new_files,
vec![expected.files()[0].file().clone()]);
+
+ builder.limit = None;
+ let scan = Py::new(py, builder.new_scan()).unwrap();
+ assert_eq!(
+ scan.bind(py).call_method0("plan").unwrap().len().unwrap(),
+ 0
+ );
+ scan.bind(py)
+ .call_method1("with_file_filters", (false, true))
+ .unwrap();
+ assert_eq!(
+ scan.bind(py).call_method0("plan").unwrap().len().unwrap(),
+ 1
+ );
+ for (skip_level0, expected_files, expected_splits) in
+ [(false, 2, 1), (true, 0, 0), (false, 2, 1)]
+ {
+ scan.bind(py)
+ .call_method1("with_file_filters", (skip_level0, true))
+ .unwrap();
+ assert_eq!(
+ scan.bind(py)
+ .call_method0("plan_files")
+ .unwrap()
+ .call_method0("files")
+ .unwrap()
+ .len()
+ .unwrap(),
+ expected_files
+ );
+ assert_eq!(
+ scan.bind(py).call_method0("plan").unwrap().len().unwrap(),
+ expected_splits
+ );
+ let all = scan.bind(py).call_method0("plan_all").unwrap();
+ let all = all.extract::<PyRef<'_, PyPlan>>().unwrap();
+ assert_eq!(
+ all.inner
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .count(),
+ 2,
+ "plan_all includes L0 even when explicit file filters
exclude it"
+ );
+ }
+ let fresh = Py::new(py, builder.new_scan()).unwrap();
+ assert_eq!(
+ fresh.bind(py).call_method0("plan").unwrap().len().unwrap(),
+ 0,
+ "file filters must not change a fresh query scan's defaults"
+ );
+ });
+ }
+
#[test]
fn stream_scan_copy_keeps_callback_ownership_and_progress_independent() {
Python::attach(|py| {
diff --git a/crates/paimon/src/table/authorized_update_tests.rs
b/crates/paimon/src/table/authorized_update_tests.rs
index aa9d8cb6..eb24ea09 100644
--- a/crates/paimon/src/table/authorized_update_tests.rs
+++ b/crates/paimon/src/table/authorized_update_tests.rs
@@ -353,7 +353,7 @@ async fn
assembled_handles_cannot_mutate_or_replay_a_live_grant() {
.table
.new_read_builder()
.new_scan()
- .plan_all_files()
+ .plan_all()
.await
.unwrap();
let mut builder = fixture.table.new_read_builder();
@@ -397,7 +397,7 @@ async fn
only_unrestricted_live_grants_allow_internal_row_id_reads() {
.table
.new_read_builder()
.new_scan()
- .plan_all_files()
+ .plan_all()
.await
.unwrap();
let serialized = serde_json::to_vec(plan.splits()).unwrap();
@@ -503,7 +503,7 @@ async fn
mutation_metadata_predicates_match_unguarded_and_authorized_rows() {
.table
.new_read_builder()
.new_scan()
- .plan_all_files()
+ .plan_all()
.await
.unwrap();
let first = plan
@@ -624,14 +624,14 @@ async fn
mutation_metadata_predicates_preserve_restricted_and_public_read_guards
.unwrap(),
);
assert!(builder.new_scan().plan().await.is_err());
- assert!(builder.new_scan().plan_all_files().await.is_err());
+ assert!(builder.new_scan().plan_all().await.is_err());
}
assert!(fixture
.table
.new_read_builder()
.new_scan()
.with_row_ranges(vec![super::super::RowRange::new(0, 0)])
- .plan_all_files()
+ .plan_all()
.await
.is_err());
assert_eq!(fixture.state.query_requests.load(Ordering::SeqCst), 0);
@@ -639,7 +639,7 @@ async fn
mutation_metadata_predicates_preserve_restricted_and_public_read_guards
.table
.new_read_builder()
.new_scan()
- .plan_all_files()
+ .plan_all()
.await
.unwrap();
// The internal marker allows canonical _ROW_ID output only. A forged
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 38c5aaa4..2df6a785 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -612,11 +612,7 @@ impl DataEvolutionDeleteWriter {
// A row filter or mask cannot authorize a physical file mutation.
let snapshot =
super::time_travel::resolve_snapshot(&self.table).await?;
let scan_table =
self.table.copy_with_pinned_snapshot(snapshot.as_ref());
- let plan = scan_table
- .new_read_builder()
- .new_scan()
- .plan_all_files()
- .await?;
+ let plan = scan_table.new_read_builder().new_scan().plan_all().await?;
let snapshot = snapshot.ok_or_else(|| crate::Error::DataInvalid {
message: "No files with row tracking found in target table".into(),
source: None,
diff --git a/crates/paimon/src/table/incremental_scan.rs
b/crates/paimon/src/table/incremental_scan.rs
index 70ece922..d06b4c03 100644
--- a/crates/paimon/src/table/incremental_scan.rs
+++ b/crates/paimon/src/table/incremental_scan.rs
@@ -423,6 +423,27 @@ impl<'a> IncrementalScan<'a> {
// valid boundary even though that snapshot need not exist.
return
Ok(Plan::new(Vec::new()).with_snapshot_id(self.end_inclusive));
}
+ let (end_snapshot, manifest_lists) =
self.selected_event_manifest_lists().await?;
+ let plan = scan
+ .plan_incremental_lists(&end_snapshot, manifest_lists, scan_stats)
+ .await?;
+ self.scan.attach_global_index_scores(plan)
+ }
+
+ /// Collect the physical event sources after the caller resolves and
validates
+ /// the snapshot range. File metadata scans use the same AUTO selection as
rows.
+ pub(crate) async fn selected_event_manifest_lists(
+ &self,
+ ) -> crate::Result<(crate::spec::Snapshot, Vec<String>)> {
+ let mode = self.resolve_mode();
+ if !matches!(
+ mode,
+ IncrementalScanMode::Delta | IncrementalScanMode::Changelog
+ ) {
+ return Err(crate::Error::Unsupported {
+ message: "File event sources require Delta or Changelog
mode".into(),
+ });
+ }
let end_snapshot = self
.snapshot_manager
.get_snapshot(self.end_inclusive)
@@ -454,10 +475,7 @@ impl<'a> IncrementalScan<'a> {
})
.map(str::to_owned)
.collect();
- let plan = scan
- .plan_incremental_lists(&end_snapshot, manifest_lists, scan_stats)
- .await?;
- self.scan.attach_global_index_scores(plan)
+ Ok((end_snapshot, manifest_lists))
}
fn resolve_mode(&self) -> IncrementalScanMode {
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 7cbce5ba..0fee5af8 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -260,7 +260,7 @@ pub use table_merge_into::{
MergeAssignment, MergeCondition, MergeSource, WhenMatched, WhenNotMatched,
};
pub use table_read::{AuditLogRead, TableRead};
-pub use table_scan::{AuditLogScan, TableScan};
+pub use table_scan::{AuditLogScan, FileScanPlan, TableScan};
pub use table_update::TableUpdate;
pub use table_update_by_row_id::TableUpdateByRowId;
pub use table_write::TableWrite;
diff --git a/crates/paimon/src/table/shard_table_updater.rs
b/crates/paimon/src/table/shard_table_updater.rs
index 3872af02..36695190 100644
--- a/crates/paimon/src/table/shard_table_updater.rs
+++ b/crates/paimon/src/table/shard_table_updater.rs
@@ -99,7 +99,7 @@ impl ShardTableUpdater {
"FULL".into(),
)]))
.copy_with_pinned_snapshot(snapshot.as_ref());
- let plan = table.new_read_builder().new_scan().plan_all_files().await?;
+ let plan = table.new_read_builder().new_scan().plan_all().await?;
let splits = select_shard(plan.splits(), shard, shard_count)?;
let index = RowIdFileIndex::from_splits(table.clone(), &splits)?;
let read_type = read_type.unwrap_or_else(||
table.schema().fields().to_vec());
diff --git a/crates/paimon/src/table/snapshot_scan_tests.rs
b/crates/paimon/src/table/snapshot_scan_tests.rs
index c535dc2a..74fe00a4 100644
--- a/crates/paimon/src/table/snapshot_scan_tests.rs
+++ b/crates/paimon/src/table/snapshot_scan_tests.rs
@@ -665,3 +665,238 @@ async fn
postpone_pending_visibility_is_an_explicit_reader_selection() {
}
}
}
+
+#[tokio::test]
+async fn explicit_file_filters_include_level_zero_with_key_merge() {
+ for engine in ["first-row", "deduplicate"] {
+ for buckets in [1, -2] {
+ let original = table(
+ &format!("file_filters_{engine}_{buckets}"),
+ engine,
+ "none",
+ buckets,
+ )
+ .await;
+ // DV's default read hides unmerged level zero. Explicit inclusion
must
+ // pack overlapping keys together and let the reader merge their
versions.
+ let table = if engine == "deduplicate" {
+ original.copy_with_options(HashMap::from([(
+ "deletion-vectors.enabled".into(),
+ "true".into(),
+ )]))
+ } else {
+ original
+ };
+ write_batch(&table, &make_batch(vec![1], vec![10])).await;
+ write_batch(&table, &make_batch(vec![1], vec![20])).await;
+ let builder = table.new_read_builder();
+ let default = builder.new_scan().plan().await.unwrap();
+ assert!(
+ default.splits().is_empty(),
+ "{engine} must keep its ordinary L0 exclusion"
+ );
+ let scan = builder
+ .new_scan()
+ .with_file_filters(false, buckets > 0)
+ .unwrap();
+ assert_eq!(scan.plan_files().await.unwrap().files().len(), 2);
+ let selected = scan.plan().await.unwrap();
+ assert_eq!(
+ selected.splits().len(),
+ 1,
+ "overlapping versions belong together"
+ );
+ assert!(!selected.splits()[0].raw_convertible());
+ let expected = if engine == "first-row" { 10 } else { 20 };
+ assert_eq!(rows(&table, &selected).await, [(1, expected)]);
+ for index in 0..3 {
+ let shard = builder
+ .new_scan()
+ .with_file_filters(false, buckets > 0)
+ .unwrap()
+ .with_shard(index, 3)
+ .unwrap()
+ .plan()
+ .await
+ .unwrap();
+ let owner = if buckets < 0 { 2 } else { 0 };
+ let expected_rows = if index == owner {
+ vec![(1, expected)]
+ } else {
+ vec![]
+ };
+ assert_eq!(
+ rows(&table, &shard).await,
+ expected_rows,
+ "{engine} bucket={buckets} worker={index}"
+ );
+ }
+ let all = builder
+ .new_scan()
+ .with_file_filters(true, true)
+ .unwrap()
+ .plan_all()
+ .await
+ .unwrap();
+ let baseline = builder.new_scan().plan_all().await.unwrap();
+ if buckets > 0 {
+ assert_eq!(all.splits(), baseline.splits());
+ } else {
+ assert!(
+ all.splits().is_empty(),
+ "whole-file planning retains the independent real-buckets
filter"
+ );
+ }
+ assert_eq!(
+ baseline
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .count(),
+ 2
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn file_scan_preserves_compacted_key_merge_inputs() {
+ for engine in ["deduplicate", "partial-update", "aggregation"] {
+ let table = table(&format!("file_merge_inputs_{engine}"), engine,
"none", 1).await;
+ for (level, value) in [(2, 10), (1, 20)] {
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1], vec![value]))
+ .await
+ .unwrap();
+ let mut messages = writer.prepare_commit().await.unwrap();
+ for message in &mut messages {
+ for file in &mut message.new_files {
+ file.level = level;
+ }
+ }
+ builder.new_commit().commit(messages).await.unwrap();
+ }
+ let expected_value = if engine == "aggregation" { 30 } else { 20 };
+ let mut builder = table.new_read_builder();
+ builder.with_filter(
+ paimon::spec::PredicateBuilder::new(table.schema().fields())
+ .equal("value", paimon::spec::Datum::Int(expected_value))
+ .unwrap(),
+ );
+ for filters in [None, Some((false, false)), Some((true, false))] {
+ let mut scan = builder.new_scan();
+ if let Some((skip, only_real)) = filters {
+ scan = scan.with_file_filters(skip, only_real).unwrap();
+ }
+ let plan = scan.plan().await.unwrap();
+ assert_eq!(rows(&table, &plan).await, [(1, expected_value)]);
+ let files = scan.plan_files().await.unwrap();
+ assert_eq!(
+ files.files().len(),
+ 2,
+ "{engine} {filters:?}: skipping L0 must retain overlapping
L1/L2 inputs"
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn file_scan_uses_effective_first_row_level_policy_for_value_stats() {
+ let uncompacted = table("file_first_row_value_stats", "first-row", "none",
1).await;
+ write_batch(&uncompacted, &make_batch(vec![1], vec![10])).await;
+ write_batch(&uncompacted, &make_batch(vec![1], vec![20])).await;
+ let mut builder = uncompacted.new_read_builder();
+ builder.with_filter(
+ paimon::spec::PredicateBuilder::new(uncompacted.schema().fields())
+ .equal("value", paimon::spec::Datum::Int(20))
+ .unwrap(),
+ );
+ assert_eq!(
+ builder.new_scan().plan_files().await.unwrap().files().len(),
+ 2,
+ "default raw planning includes L0 merge inputs, unlike a normal
first-row query"
+ );
+ assert!(builder
+ .new_scan()
+ .with_file_filters(true, false)
+ .unwrap()
+ .plan_files()
+ .await
+ .unwrap()
+ .files()
+ .is_empty());
+
+ let compacted = table("file_first_row_materialized", "first-row", "none",
1).await;
+ let builder = compacted.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![2], vec![20]))
+ .await
+ .unwrap();
+ let mut messages = writer.prepare_commit().await.unwrap();
+ messages[0].new_files[0].level = 1;
+ builder.new_commit().commit(messages).await.unwrap();
+ let mut builder = compacted.new_read_builder();
+ builder.with_filter(
+ paimon::spec::PredicateBuilder::new(compacted.schema().fields())
+ .equal("value", paimon::spec::Datum::Int(10))
+ .unwrap(),
+ );
+ assert!(
+ builder
+ .new_scan()
+ .with_file_filters(true, false)
+ .unwrap()
+ .plan_files()
+ .await
+ .unwrap()
+ .files()
+ .is_empty(),
+ "materialized first-row files still allow value stats pruning"
+ );
+}
+
+#[tokio::test]
+async fn plan_all_preserves_independent_pending_bucket_filters() {
+ let table = table("all_pending_file_filters", "deduplicate", "none",
-2).await;
+ write_batch(&table, &make_batch(vec![1], vec![10])).await;
+ let builder = table.new_read_builder();
+ let baseline = builder.new_scan().plan_all().await.unwrap();
+ assert_eq!(baseline.splits().len(), 1);
+ assert!(baseline.splits()[0].bucket() < 0);
+ for skip_level0 in [false, true] {
+ let scan = builder
+ .new_scan()
+ .with_file_filters(skip_level0, true)
+ .unwrap();
+ assert!(scan.plan().await.unwrap().splits().is_empty());
+ assert!(scan.plan_files().await.unwrap().files().is_empty());
+ assert!(scan
+ .clone()
+ .with_scan_all_files()
+ .plan_files()
+ .await
+ .unwrap()
+ .files()
+ .is_empty());
+ assert!(
+ scan.plan_all().await.unwrap().splits().is_empty(),
+ "plan_all disables L0 filtering without exposing excluded pending
buckets"
+ );
+ }
+ let all = builder
+ .new_scan()
+ .only_read_snapshot_real_buckets()
+ .with_file_filters(true, false)
+ .unwrap()
+ .plan_all()
+ .await
+ .unwrap();
+ assert_eq!(
+ all.splits(),
+ baseline.splits(),
+ "an explicit all-buckets policy replaces a previous real-buckets
selection"
+ );
+}
diff --git a/crates/paimon/src/table/table_merge_input.rs
b/crates/paimon/src/table/table_merge_input.rs
index 8c95ee07..ffdc71c6 100644
--- a/crates/paimon/src/table/table_merge_input.rs
+++ b/crates/paimon/src/table/table_merge_input.rs
@@ -121,7 +121,7 @@ impl MergeInput {
};
// Pin the source independently, preserving its resolved read schema.
// Authorized sources keep their original catalog identity and plan the
- // selected snapshot explicitly below. Include L0 as plan_all_files.
+ // selected snapshot explicitly below. Include L0 as plan_all.
let query_auth = table.schema().core_options().query_auth_enabled();
let table = if incremental || query_auth {
table.clone()
diff --git a/crates/paimon/src/table/table_merge_into.rs
b/crates/paimon/src/table/table_merge_into.rs
index 4aa129db..4abdff74 100644
--- a/crates/paimon/src/table/table_merge_into.rs
+++ b/crates/paimon/src/table/table_merge_into.rs
@@ -304,7 +304,7 @@ pub(super) async fn merge_into(
builder.with_projection(&projection.iter().map(String::as_str).collect::<Vec<_>>())?;
// The target may be empty, but its whole-table authorization must still
// precede source callbacks and insert-only MERGE actions.
- let plan = builder.new_scan().plan_all_files().await?;
+ let plan = builder.new_scan().plan_all().await?;
let index = RowIdFileIndex::from_splits(scan_table.clone(),
plan.splits())?;
let source = match source {
MergeSource::Batches(batches) => Some(MergeInput::new(batches)?),
diff --git a/crates/paimon/src/table/table_scan.rs
b/crates/paimon/src/table/table_scan.rs
index 12fbfe0d..119e88eb 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -66,6 +66,9 @@ pub use audit::AuditLogScan;
#[path = "table_scan/restore_splits.rs"]
mod restore_splits;
+mod file_scan;
+pub use file_scan::FileScanPlan;
+
/// Path segment for manifest directory under table.
const MANIFEST_DIR: &str = "manifest";
/// Path segment for index directory under table.
@@ -1127,6 +1130,7 @@ impl<'a> TableScan<'a> {
split_selection: scan.split_selection,
row_range_optimization_disabled:
scan.row_range_optimization_disabled,
scan_all_files: scan.scan_all_files,
+ file_scan_options: scan.file_scan_options,
incremental_split_mode: scan.incremental_split_mode,
projected_read_field_ids: scan.projected_read_field_ids,
query_auth_grant: scan.query_auth_grant,
@@ -1513,10 +1517,10 @@ impl<'a> TableScan<'a> {
}
}
- /// Plan matching files including L0 and all data-evolution column files.
+ /// Plan splits including L0 and all matching data-evolution column files.
/// Existing scan filters and limits still apply. Query authorization must
/// be unrestricted, as required by writer restore, overwrite and indexing.
- pub async fn plan_all_files(&self) -> crate::Result<Plan> {
+ pub async fn plan_all(&self) -> crate::Result<Plan> {
let mut source = self.clone();
if let TableScanKind::Paimon(scan) = &mut source.0 {
scan.projected_read_field_ids = None;
@@ -1729,6 +1733,7 @@ struct PaimonTableScan<'a> {
/// Used by non-read paths (overwrite, truncate, writer restore) that need
/// the complete file set. Normal read scans leave this as `false`.
scan_all_files: bool,
+ file_scan_options: Option<file_scan::FileScanOptions>,
incremental_split_mode: Option<IncrementalSplitMode>,
projected_read_field_ids: Option<HashSet<i32>>,
/// Only set on an ephemeral copy after authorization. Never stored back
into
@@ -1796,6 +1801,9 @@ impl<'a> PaimonTableScan<'a> {
}
fn only_real_buckets(&self) -> bool {
+ if let Some(options) = self.file_scan_options {
+ return options.only_read_real_buckets;
+ }
self.split_selection
.as_deref()
.is_some_and(|selection| selection.only_real_buckets)
@@ -1857,6 +1865,7 @@ impl<'a> PaimonTableScan<'a> {
split_selection: None,
row_range_optimization_disabled: false,
scan_all_files: false,
+ file_scan_options: None,
incremental_split_mode: None,
projected_read_field_ids: None,
query_auth_grant: None,
@@ -1869,6 +1878,11 @@ impl<'a> PaimonTableScan<'a> {
/// the complete file set regardless of merge engine or DV settings.
pub fn with_scan_all_files(mut self) -> Self {
self.scan_all_files = true;
+ if let Some(options) = self.file_scan_options.take() {
+ self.split_selection
+ .get_or_insert_with(Default::default)
+ .only_real_buckets = options.only_read_real_buckets;
+ }
self.projected_read_field_ids = None;
self
}
@@ -2096,8 +2110,8 @@ impl<'a> PaimonTableScan<'a> {
/// The grant predates the manifest read, so reject a concurrent
replacement
/// or rule-schema change. File metadata, including old column statistics,
/// is carried unchanged by Java QueryAuthSplit as well.
- async fn check_planned_files(&self, _plan: &Plan, query_auth: bool) ->
crate::Result<()> {
- if query_auth {
+ async fn check_planned_files(&self) -> crate::Result<()> {
+ if self.query_auth_grant.is_some() {
if let Some(rest_env) = self.table.rest_env() {
if let Some((id, fields)) = self
.query_auth_grant
@@ -2143,8 +2157,7 @@ impl<'a> PaimonTableScan<'a> {
}
async fn finish_plan(&self, plan: Plan) -> crate::Result<Plan> {
- self.check_planned_files(&plan, self.query_auth_grant.is_some())
- .await?;
+ self.check_planned_files().await?;
Ok(plan.planned(self.query_auth_grant.clone()))
}
@@ -2255,12 +2268,19 @@ impl<'a> PaimonTableScan<'a> {
let options = self.table.schema().core_options();
let has_primary_keys = !self.table.schema().primary_keys().is_empty();
let initial_state = self.snapshot_mode() ==
Some(SnapshotScanMode::All);
- // Initial stream state merges overlapping key versions, including
- // L0 files. Keep each bucket intact until that merge has completed.
+ let includes_level_zero = self
+ .file_scan_options
+ .is_some_and(|options| !options.skip_level0);
+ let includes_pending = self
+ .file_scan_options
+ .is_some_and(|options| !options.only_read_real_buckets);
+ // Initial stream state and explicit L0 reads merge overlapping key
+ // versions. Keep each bucket intact until that merge has completed.
// Follow-up physical events and ordinary raw scans retain Java's
// SplitGenerator.alwaysRawConvertible file-name distribution.
let by_file_name = if has_primary_keys {
!initial_state
+ && !includes_level_zero
&& (options.deletion_vectors_enabled()
|| options.merge_engine()? ==
crate::spec::MergeEngine::FirstRow)
} else {
@@ -2280,9 +2300,13 @@ impl<'a> PaimonTableScan<'a> {
((i128::from(hash) % count as i128).unsigned_abs() as usize)
== index
} else {
let bucket = i128::from(entry.bucket());
- // Initial state includes postpone buckets. Assign them to
- // one worker as well, rather than dropping a negative
remainder.
- let bucket = if initial_state { bucket.abs() } else { bucket };
+ // Initial state and explicit all-bucket reads include postpone
+ // buckets. Assign them to one worker as well.
+ let bucket = if initial_state || includes_pending {
+ bucket.abs()
+ } else {
+ bucket
+ };
bucket % count as i128 == index as i128
}
});
@@ -2406,7 +2430,10 @@ impl<'a> PaimonTableScan<'a> {
&self.table,
source,
skip_level_zero,
- self.scan_all_files,
+ self.file_scan_options
+ .map_or(self.scan_all_files, |options| {
+ !options.only_read_real_buckets
+ }),
has_primary_keys,
self.partition_filter.as_ref(),
&partition_fields,
@@ -2615,6 +2642,9 @@ impl<'a> PaimonTableScan<'a> {
let options = self.table.schema().core_options();
self.snapshot_mode() == Some(SnapshotScanMode::All)
|| self.is_streaming()
+ || self
+ .file_scan_options
+ .is_some_and(|options| !options.skip_level0)
|| match options.merge_engine() {
Ok(crate::spec::MergeEngine::FirstRow) =>
!self.skip_level_zero(),
_ => {
@@ -2624,6 +2654,9 @@ impl<'a> PaimonTableScan<'a> {
}
fn skip_level_zero(&self) -> bool {
+ if let Some(options) = self.file_scan_options {
+ return options.skip_level0;
+ }
let options = self.table.schema().core_options();
should_skip_level_zero_for_scan(
self.scan_all_files || self.is_streaming(),
@@ -7780,6 +7813,192 @@ mod tests {
assert!(empty.splits().is_empty());
assert_eq!(empty.snapshot_id(), Some(1));
}
+ #[tokio::test]
+ async fn test_file_scan_merges_files_before_limits_and_distribution() {
+ let table = scan_stats_test_table("memory:/file_scan_merged");
+ setup_scan_stats_dirs(&table).await;
+ let partition = BinaryRowBuilder::new(0).build_serialized();
+ let removed = stats_test_file("removed.parquet", 1, 2);
+ let keep = stats_test_file("keep.parquet", 10, 20);
+ let commit = TableCommit::new(table.clone(), "file-scan".into());
+ commit
+ .commit(vec![CommitMessage::new(
+ partition.clone(),
+ 0,
+ vec![removed.clone(), keep.clone()],
+ )])
+ .await
+ .unwrap();
+ let mut changes =
+ CommitMessage::new(partition, 0,
vec![stats_test_file("new.parquet", 100, 101)]);
+ changes.deleted_files.push(removed);
+ commit.commit(vec![changes]).await.unwrap();
+
+ let mut builder = table.new_read_builder();
+ builder.with_limit(0);
+ builder.with_row_ranges(Vec::new());
+ let plan = builder
+ .new_scan()
+ .with_row_position_slice(0, 1)
+ .unwrap()
+ .plan_files()
+ .await
+ .unwrap();
+ assert_eq!(plan.snapshot_id(), Some(2));
+ assert_eq!(
+ plan.files()
+ .iter()
+ .map(|e| e.file().file_name.as_str())
+ .collect::<Vec<_>>(),
+ ["keep.parquet", "new.parquet"]
+ );
+ let sharded = builder
+ .new_scan()
+ .with_shard(1, 2)
+ .unwrap()
+ .plan_files()
+ .await
+ .unwrap();
+ assert_eq!(sharded.files(), plan.files());
+ builder.with_filter(
+ PredicateBuilder::new(table.schema().fields())
+ .equal("id", Datum::Int(15))
+ .unwrap(),
+ );
+ let filtered = builder.new_scan().plan_files().await.unwrap();
+ assert_eq!(filtered.files().len(), 1);
+ assert_eq!(filtered.files()[0].file().file_name, "keep.parquet");
+ assert_eq!(filtered.files()[0].file().value_stats, keep.value_stats);
+
+ let historical =
+ table.copy_with_options(HashMap::from([("scan.snapshot-id".into(),
"1".into())]));
+ let old = historical
+ .new_read_builder()
+ .new_scan()
+ .plan_files()
+ .await
+ .unwrap();
+ assert_eq!(old.snapshot_id(), Some(1));
+ assert_eq!(
+ old.files()
+ .iter()
+ .map(|e| e.file().file_name.as_str())
+ .collect::<Vec<_>>(),
+ ["removed.parquet", "keep.parquet"]
+ );
+ }
+
+ #[tokio::test]
+ async fn
test_file_scan_level_and_pending_bucket_policies_are_independent() {
+ let schema = PaimonSchema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "-2")
+ .option("deletion-vectors.enabled", "true")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "policies"),
+ "memory:/file_scan_policies".into(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ setup_scan_stats_dirs(&table).await;
+ let file = |name: &str, level| {
+ let mut file = make_evo_file(name, 128, 2, 1, None);
+ file.level = level;
+ file
+ };
+ let mut real = CommitMessage::new(
+ BinaryRowBuilder::new(0).build_serialized(),
+ 0,
+ vec![file("real-l0.parquet", 0), file("real-l1.parquet", 1)],
+ );
+ real.total_buckets = Some(2);
+ let pending = CommitMessage::new(
+ BinaryRowBuilder::new(0).build_serialized(),
+ -2,
+ vec![file("pending-l0.parquet", 0), file("pending-l1.parquet", 1)],
+ );
+ TableCommit::new(table.clone(), "file-policies".into())
+ .commit(vec![real, pending])
+ .await
+ .unwrap();
+ let builder = table.new_read_builder();
+ let mut scan = builder.new_scan();
+ assert_eq!(scan.plan_files().await.unwrap().files().len(), 4);
+ for (skip, only_real, expected) in [
+ (false, false, 4),
+ (true, false, 2),
+ (false, true, 2),
+ (true, true, 1),
+ (false, false, 4),
+ ] {
+ scan = scan.with_file_filters(skip, only_real).unwrap();
+ let plan = scan.plan_files().await.unwrap();
+ assert_eq!(plan.files().len(), expected);
+ assert!(plan
+ .files()
+ .iter()
+ .all(|e| (!skip || e.file().level != 0) && (!only_real ||
e.bucket() >= 0)));
+ }
+ // Explicit metadata policies must not change a later query scan's
defaults.
+ let query = table.new_read_builder().new_scan().plan().await.unwrap();
+ assert!(query
+ .splits()
+ .iter()
+ .all(|s| s.bucket() >= 0 && s.data_files().iter().all(|f| f.level
> 0)));
+ }
+
+ #[tokio::test]
+ async fn
test_file_scan_keeps_unprojected_blob_files_and_ignores_index_ranges() {
+ let schema = PaimonSchema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(crate::spec::BlobType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .build()
+ .unwrap();
+ let table = data_evolution_test_table(
+ "memory:/file_scan_blob_columns",
+ TableSchema::new(0, &schema),
+ );
+ setup_scan_stats_dirs(&table).await;
+ let mut anchor = make_evo_file_with_cols("anchor.parquet", 4, 1, 0,
&["id"]);
+ anchor.first_row_id = None;
+ anchor.file_source = Some(0);
+ let mut blob = make_evo_file_with_cols("payload.blob", 4, 1, 0,
&["payload"]);
+ blob.file_source = Some(0);
+ let commit = TableCommit::new(table.clone(), "file-columns".into());
+ commit
+ .commit(vec![CommitMessage::new(
+ BinaryRowBuilder::new(0).build_serialized(),
+ 0,
+ vec![anchor],
+ )])
+ .await
+ .unwrap();
+ commit
+ .commit(vec![CommitMessage::new(
+ BinaryRowBuilder::new(0).build_serialized(),
+ 0,
+ vec![blob],
+ )])
+ .await
+ .unwrap();
+ let mut builder = table.new_read_builder();
+ builder.with_projection(&["id"]).unwrap().with_limit(0);
+ let plan = builder
+ .new_scan()
+
.with_global_index_result(super::GlobalIndexResult::from_row_ranges(Vec::new()))
+ .unwrap()
+ .plan_files()
+ .await
+ .unwrap();
+ assert_eq!(plan.files().len(), 2);
+ assert_eq!(plan.files()[1].file().file_name, "payload.blob");
+ }
}
#[cfg(test)]
diff --git a/crates/paimon/src/table/table_scan/file_scan.rs
b/crates/paimon/src/table/table_scan/file_scan.rs
new file mode 100644
index 00000000..93dfab5b
--- /dev/null
+++ b/crates/paimon/src/table/table_scan/file_scan.rs
@@ -0,0 +1,234 @@
+// 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.
+
+//! File metadata planning before row selection and split packing.
+
+use super::{
+ read_all_manifest_entries, BucketSelector, CoreOptions, ManifestEntry,
ManifestListSource,
+ PaimonTableScan, ScanStats, TableScan, TableScanKind,
+};
+use crate::table::incremental_scan::timestamp_snapshot_range;
+use crate::table::{IncrementalScan, IncrementalScanMode};
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, Copy)]
+pub(super) struct FileScanOptions {
+ pub(super) skip_level0: bool,
+ pub(super) only_read_real_buckets: bool,
+}
+
+/// A selected snapshot's filtered files, before split packing or row
selection.
+#[derive(Debug, Clone)]
+pub struct FileScanPlan {
+ files: Vec<ManifestEntry>,
+ snapshot_id: Option<i64>,
+}
+
+impl FileScanPlan {
+ pub fn files(&self) -> &[ManifestEntry] {
+ &self.files
+ }
+
+ /// None for an empty table or a timestamp window with no selected
snapshot.
+ pub fn snapshot_id(&self) -> Option<i64> {
+ self.snapshot_id
+ }
+}
+
+impl TableScan<'_> {
+ /// Override file-level policies used by [`Self::plan`] and
[`Self::plan_files`].
+ /// Snapshot selection and whole-file metadata authorization are unchanged.
+ /// Unconfigured queries keep their normal merge-engine and deletion-vector
+ /// policies. [`Self::plan_all`] disables the level-zero exclusion while
+ /// preserving the configured bucket policy.
+ pub fn with_file_filters(
+ mut self,
+ skip_level0: bool,
+ only_read_real_buckets: bool,
+ ) -> crate::Result<Self> {
+ let TableScanKind::Paimon(scan) = &mut self.0 else {
+ return Err(crate::Error::Unsupported {
+ message: "File filters require a Paimon table".into(),
+ });
+ };
+ scan.file_scan_options = Some(FileScanOptions {
+ skip_level0,
+ only_read_real_buckets,
+ });
+ Ok(self)
+ }
+
+ /// Plan filtered, merged file entries without applying projections, LIMIT,
+ /// row ranges, split distribution, or global-index selections.
+ ///
+ /// The independent policies match FileStoreScan: skipping level zero never
+ /// changes whether pending buckets are visible. Metadata requires an
+ /// unrestricted whole-table grant even when the row reader projects
fields.
+ /// By default include level zero and pending buckets; configured bucket
+ /// exclusions also survive [`Self::with_scan_all_files`].
+ pub async fn plan_files(&self) -> crate::Result<FileScanPlan> {
+ let mut requested = self.clone();
+ let TableScanKind::Paimon(scan) = &mut requested.0 else {
+ return Err(crate::Error::Unsupported {
+ message: "Format tables do not have Paimon manifest
entries".into(),
+ });
+ };
+ let filters = scan.file_scan_options.unwrap_or(FileScanOptions {
+ skip_level0: false,
+ only_read_real_buckets: scan.only_real_buckets(),
+ });
+ scan.projected_read_field_ids = None;
+ scan.row_ranges = None;
+ scan.split_selection = None;
+ // Preserve the effective raw-file policy for the shared merge-safe
stats
+ // rules. Raw defaults include L0 even when ordinary first-row reads
do not.
+ scan.file_scan_options = Some(filters);
+ scan.limit = None;
+ // Authorize before turning on trusted metadata-reader behavior.
+ scan.scan_all_files = false;
+ let authorized = requested.authorize_all_files().await?;
+ let TableScanKind::Paimon(scan) = &authorized.0 else {
+ unreachable!("file metadata planning only accepts Paimon tables")
+ };
+ let plan = scan
+ .plan_file_entries(filters.skip_level0,
filters.only_read_real_buckets)
+ .await?;
+ scan.check_planned_files().await?;
+ Ok(plan)
+ }
+}
+
+impl PaimonTableScan<'_> {
+ async fn plan_file_entries(
+ &self,
+ skip_level0: bool,
+ only_read_real_buckets: bool,
+ ) -> crate::Result<FileScanPlan> {
+ let options = self.table.schema().core_options();
+ if let Some((start, end)) = options.incremental_timestamp_window()? {
+ let Some((start, end)) =
+ timestamp_snapshot_range(&self.table.snapshot_manager(),
start, end).await?
+ else {
+ return Ok(FileScanPlan {
+ files: Vec::new(),
+ snapshot_id: None,
+ });
+ };
+ let incremental = IncrementalScan::new(
+ &self.table,
+ TableScan(TableScanKind::Paimon(self.clone())),
+ IncrementalScanMode::Auto,
+ start,
+ end,
+ );
+ let (snapshot, lists) =
incremental.selected_event_manifest_lists().await?;
+ let files = self
+ .read_file_entries(
+ ManifestListSource::IncrementalLists(lists),
+ skip_level0,
+ only_read_real_buckets,
+ )
+ .await?;
+ return Ok(FileScanPlan {
+ files,
+ snapshot_id: Some(snapshot.id()),
+ });
+ }
+ let Some(snapshot) =
super::super::time_travel::resolve_snapshot(&self.table).await? else {
+ return Ok(FileScanPlan {
+ files: Vec::new(),
+ snapshot_id: None,
+ });
+ };
+ let files = self
+ .read_file_entries(
+ ManifestListSource::Snapshot(&snapshot),
+ skip_level0,
+ only_read_real_buckets,
+ )
+ .await?;
+ Ok(FileScanPlan {
+ files,
+ snapshot_id: Some(snapshot.id()),
+ })
+ }
+
+ async fn read_file_entries(
+ &self,
+ source: ManifestListSource<'_>,
+ skip_level0: bool,
+ only_read_real_buckets: bool,
+ ) -> crate::Result<Vec<ManifestEntry>> {
+ let options = CoreOptions::new(self.table.schema().options());
+ let primary_key = !self.table.schema().primary_keys().is_empty();
+ let data_predicates = if options.data_evolution_enabled() {
+ Vec::new()
+ } else {
+ self.stats_pruning_predicates()
+ };
+ let key_predicates = self.key_stats_predicates(&data_predicates);
+ let bucket_selector = self
+ .bucket_predicate
+ .as_ref()
+ .map(|predicate| BucketSelector::new(&self.table, predicate))
+ .transpose()?
+ .flatten();
+ let entries = read_all_manifest_entries(
+ &self.table,
+ source,
+ skip_level0,
+ true,
+ primary_key,
+ self.partition_filter.as_ref(),
+ &self.table.schema().partition_fields(),
+ &data_predicates,
+ &key_predicates,
+ self.table.schema().id(),
+ self.table.schema().fields(),
+ &self.table.schema().trimmed_primary_keys(),
+ bucket_selector.as_ref(),
+ None,
+ options.manifest_sidecar_enabled(),
+ options.scan_manifest_parallelism()?,
+ &mut ScanStats::default(),
+ )
+ .await?;
+ let mut files = Vec::with_capacity(entries.len());
+ let mut schema_cache = HashMap::new();
+ for entry in entries {
+ if only_read_real_buckets && entry.bucket() < 0 {
+ continue;
+ }
+ // Older-schema stats need the same field-id evolution as row
scans.
+ if entry.file().schema_id != self.table.schema().id()
+ && !data_predicates.is_empty()
+ && !super::data_file_matches_predicates_for_table(
+ &self.table,
+ entry.file(),
+ &data_predicates,
+ &key_predicates,
+ &mut schema_cache,
+ )
+ .await
+ {
+ continue;
+ }
+ files.push(entry);
+ }
+ Ok(files)
+ }
+}
diff --git a/crates/paimon/src/table/table_upsert.rs
b/crates/paimon/src/table/table_upsert.rs
index fc082a02..73d62647 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -400,7 +400,7 @@ impl TableUpsert {
let mut projection =
self.keys.iter().map(String::as_str).collect::<Vec<_>>();
projection.push(ROW_ID);
read_builder.with_projection(&projection)?;
- let plan = read_builder.new_scan().plan_all_files().await?;
+ let plan = read_builder.new_scan().plan_all().await?;
let read = read_builder.new_read()?.for_update();
let splits = self.matching_splits(&source, &plan)?;
let mut stream = read.to_arrow(splits.as_ref())?;
diff --git a/crates/paimon/tests/incremental_timestamp_scan_test.rs
b/crates/paimon/tests/incremental_timestamp_scan_test.rs
index 9f0da25d..090614b4 100644
--- a/crates/paimon/tests/incremental_timestamp_scan_test.rs
+++ b/crates/paimon/tests/incremental_timestamp_scan_test.rs
@@ -158,6 +158,30 @@ async fn
timestamp_options_select_events_and_preserve_end_snapshot() {
assert_eq!(rows, expected, "{mode}: ({start},{end}]");
}
let plan =
selected.new_read_builder().new_scan().plan().await.unwrap();
+ let files = selected
+ .new_read_builder()
+ .new_scan()
+ .plan_files()
+ .await
+ .unwrap();
+ assert_eq!(files.snapshot_id(), snapshot);
+ let mut actual_names = files
+ .files()
+ .iter()
+ .map(|entry| entry.file().file_name.clone())
+ .collect::<Vec<_>>();
+ let mut expected_names = plan
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .map(|file| file.file_name.clone())
+ .collect::<Vec<_>>();
+ actual_names.sort();
+ expected_names.sort();
+ assert_eq!(
+ actual_names, expected_names,
+ "file events {mode}: ({start},{end}]"
+ );
let scan_stats = plan.stats().clone();
assert_eq!(plan.snapshot_id(), snapshot);
assert_eq!(scan_stats.final_splits, plan.splits().len());
@@ -183,6 +207,23 @@ async fn
timestamp_auto_uses_physical_changelog_and_preserves_retracts() {
let selected = window(&table, 100, 300);
let builder = selected.new_read_builder();
let plan = builder.new_scan().plan().await.unwrap();
+ let files = builder.new_scan().plan_files().await.unwrap();
+ assert_eq!(files.snapshot_id(), Some(3));
+ let actual_names = files
+ .files()
+ .iter()
+ .map(|entry| entry.file().file_name.as_str())
+ .collect::<Vec<_>>();
+ let expected_names = plan
+ .splits()
+ .iter()
+ .flat_map(|split| split.data_files())
+ .map(|file| file.file_name.as_str())
+ .collect::<Vec<_>>();
+ assert_eq!(
+ actual_names, expected_names,
+ "AUTO must select physical changelog files"
+ );
let scan_stats = plan.stats().clone();
assert_eq!(plan.snapshot_id(), Some(3));
assert!(plan.splits().iter().all(|split| split.snapshot_id() == 3));
diff --git a/crates/paimon/tests/nullable_primary_key_test.rs
b/crates/paimon/tests/nullable_primary_key_test.rs
index 9ca5ed1b..c56e0988 100644
--- a/crates/paimon/tests/nullable_primary_key_test.rs
+++ b/crates/paimon/tests/nullable_primary_key_test.rs
@@ -82,7 +82,7 @@ async fn read(table: &Table, predicate: Option<Predicate>) ->
Vec<Row> {
if let Some(predicate) = predicate {
builder.with_filter(predicate);
}
- // First-row source reads include L0, as Python plan_all_files does.
+ // First-row source reads include L0, as Python plan_all does.
let plan = builder
.new_scan()
.with_scan_all_files()
diff --git a/crates/paimon/tests/rest_catalog/merge_sources.rs
b/crates/paimon/tests/rest_catalog/merge_sources.rs
index 3f2917c5..756d4aa2 100644
--- a/crates/paimon/tests/rest_catalog/merge_sources.rs
+++ b/crates/paimon/tests/rest_catalog/merge_sources.rs
@@ -423,7 +423,7 @@ async fn
merge_rejects_restricted_sources_even_for_unused_fields_or_empty_views(
source.set_rules(&[int_leaf(0, "id", "GREATER_THAN", 1)], &[]);
}
// DELETE matches only `id`: an unused column's mask still makes
the
- // source unsuitable for Python's existing plan_all_files contract.
+ // source unsuitable for Python's existing plan_all contract.
let error = merge(&target, &source.table, true).await.unwrap_err();
assert!(error.to_string().contains("unrestricted"), "{error}");
assert_eq!(
diff --git a/crates/paimon/tests/rest_catalog/read_path_completion.rs
b/crates/paimon/tests/rest_catalog/read_path_completion.rs
index e8d7eb98..97fa39c0 100644
--- a/crates/paimon/tests/rest_catalog/read_path_completion.rs
+++ b/crates/paimon/tests/rest_catalog/read_path_completion.rs
@@ -176,7 +176,7 @@ async fn
write_planning_rejects_rules_and_keeps_all_file_scan_guard() {
.table
.new_read_builder()
.new_scan()
- .plan_all_files()
+ .plan_all()
.await
.unwrap()
.splits()
@@ -186,7 +186,7 @@ async fn
write_planning_rejects_rules_and_keeps_all_file_scan_guard() {
.set_column_auth("default", "write_plan", vec!["id".into()]);
let mut projected = g.table.new_read_builder();
projected.with_projection(&["id"]).unwrap();
- assert_forbidden(projected.new_scan().plan_all_files().await.unwrap_err());
+ assert_forbidden(projected.new_scan().plan_all().await.unwrap_err());
g.ctx
.server
.set_column_auth("default", "write_plan", vec!["id".into(),
"name".into()]);
@@ -195,7 +195,7 @@ async fn
write_planning_rejects_rules_and_keeps_all_file_scan_guard() {
.table
.new_read_builder()
.new_scan()
- .plan_all_files()
+ .plan_all()
.await
.is_err());
assert!(g
@@ -864,3 +864,115 @@ async fn
transported_optional_bucket_layout_uses_canonical_snapshot_paths() {
[1, 2]
);
}
+
+#[tokio::test]
+async fn file_scan_requires_unrestricted_whole_table_authorization() {
+ let g = written("raw_files", people, vec![people_rows(1..=2)]).await;
+ let mut projected = g.table.new_read_builder();
+ projected.with_projection(&["id"]).unwrap().with_limit(0);
+ let files = projected.new_scan().plan_files().await.unwrap();
+ assert_eq!(files.snapshot_id(), Some(1));
+ assert!(
+ !files.files().is_empty(),
+ "row LIMIT must not hide metadata"
+ );
+ assert_eq!(
+ g.ctx.server.auth_selects("default", "raw_files").last(),
+ Some(&None)
+ );
+
+ g.ctx
+ .server
+ .set_column_auth("default", "raw_files", vec!["id".into()]);
+ assert_forbidden(projected.new_scan().plan_files().await.unwrap_err());
+ g.ctx
+ .server
+ .set_column_auth("default", "raw_files", vec!["id".into(),
"name".into()]);
+ for (filters, masks) in [
+ (vec![int_leaf(0, "id", "GREATER_THAN", 1)], vec![]),
+ (vec![], vec![("name", upper(1, "name"))]),
+ ] {
+ g.set_rules(&filters, &masks);
+ assert!(projected.new_scan().plan_files().await.is_err());
+ let empty = g.table.copy_with_options(HashMap::from([
+ ("incremental-between-timestamp".into(), "100,100".into()),
+ ("scan.mode".into(), "incremental".into()),
+ ]));
+ assert!(
+ empty
+ .new_read_builder()
+ .new_scan()
+ .plan_files()
+ .await
+ .is_err(),
+ "empty timestamp windows must authorize before returning metadata"
+ );
+ }
+}
+
+#[tokio::test]
+async fn file_scan_refuses_a_replaced_schema() {
+ let g = guarded("file_schema", &["id"]).await;
+ let replaced = g
+ .table
+ .copy_with_resolved_schema(
+ paimon::spec::TableSchema::new(0, &schema_of(&["id", "secret"],
GUARDED)),
+ "main",
+ )
+ .unwrap();
+ assert_refused(
+ replaced
+ .new_read_builder()
+ .new_scan()
+ .plan_files()
+ .await
+ .unwrap_err(),
+ );
+ g.ctx.server.set_table_schema_id(
+ "default",
+ "file_schema",
+ schema_of(&["id", "extra"], GUARDED),
+ 0,
+ );
+ assert_drifted(
+ g.table
+ .new_read_builder()
+ .new_scan()
+ .plan_files()
+ .await
+ .unwrap_err(),
+ "serves other columns",
+ );
+}
+
+#[tokio::test]
+async fn file_scan_checks_identity_after_planning_empty_file_sets() {
+ for timestamp_window in [false, true] {
+ let name = if timestamp_window {
+ "empty_file_window"
+ } else {
+ "empty_file_table"
+ };
+ let g = guarded(name, &["id"]).await;
+ let selected = if timestamp_window {
+ g.table.copy_with_options(HashMap::from([
+ ("incremental-between-timestamp".into(), "100,100".into()),
+ ("scan.mode".into(), "incremental".into()),
+ ]))
+ } else {
+ g.table.clone()
+ };
+ g.ctx
+ .server
+ .set_table_uuid_after_calls("default", name,
"replacement-after-grant", 2);
+ assert_drifted(
+ selected
+ .new_read_builder()
+ .new_scan()
+ .plan_files()
+ .await
+ .unwrap_err(),
+ "now resolves to uuid",
+ );
+ }
+}
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index 164f76b2..3bb470f7 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -4964,7 +4964,7 @@ async fn
test_query_auth_keeps_a_mask_source_in_another_partial_file() {
.sum::<usize>(),
1
);
- let write_plan = projected.new_scan().plan_all_files().await.unwrap();
+ let write_plan = projected.new_scan().plan_all().await.unwrap();
assert_eq!(
write_plan
.splits()