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 3757d9d  Add scan trace counters and pruning baselines (#381)
3757d9d is described below

commit 3757d9dbff791d2c2f989f183d4468375590c392
Author: Huang Qiwei <[email protected]>
AuthorDate: Fri Jul 3 20:11:34 2026 +0800

    Add scan trace counters and pruning baselines (#381)
---
 .../datafusion/src/full_text_search.rs             |   1 +
 .../datafusion/src/physical_plan/scan.rs           |  13 +-
 crates/integrations/datafusion/src/table/mod.rs    |   5 +-
 .../integrations/datafusion/src/vector_search.rs   |   1 +
 .../integrations/datafusion/tests/read_tables.rs   |   2 +-
 .../datafusion/tests/scan_pruning_trace.rs         | 293 +++++++++++++++++
 crates/paimon/src/lib.rs                           |   2 +-
 crates/paimon/src/table/mod.rs                     |   2 +
 crates/paimon/src/table/scan_trace.rs              |  93 ++++++
 crates/paimon/src/table/table_scan.rs              | 348 +++++++++++++++++----
 10 files changed, 687 insertions(+), 73 deletions(-)

diff --git a/crates/integrations/datafusion/src/full_text_search.rs 
b/crates/integrations/datafusion/src/full_text_search.rs
index 7e9dc39..9b9679b 100644
--- a/crates/integrations/datafusion/src/full_text_search.rs
+++ b/crates/integrations/datafusion/src/full_text_search.rs
@@ -191,6 +191,7 @@ impl TableProvider for FullTextSearchTableProvider {
             table,
             schema: &self.schema(),
             plan: &plan,
+            scan_trace: None,
             projection,
             pushed_predicate: None,
             limit,
diff --git a/crates/integrations/datafusion/src/physical_plan/scan.rs 
b/crates/integrations/datafusion/src/physical_plan/scan.rs
index a7387f4..68a9c42 100644
--- a/crates/integrations/datafusion/src/physical_plan/scan.rs
+++ b/crates/integrations/datafusion/src/physical_plan/scan.rs
@@ -29,7 +29,7 @@ use 
datafusion::physical_plan::stream::RecordBatchStreamAdapter;
 use datafusion::physical_plan::{DisplayAs, ExecutionPlan, Partitioning, 
PlanProperties};
 use futures::{StreamExt, TryStreamExt};
 use paimon::spec::Predicate;
-use paimon::table::Table;
+use paimon::table::{ScanTrace, Table};
 use paimon::DataSplit;
 
 use crate::error::to_datafusion_error;
@@ -57,9 +57,12 @@ pub struct PaimonTableScan {
     /// Whether the pushed predicate is exact (no residual filtering needed).
     /// When true and all splits have known merged_row_count, statistics can 
be exact.
     filter_exact: bool,
+    /// Metadata-pruning trace captured during eager scan planning.
+    scan_trace: Option<ScanTrace>,
 }
 
 impl PaimonTableScan {
+    #[allow(clippy::too_many_arguments)]
     pub(crate) fn new(
         schema: ArrowSchemaRef,
         table: Table,
@@ -68,6 +71,7 @@ impl PaimonTableScan {
         planned_partitions: Vec<Arc<[DataSplit]>>,
         limit: Option<usize>,
         filter_exact: bool,
+        scan_trace: Option<ScanTrace>,
     ) -> Self {
         let plan_properties = Arc::new(PlanProperties::new(
             EquivalenceProperties::new(schema.clone()),
@@ -83,6 +87,7 @@ impl PaimonTableScan {
             plan_properties,
             limit,
             filter_exact,
+            scan_trace,
         }
     }
 
@@ -245,6 +250,9 @@ impl DisplayAs for PaimonTableScan {
         if let Some(limit) = self.limit {
             write!(f, ", limit={limit}")?;
         }
+        if let Some(ref trace) = self.scan_trace {
+            write!(f, ", trace={trace}")?;
+        }
         Ok(())
     }
 }
@@ -290,6 +298,7 @@ mod tests {
             vec![Arc::from(Vec::new())],
             None,
             false,
+            None,
         );
         assert_eq!(scan.properties().output_partitioning().partition_count(), 
1);
     }
@@ -310,6 +319,7 @@ mod tests {
             planned_partitions,
             None,
             false,
+            None,
         );
         assert_eq!(scan.properties().output_partitioning().partition_count(), 
3);
     }
@@ -387,6 +397,7 @@ mod tests {
             vec![Arc::from(vec![split])],
             None,
             false,
+            None,
         );
 
         let ctx = SessionContext::new();
diff --git a/crates/integrations/datafusion/src/table/mod.rs 
b/crates/integrations/datafusion/src/table/mod.rs
index ec8893b..316bf9d 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -103,6 +103,7 @@ pub(crate) struct PaimonScanBuilder<'a> {
     pub(crate) table: &'a Table,
     pub(crate) schema: &'a ArrowSchemaRef,
     pub(crate) plan: &'a paimon::table::Plan,
+    pub(crate) scan_trace: Option<paimon::table::ScanTrace>,
     pub(crate) projection: Option<&'a Vec<usize>>,
     pub(crate) pushed_predicate: Option<paimon::spec::Predicate>,
     pub(crate) limit: Option<usize>,
@@ -149,6 +150,7 @@ impl PaimonScanBuilder<'_> {
             planned_partitions,
             self.limit,
             self.filter_exact,
+            self.scan_trace,
         )))
     }
 }
@@ -199,7 +201,7 @@ impl TableProvider for PaimonTableProvider {
         // Tokio runtime. `scan.plan()` can reach OpenDAL/Tokio filesystem 
calls while
         // reading Paimon metadata, so we must provide a runtime here instead 
of
         // assuming the caller already entered one.
-        let plan = await_with_runtime(scan.plan())
+        let (plan, scan_trace) = await_with_runtime(scan.plan_with_trace())
             .await
             .map_err(to_datafusion_error)?;
 
@@ -213,6 +215,7 @@ impl TableProvider for PaimonTableProvider {
             table: &self.table,
             schema: &self.schema,
             plan: &plan,
+            scan_trace: Some(scan_trace),
             projection,
             pushed_predicate: filter_analysis.pushed_predicate,
             limit: pushed_limit,
diff --git a/crates/integrations/datafusion/src/vector_search.rs 
b/crates/integrations/datafusion/src/vector_search.rs
index 34daadc..5b53d14 100644
--- a/crates/integrations/datafusion/src/vector_search.rs
+++ b/crates/integrations/datafusion/src/vector_search.rs
@@ -188,6 +188,7 @@ impl TableProvider for VectorSearchTableProvider {
             table,
             schema: &self.schema(),
             plan: &plan,
+            scan_trace: None,
             projection,
             pushed_predicate: None,
             limit,
diff --git a/crates/integrations/datafusion/tests/read_tables.rs 
b/crates/integrations/datafusion/tests/read_tables.rs
index 625a686..cc4c781 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -526,7 +526,7 @@ async fn 
test_residual_filter_limit_keeps_connector_limit_and_correctness() {
     assert!(
         scan_lines
             .iter()
-            .all(|line| !line.contains("limit=")),
+            .all(|line| !line.contains(", limit=")),
         "Residual filter queries should not push a scan limit hint when 
residual filters stay above the scan, plan:\n{plan_text}"
     );
 
diff --git a/crates/integrations/datafusion/tests/scan_pruning_trace.rs 
b/crates/integrations/datafusion/tests/scan_pruning_trace.rs
new file mode 100644
index 0000000..c92ee8a
--- /dev/null
+++ b/crates/integrations/datafusion/tests/scan_pruning_trace.rs
@@ -0,0 +1,293 @@
+// 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.
+
+//! Self-contained baselines for scan-pruning trace counters.
+
+mod common;
+
+use std::collections::HashMap;
+
+use datafusion::arrow::array::Int64Array;
+use datafusion::physical_plan::displayable;
+use paimon::catalog::Identifier;
+use paimon::spec::{Datum, PredicateBuilder};
+use paimon::{Catalog, Table};
+
+async fn setup_trace_table() -> (tempfile::TempDir, 
std::sync::Arc<paimon::FileSystemCatalog>) {
+    let (tmp, catalog) = common::create_test_env();
+    let sql_context = common::create_sql_context(catalog.clone()).await;
+    sql_context
+        .sql("CREATE SCHEMA paimon.test_db")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    sql_context
+        .sql(
+            "CREATE TABLE paimon.test_db.trace_append (
+                id INT, value INT, dt STRING
+             ) PARTITIONED BY (dt)",
+        )
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    common::exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.trace_append VALUES
+         (1, 10, '2024-01-01'), (2, 20, '2024-01-01')",
+    )
+    .await;
+    common::exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.trace_append VALUES
+         (100, 1000, '2024-01-02'), (101, 1010, '2024-01-02')",
+    )
+    .await;
+    common::exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.trace_append VALUES
+         (200, 2000, '2024-01-03'), (201, 2010, '2024-01-03')",
+    )
+    .await;
+
+    (tmp, catalog)
+}
+
+async fn load_table(
+    catalog: &std::sync::Arc<paimon::FileSystemCatalog>,
+    table_name: &str,
+) -> Table {
+    catalog
+        .get_table(&Identifier::new("test_db", table_name))
+        .await
+        .unwrap()
+}
+
+fn trace_counter(plan_text: &str, key: &str) -> usize {
+    let needle = format!("{key}=");
+    let start = plan_text
+        .find(&needle)
+        .unwrap_or_else(|| panic!("trace counter {key} not found in 
plan:\n{plan_text}"))
+        + needle.len();
+    let end = plan_text[start..]
+        .find(|c: char| !c.is_ascii_digit())
+        .map(|offset| start + offset)
+        .unwrap_or(plan_text.len());
+    plan_text[start..end].parse().unwrap()
+}
+
+fn trace_manifest_counts(plan_text: &str) -> (usize, usize) {
+    let needle = "manifests=";
+    let start = plan_text
+        .find(needle)
+        .unwrap_or_else(|| panic!("manifest counts not found in 
plan:\n{plan_text}"))
+        + needle.len();
+    let rest = &plan_text[start..];
+    let slash = rest
+        .find('/')
+        .unwrap_or_else(|| panic!("manifest counts missing slash in 
plan:\n{plan_text}"));
+    let after: usize = rest[..slash].parse().unwrap();
+    let before_len = rest[slash + 1..]
+        .find(|c: char| !c.is_ascii_digit())
+        .unwrap_or(rest.len() - slash - 1);
+    let before: usize = rest[slash + 1..slash + 1 + 
before_len].parse().unwrap();
+    (after, before)
+}
+
+#[tokio::test]
+async fn test_scan_trace_records_partition_pruning() {
+    let (_tmp, catalog) = setup_trace_table().await;
+    let table = load_table(&catalog, "trace_append").await;
+
+    let (_all_plan, all_trace) = table
+        .new_read_builder()
+        .new_scan()
+        .plan_with_trace()
+        .await
+        .unwrap();
+    assert_eq!(all_trace.snapshot_id, Some(3));
+    assert_eq!(all_trace.final_files, 3);
+
+    let fields = table.schema().fields();
+    let pb = PredicateBuilder::new(fields);
+    let mut partition_reader = table.new_read_builder();
+    partition_reader.with_filter(
+        pb.equal("dt", Datum::String("2024-01-01".to_string()))
+            .unwrap(),
+    );
+    let (_partition_plan, partition_trace) =
+        partition_reader.new_scan().plan_with_trace().await.unwrap();
+    assert!(
+        partition_trace.manifest_files_after_partition_pruning
+            < partition_trace.manifest_files_before_partition_pruning
+            || partition_trace.manifest_entries_pruned_by_partition > 0,
+        "partition predicate should prune at manifest or entry level: 
{partition_trace:?}"
+    );
+    assert!(
+        partition_trace.final_files < all_trace.final_files,
+        "partition pruning should reduce final files: all={all_trace:?}, 
filtered={partition_trace:?}"
+    );
+}
+
+#[tokio::test]
+async fn test_sql_between_records_partition_pruning_trace() {
+    let (_tmp, catalog) = setup_trace_table().await;
+    let sql_context = common::create_sql_context(catalog).await;
+    let sql = "SELECT id, value FROM paimon.test_db.trace_append
+               WHERE dt BETWEEN '2024-01-01' AND '2024-01-02'";
+
+    let plan = sql_context
+        .sql(sql)
+        .await
+        .unwrap()
+        .create_physical_plan()
+        .await
+        .unwrap();
+    let plan_text = displayable(plan.as_ref()).indent(true).to_string();
+    let (manifests_after, manifests_before) = 
trace_manifest_counts(&plan_text);
+    let partition_pruned = trace_counter(&plan_text, "partition_pruned");
+    assert!(
+        manifests_after < manifests_before || partition_pruned > 0,
+        "SQL BETWEEN over partition column should prune at manifest or entry 
level:\n{plan_text}"
+    );
+
+    let rows = common::collect_id_value(&sql_context, sql).await;
+    assert_eq!(rows, vec![(1, 10), (2, 20), (100, 1000), (101, 1010)]);
+}
+
+#[tokio::test]
+async fn test_count_star_uses_statistics_without_scan_trace() {
+    let (_tmp, catalog) = setup_trace_table().await;
+    let sql_context = common::create_sql_context(catalog).await;
+    let sql = "SELECT COUNT(*) FROM paimon.test_db.trace_append";
+
+    let df = sql_context.sql(sql).await.unwrap();
+    let plan = df.create_physical_plan().await.unwrap();
+    let plan_text = displayable(plan.as_ref()).indent(true).to_string();
+    assert!(
+        !plan_text.contains("PaimonTableScan"),
+        "COUNT(*) should be satisfied from exact scan statistics without data 
scan:\n{plan_text}"
+    );
+    assert!(
+        !plan_text.contains("trace="),
+        "COUNT(*) statistics rewrite should remove the scan node before trace 
display:\n{plan_text}"
+    );
+
+    let batches = sql_context.sql(sql).await.unwrap().collect().await.unwrap();
+    let count = batches[0]
+        .column(0)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap()
+        .value(0);
+    assert_eq!(count, 6);
+}
+
+#[tokio::test]
+async fn test_scan_trace_records_bucket_limit_and_time_travel() {
+    let (tmp, catalog) = common::create_test_env();
+    let sql_context = common::create_sql_context(catalog.clone()).await;
+    common::exec(&sql_context, "CREATE SCHEMA paimon.test_db").await;
+    common::exec(
+        &sql_context,
+        "CREATE TABLE paimon.test_db.trace_pk (
+            id INT NOT NULL, value INT,
+            PRIMARY KEY (id)
+         ) WITH ('bucket' = '4')",
+    )
+    .await;
+    common::exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.trace_pk VALUES
+         (1, 10), (2, 20), (3, 30), (4, 40)",
+    )
+    .await;
+    common::exec(
+        &sql_context,
+        "INSERT INTO paimon.test_db.trace_pk VALUES
+         (5, 50), (6, 60), (7, 70), (8, 80)",
+    )
+    .await;
+
+    let table = load_table(&catalog, "trace_pk").await;
+    let fields = table.schema().fields();
+    let pb = PredicateBuilder::new(fields);
+    let mut bucket_reader = table.new_read_builder();
+    bucket_reader.with_filter(pb.equal("id", Datum::Int(1)).unwrap());
+    let (_bucket_plan, bucket_trace) = 
bucket_reader.new_scan().plan_with_trace().await.unwrap();
+    assert!(
+        bucket_trace.manifest_entries_pruned_by_bucket > 0,
+        "bucket-key predicate should prune manifest entries by bucket: 
{bucket_trace:?}"
+    );
+
+    let mut limit_reader = table.new_read_builder();
+    limit_reader.with_limit(1);
+    let (_limit_plan, limit_trace) = 
limit_reader.new_scan().plan_with_trace().await.unwrap();
+    assert!(
+        limit_trace.splits_after_limit < limit_trace.splits_before_limit,
+        "LIMIT should reduce planned splits when no data residual exists: 
{limit_trace:?}"
+    );
+
+    let snapshot_one_table = table.copy_with_options(HashMap::from([(
+        "scan.version".to_string(),
+        "1".to_string(),
+    )]));
+    let (_snapshot_one_plan, snapshot_one_trace) = snapshot_one_table
+        .new_read_builder()
+        .new_scan()
+        .plan_with_trace()
+        .await
+        .unwrap();
+    assert_eq!(snapshot_one_trace.snapshot_id, Some(1));
+
+    let (_latest_plan, latest_trace) = table
+        .new_read_builder()
+        .new_scan()
+        .plan_with_trace()
+        .await
+        .unwrap();
+    assert_eq!(latest_trace.snapshot_id, Some(2));
+    assert!(
+        snapshot_one_trace.final_files < latest_trace.final_files,
+        "time travel should plan from the selected snapshot: 
snapshot1={snapshot_one_trace:?}, latest={latest_trace:?}"
+    );
+
+    drop(tmp);
+}
+
+#[tokio::test]
+async fn test_physical_plan_displays_scan_trace_summary() {
+    let (_tmp, catalog) = setup_trace_table().await;
+    let sql_context = common::create_sql_context(catalog).await;
+    let plan = sql_context
+        .sql("SELECT id FROM paimon.test_db.trace_append LIMIT 1")
+        .await
+        .unwrap()
+        .create_physical_plan()
+        .await
+        .unwrap();
+    let plan_text = displayable(plan.as_ref()).indent(true).to_string();
+
+    assert!(
+        plan_text.contains("trace=") && 
plan_text.contains("splits_before_limit="),
+        "physical plan should include scan trace summary:\n{plan_text}"
+    );
+}
diff --git a/crates/paimon/src/lib.rs b/crates/paimon/src/lib.rs
index 7b5007f..a697218 100644
--- a/crates/paimon/src/lib.rs
+++ b/crates/paimon/src/lib.rs
@@ -46,7 +46,7 @@ pub use catalog::FileSystemCatalog;
 
 pub use table::{
     CommitMessage, DataEvolutionWriter, DataSplit, DataSplitBuilder, 
DeletionFile, PartitionBucket,
-    Plan, RESTEnv, RESTSnapshotCommit, ReadBuilder, RenamingSnapshotCommit, 
RowRange,
+    Plan, RESTEnv, RESTSnapshotCommit, ReadBuilder, RenamingSnapshotCommit, 
RowRange, ScanTrace,
     SnapshotCommit, SnapshotManager, Table, TableCommit, TableRead, TableScan, 
TableUpdate,
     TableWrite, TagManager, WriteBuilder,
 };
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index e7a9052..b961f76 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -48,6 +48,7 @@ mod read_builder;
 pub mod referenced_files;
 pub(crate) mod rest_env;
 pub(crate) mod row_id_predicate;
+mod scan_trace;
 pub(crate) mod schema_manager;
 pub(crate) mod snapshot_commit;
 mod snapshot_manager;
@@ -76,6 +77,7 @@ use futures::stream::BoxStream;
 pub use lumina_index_build_builder::LuminaIndexBuildBuilder;
 pub use read_builder::ReadBuilder;
 pub use rest_env::RESTEnv;
+pub use scan_trace::ScanTrace;
 pub use schema_manager::SchemaManager;
 pub use snapshot_commit::{RESTSnapshotCommit, RenamingSnapshotCommit, 
SnapshotCommit};
 pub use snapshot_manager::SnapshotManager;
diff --git a/crates/paimon/src/table/scan_trace.rs 
b/crates/paimon/src/table/scan_trace.rs
new file mode 100644
index 0000000..d6e9e46
--- /dev/null
+++ b/crates/paimon/src/table/scan_trace.rs
@@ -0,0 +1,93 @@
+// 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.
+
+//! Scan planning trace counters.
+
+use std::fmt;
+
+/// Stage counters collected while planning a table scan.
+///
+/// The counters are intended for explain output and regression tests. They
+/// describe pruning at metadata planning time only; reader-side Parquet row
+/// group pruning and DataFusion residual filters are outside this trace.
+#[non_exhaustive]
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ScanTrace {
+    pub snapshot_id: Option<i64>,
+    pub base_manifest_files: usize,
+    pub delta_manifest_files: usize,
+    pub manifest_files_before_partition_pruning: usize,
+    pub manifest_files_after_partition_pruning: usize,
+    pub manifest_entries_read: usize,
+    pub manifest_entries_pruned_by_bucket: usize,
+    pub manifest_entries_pruned_by_partition: usize,
+    pub manifest_entries_after_entry_pruning: usize,
+    pub manifest_entries_pruned_by_level: usize,
+    pub manifest_entries_pruned_by_data_stats: usize,
+    pub manifest_entries_after_manifest_filters: usize,
+    pub manifest_entries_after_merge: usize,
+    pub manifest_entries_pruned_by_cross_schema_stats: usize,
+    pub manifest_entries_after_cross_schema_stats: usize,
+    pub data_evolution_groups_before_stats: usize,
+    pub data_evolution_groups_pruned_by_stats: usize,
+    pub data_evolution_groups_pruned_by_row_ranges: usize,
+    pub splits_before_limit: usize,
+    pub splits_after_limit: usize,
+    pub final_splits: usize,
+    pub final_files: usize,
+    pub limit: Option<usize>,
+}
+
+impl ScanTrace {
+    pub(crate) fn record_manifest_lists(&mut self, base_count: usize, 
delta_count: usize) {
+        self.base_manifest_files = base_count;
+        self.delta_manifest_files = delta_count;
+        self.manifest_files_before_partition_pruning = base_count + 
delta_count;
+    }
+
+    pub(crate) fn record_final_plan(
+        &mut self,
+        splits_before_limit: usize,
+        splits: usize,
+        files: usize,
+    ) {
+        self.splits_before_limit = splits_before_limit;
+        self.splits_after_limit = splits;
+        self.final_splits = splits;
+        self.final_files = files;
+    }
+}
+
+impl fmt::Display for ScanTrace {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(
+            f,
+            "snapshot={:?}, manifests={}/{}, entries_read={}, 
bucket_pruned={}, partition_pruned={}, data_stats_pruned={}, 
cross_schema_pruned={}, splits_before_limit={}, splits_after_limit={}, 
files={}",
+            self.snapshot_id,
+            self.manifest_files_after_partition_pruning,
+            self.manifest_files_before_partition_pruning,
+            self.manifest_entries_read,
+            self.manifest_entries_pruned_by_bucket,
+            self.manifest_entries_pruned_by_partition,
+            self.manifest_entries_pruned_by_data_stats,
+            self.manifest_entries_pruned_by_cross_schema_stats,
+            self.splits_before_limit,
+            self.splits_after_limit,
+            self.final_files
+        )
+    }
+}
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index 409f40f..57f21d8 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -45,6 +45,7 @@ use crate::table::source::{
     any_range_overlaps_file, intersect_ranges_with_file, merge_row_ranges, 
DataSplit,
     DataSplitBuilder, DeletionFile, PartitionBucket, Plan, RowRange,
 };
+use crate::table::ScanTrace;
 use crate::table::SnapshotManager;
 use futures::{StreamExt, TryStreamExt};
 use std::collections::{HashMap, HashSet};
@@ -55,6 +56,29 @@ const MANIFEST_DIR: &str = "manifest";
 /// Path segment for index directory under table.
 const INDEX_DIR: &str = "index";
 
+#[derive(Debug, Default)]
+struct ManifestReadCounters {
+    entries_read: usize,
+    pruned_by_bucket: usize,
+    pruned_by_partition: usize,
+    after_entry_pruning: usize,
+    pruned_by_level: usize,
+    pruned_by_data_stats: usize,
+    after_manifest_filters: usize,
+}
+
+impl ManifestReadCounters {
+    fn merge(&mut self, other: Self) {
+        self.entries_read += other.entries_read;
+        self.pruned_by_bucket += other.pruned_by_bucket;
+        self.pruned_by_partition += other.pruned_by_partition;
+        self.after_entry_pruning += other.after_entry_pruning;
+        self.pruned_by_level += other.pruned_by_level;
+        self.pruned_by_data_stats += other.pruned_by_data_stats;
+        self.after_manifest_filters += other.after_manifest_filters;
+    }
+}
+
 /// Reads a manifest list file (Avro) and returns manifest file metas.
 async fn read_manifest_list(
     file_io: &FileIO,
@@ -97,15 +121,21 @@ async fn read_all_manifest_entries(
     bucket_predicate: Option<&Predicate>,
     bucket_key_fields: &[DataField],
     bucket_function_type: BucketFunctionType,
+    trace: Option<&mut ScanTrace>,
 ) -> crate::Result<Vec<ManifestEntry>> {
     let (mut manifest_files, delta) = futures::try_join!(
         read_manifest_list(file_io, table_path, snapshot.base_manifest_list()),
         read_manifest_list(file_io, table_path, 
snapshot.delta_manifest_list()),
     )?;
+    let mut trace = trace;
+    if let Some(trace) = trace.as_deref_mut() {
+        trace.record_manifest_lists(manifest_files.len(), delta.len());
+    }
     manifest_files.extend(delta);
 
     // Manifest-file-level partition stats pruning: skip entire manifest files
     // whose partition range doesn't overlap the partition predicate.
+    let manifest_files_before_partition_pruning = manifest_files.len();
     if let Some(pf) = partition_filter {
         if !partition_fields.is_empty() {
             manifest_files.retain(|meta| {
@@ -123,63 +153,77 @@ async fn read_all_manifest_entries(
             });
         }
     }
+    if let Some(trace) = trace.as_deref_mut() {
+        trace.manifest_files_before_partition_pruning = 
manifest_files_before_partition_pruning;
+        trace.manifest_files_after_partition_pruning = manifest_files.len();
+    }
 
     let manifest_path_prefix = format!("{}/{}", 
table_path.trim_end_matches('/'), MANIFEST_DIR);
     let shared_cache = SharedSchemaCache::new();
-    let all_entries: Vec<ManifestEntry> = futures::stream::iter(manifest_files)
-        .map(|meta| {
-            let path = format!("{}/{}", manifest_path_prefix, 
meta.file_name());
-            let cache = shared_cache.clone();
-            async move {
-                let input_file = file_io.new_input(&path)?;
-                let content = input_file.read().await?;
-
-                // Per-task bucket cache (few distinct total_buckets values 
per manifest).
-                let mut bucket_cache: HashMap<i32, Option<HashSet<i32>>> = 
HashMap::new();
-
-                let entries = 
crate::spec::avro::from_manifest_bytes_filtered_shared(
-                    &content,
-                    &cache,
-                    &mut |_kind, partition_bytes, bucket, total_buckets| {
-                        // Bucket filter (negative bucket = unassigned)
-                        if has_primary_keys && !scan_all_files && bucket < 0 {
-                            return false;
-                        }
-                        if let Some(pred) = bucket_predicate {
-                            let targets = 
bucket_cache.entry(total_buckets).or_insert_with(|| {
-                                compute_target_buckets(
-                                    pred,
-                                    bucket_key_fields,
-                                    bucket_function_type,
-                                    total_buckets,
-                                )
-                            });
-                            if let Some(targets) = targets {
-                                if !targets.contains(&bucket) {
-                                    return false;
+    let manifest_results: Vec<(Vec<ManifestEntry>, ManifestReadCounters)> =
+        futures::stream::iter(manifest_files)
+            .map(|meta| {
+                let path = format!("{}/{}", manifest_path_prefix, 
meta.file_name());
+                let cache = shared_cache.clone();
+                async move {
+                    let input_file = file_io.new_input(&path)?;
+                    let content = input_file.read().await?;
+
+                    // Per-task bucket cache (few distinct total_buckets 
values per manifest).
+                    let mut bucket_cache: HashMap<i32, Option<HashSet<i32>>> = 
HashMap::new();
+                    let mut counters = ManifestReadCounters::default();
+
+                    let entries = 
crate::spec::avro::from_manifest_bytes_filtered_shared(
+                        &content,
+                        &cache,
+                        &mut |_kind, partition_bytes, bucket, total_buckets| {
+                            counters.entries_read += 1;
+                            // Bucket filter (negative bucket = unassigned)
+                            if has_primary_keys && !scan_all_files && bucket < 
0 {
+                                counters.pruned_by_bucket += 1;
+                                return false;
+                            }
+                            if let Some(pred) = bucket_predicate {
+                                let targets =
+                                    
bucket_cache.entry(total_buckets).or_insert_with(|| {
+                                        compute_target_buckets(
+                                            pred,
+                                            bucket_key_fields,
+                                            bucket_function_type,
+                                            total_buckets,
+                                        )
+                                    });
+                                if let Some(targets) = targets {
+                                    if !targets.contains(&bucket) {
+                                        counters.pruned_by_bucket += 1;
+                                        return false;
+                                    }
                                 }
                             }
-                        }
 
-                        // Partition filter
-                        if let Some(pf) = partition_filter {
-                            match pf.matches_entry(partition_bytes) {
-                                Ok(false) => return false,
-                                Ok(true) => {}
-                                Err(_) => {}
+                            // Partition filter
+                            if let Some(pf) = partition_filter {
+                                match pf.matches_entry(partition_bytes) {
+                                    Ok(false) => {
+                                        counters.pruned_by_partition += 1;
+                                        return false;
+                                    }
+                                    Ok(true) => {}
+                                    Err(_) => {}
+                                }
                             }
-                        }
 
-                        true
-                    },
-                )?;
+                            true
+                        },
+                    )?;
+                    counters.after_entry_pruning = entries.len();
 
-                // Post-filter: level-0 and data predicates (need DataFileMeta)
-                let filtered: Vec<ManifestEntry> = entries
-                    .into_iter()
-                    .filter(|entry| {
+                    // Post-filter: level-0 and data predicates (need 
DataFileMeta)
+                    let mut filtered = Vec::with_capacity(entries.len());
+                    for entry in entries {
                         if skip_level_zero && has_primary_keys && 
entry.file().level == 0 {
-                            return false;
+                            counters.pruned_by_level += 1;
+                            continue;
                         }
                         if !data_predicates.is_empty()
                             && !data_file_matches_predicates(
@@ -189,20 +233,34 @@ async fn read_all_manifest_entries(
                                 schema_fields,
                             )
                         {
-                            return false;
+                            counters.pruned_by_data_stats += 1;
+                            continue;
                         }
-                        true
-                    })
-                    .collect();
-                Ok::<_, crate::Error>(filtered)
-            }
-        })
-        .buffered(64)
-        .try_collect::<Vec<_>>()
-        .await?
-        .into_iter()
-        .flatten()
-        .collect();
+                        filtered.push(entry);
+                    }
+                    counters.after_manifest_filters = filtered.len();
+                    Ok::<_, crate::Error>((filtered, counters))
+                }
+            })
+            .buffered(64)
+            .try_collect::<Vec<_>>()
+            .await?;
+
+    let mut counters = ManifestReadCounters::default();
+    let mut all_entries = Vec::new();
+    for (entries, manifest_counters) in manifest_results {
+        counters.merge(manifest_counters);
+        all_entries.extend(entries);
+    }
+    if let Some(trace) = trace {
+        trace.manifest_entries_read = counters.entries_read;
+        trace.manifest_entries_pruned_by_bucket = counters.pruned_by_bucket;
+        trace.manifest_entries_pruned_by_partition = 
counters.pruned_by_partition;
+        trace.manifest_entries_after_entry_pruning = 
counters.after_entry_pruning;
+        trace.manifest_entries_pruned_by_level = counters.pruned_by_level;
+        trace.manifest_entries_pruned_by_data_stats = 
counters.pruned_by_data_stats;
+        trace.manifest_entries_after_manifest_filters = 
counters.after_manifest_filters;
+    }
     Ok(all_entries)
 }
 
@@ -581,10 +639,32 @@ impl<'a> TableScan<'a> {
             Some(snapshot) => snapshot,
             None => return Ok(Plan::new(Vec::new())),
         };
-        self.plan_snapshot(snapshot, data_evolution_read_field_ids.as_ref())
+        self.plan_snapshot(snapshot, data_evolution_read_field_ids.as_ref(), 
None)
             .await
     }
 
+    /// Plan the full scan and return metadata-pruning trace counters.
+    pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
+        let data_evolution_read_field_ids = self.projected_read_field_ids()?;
+        let mut trace = ScanTrace {
+            limit: self.limit,
+            ..Default::default()
+        };
+        let snapshot = match self.resolve_snapshot().await? {
+            Some(snapshot) => snapshot,
+            None => return Ok((Plan::new(Vec::new()), trace)),
+        };
+        trace.snapshot_id = Some(snapshot.id());
+        let plan = self
+            .plan_snapshot(
+                snapshot,
+                data_evolution_read_field_ids.as_ref(),
+                Some(&mut trace),
+            )
+            .await?;
+        Ok((plan, trace))
+    }
+
     fn projected_read_field_ids(&self) -> crate::Result<Option<HashSet<i32>>> {
         super::read_builder::projected_read_field_ids(
             self.table.identifier().full_name(),
@@ -678,6 +758,14 @@ impl<'a> TableScan<'a> {
     pub(crate) async fn plan_manifest_entries(
         &self,
         snapshot: &Snapshot,
+    ) -> crate::Result<Vec<ManifestEntry>> {
+        self.plan_manifest_entries_with_trace(snapshot, None).await
+    }
+
+    async fn plan_manifest_entries_with_trace(
+        &self,
+        snapshot: &Snapshot,
+        mut trace: Option<&mut ScanTrace>,
     ) -> crate::Result<Vec<ManifestEntry>> {
         let file_io = self.table.file_io();
         let table_path = self.table.location();
@@ -750,9 +838,14 @@ impl<'a> TableScan<'a> {
             self.bucket_predicate.as_ref(),
             &bucket_key_fields,
             bucket_function_type,
+            trace.as_deref_mut(),
         )
         .await?;
-        Ok(merge_manifest_entries(entries))
+        let merged = merge_manifest_entries(entries);
+        if let Some(trace) = trace {
+            trace.manifest_entries_after_merge = merged.len();
+        }
+        Ok(merged)
     }
 
     fn can_push_down_limit_hint(&self, row_ranges: Option<&[RowRange]>) -> 
bool {
@@ -763,6 +856,7 @@ impl<'a> TableScan<'a> {
         &self,
         snapshot: Snapshot,
         data_evolution_read_field_ids: Option<&HashSet<i32>>,
+        mut trace: Option<&mut ScanTrace>,
     ) -> crate::Result<Plan> {
         let file_io = self.table.file_io();
         let table_path = self.table.location();
@@ -776,8 +870,13 @@ impl<'a> TableScan<'a> {
         let open_file_cost = core_options.source_split_open_file_cost();
         let partition_keys = self.table.schema().partition_keys();
 
-        let entries = self.plan_manifest_entries(&snapshot).await?;
+        let entries = self
+            .plan_manifest_entries_with_trace(&snapshot, trace.as_deref_mut())
+            .await?;
         if entries.is_empty() {
+            if let Some(trace) = trace {
+                trace.record_final_plan(0, 0, 0);
+            }
             return Ok(Plan::new(Vec::new()));
         }
 
@@ -791,8 +890,12 @@ impl<'a> TableScan<'a> {
                 .iter()
                 .any(|e| e.file().schema_id != current_schema_id);
             if !has_cross_schema {
+                if let Some(trace) = trace.as_deref_mut() {
+                    trace.manifest_entries_after_cross_schema_stats = 
entries.len();
+                }
                 entries
             } else {
+                let before = entries.len();
                 let mut kept = Vec::with_capacity(entries.len());
                 let mut schema_cache: HashMap<i64, 
Option<Arc<ResolvedStatsSchema>>> =
                     HashMap::new();
@@ -809,11 +912,22 @@ impl<'a> TableScan<'a> {
                         kept.push(entry);
                     }
                 }
+                if let Some(trace) = trace.as_deref_mut() {
+                    trace.manifest_entries_pruned_by_cross_schema_stats += 
before - kept.len();
+                    trace.manifest_entries_after_cross_schema_stats = 
kept.len();
+                }
                 kept
             }
         };
         if entries.is_empty() {
+            if let Some(trace) = trace {
+                trace.record_final_plan(0, 0, 0);
+            }
             return Ok(Plan::new(Vec::new()));
+        } else if let Some(trace) = trace.as_deref_mut() {
+            if trace.manifest_entries_after_cross_schema_stats == 0 {
+                trace.manifest_entries_after_cross_schema_stats = 
entries.len();
+            }
         }
 
         // Group by (partition, bucket), decomposing entries to avoid cloning 
partition.
@@ -913,7 +1027,6 @@ impl<'a> TableScan<'a> {
         let mut data_file_field_ids_cache = DataFileFieldIdsCache::new();
         for ((partition, bucket), (total_buckets, data_files)) in groups {
             let partition_row = BinaryRow::from_serialized_bytes(&partition)?;
-
             let bucket_path = if let Some(ref computer) = partition_computer {
                 let partition_path = 
computer.generate_partition_path(&partition_row)?;
                 format!("{base_path}/{partition_path}{}", 
bucket_dir_name(bucket))
@@ -931,12 +1044,16 @@ impl<'a> TableScan<'a> {
             // Apply group-level predicate filtering after grouping by row_id 
range.
             let file_groups: Vec<SplitGroup> = if data_evolution_enabled {
                 let row_id_groups = group_by_overlapping_row_id(data_files);
+                if let Some(trace) = trace.as_deref_mut() {
+                    trace.data_evolution_groups_before_stats += 
row_id_groups.len();
+                }
 
                 // Filter groups by merged stats before splitting.
                 let row_id_groups: Vec<Vec<DataFileMeta>> = if 
self.data_predicates.is_empty() {
                     row_id_groups
                 } else {
-                    row_id_groups
+                    let before = row_id_groups.len();
+                    let groups = row_id_groups
                         .into_iter()
                         .filter(|group| {
                             data_evolution_group_matches_predicates(
@@ -945,15 +1062,24 @@ impl<'a> TableScan<'a> {
                                 self.table.schema().fields(),
                             )
                         })
-                        .collect()
+                        .collect::<Vec<_>>();
+                    if let Some(trace) = trace.as_deref_mut() {
+                        trace.data_evolution_groups_pruned_by_stats += before 
- groups.len();
+                    }
+                    groups
                 };
 
                 // Filter groups by row ID ranges.
                 let row_id_groups = if let Some(ref ranges) = 
effective_row_ranges {
-                    row_id_groups
+                    let before = row_id_groups.len();
+                    let groups = row_id_groups
                         .into_iter()
                         .filter(|group| group.iter().any(|f| 
any_range_overlaps_file(ranges, f)))
-                        .collect()
+                        .collect::<Vec<_>>();
+                    if let Some(trace) = trace.as_deref_mut() {
+                        trace.data_evolution_groups_pruned_by_row_ranges += 
before - groups.len();
+                    }
+                    groups
                 } else {
                     row_id_groups
                 };
@@ -1086,11 +1212,16 @@ impl<'a> TableScan<'a> {
 
         // With data predicates or row_ranges, merged_row_count() reflects 
pre-filter
         // row counts, so stopping early could return fewer rows than the 
limit.
+        let splits_before_limit = splits.len();
         let splits = if 
self.can_push_down_limit_hint(effective_row_ranges.as_deref()) {
             self.apply_limit_pushdown(splits)
         } else {
             splits
         };
+        if let Some(trace) = trace {
+            let final_files = splits.iter().map(|split| 
split.data_files().len()).sum();
+            trace.record_final_plan(splits_before_limit, splits.len(), 
final_files);
+        }
 
         Ok(Plan::new(splits))
     }
@@ -1116,7 +1247,7 @@ mod tests {
         data_evolution_group_matches_predicates, data_file_matches_predicates,
         group_by_overlapping_row_id,
     };
-    use crate::table::Table;
+    use crate::table::{CommitMessage, Table, TableCommit};
     use crate::Error;
     use bytes::Bytes;
     use chrono::{DateTime, Utc};
@@ -1323,6 +1454,46 @@ mod tests {
         }
     }
 
+    fn scan_trace_test_table(table_path: &str) -> Table {
+        let file_io = FileIOBuilder::new("memory").build().unwrap();
+        let schema = PaimonSchema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .build()
+            .unwrap();
+        let table_schema = TableSchema::new(0, &schema);
+        Table::new(
+            file_io,
+            Identifier::new("test_db", "scan_trace"),
+            table_path.to_string(),
+            table_schema,
+            None,
+        )
+    }
+
+    async fn setup_scan_trace_dirs(table: &Table) {
+        table
+            .file_io()
+            .mkdirs(&format!("{}/snapshot/", table.location()))
+            .await
+            .unwrap();
+        table
+            .file_io()
+            .mkdirs(&format!("{}/manifest/", table.location()))
+            .await
+            .unwrap();
+    }
+
+    fn stats_trace_file(name: &str, min_id: i32, max_id: i32) -> DataFileMeta {
+        let mut file = test_data_file_meta(
+            int_stats_row(Some(min_id)),
+            int_stats_row(Some(max_id)),
+            vec![Some(0)],
+            2,
+        );
+        file.file_name = name.to_string();
+        file
+    }
+
     fn limit_test_table() -> Table {
         let file_io = FileIOBuilder::new("file").build().unwrap();
         let schema = PaimonSchema::builder().build().unwrap();
@@ -1795,6 +1966,45 @@ mod tests {
         ));
     }
 
+    #[tokio::test]
+    async fn test_plan_with_trace_records_between_data_stats_pruning() {
+        let table_path = 
"memory:/test_plan_with_trace_records_between_data_stats_pruning";
+        let table = scan_trace_test_table(table_path);
+        setup_scan_trace_dirs(&table).await;
+
+        TableCommit::new(table.clone(), "scan-trace-test".to_string())
+            .commit(vec![CommitMessage::new(
+                BinaryRowBuilder::new(0).build_serialized(),
+                0,
+                vec![
+                    stats_trace_file("stats-1.parquet", 1, 2),
+                    stats_trace_file("stats-2.parquet", 10, 20),
+                    stats_trace_file("stats-3.parquet", 100, 101),
+                ],
+            )])
+            .await
+            .unwrap();
+
+        let fields = int_field();
+        let pb = PredicateBuilder::new(&fields);
+        let between = Predicate::and(vec![
+            pb.greater_or_equal("id", Datum::Int(10)).unwrap(),
+            pb.less_or_equal("id", Datum::Int(20)).unwrap(),
+        ]);
+        let mut reader = table.new_read_builder();
+        reader.with_filter(between);
+        let (_plan, trace) = 
reader.new_scan().plan_with_trace().await.unwrap();
+
+        assert_eq!(
+            trace.final_files, 1,
+            "BETWEEN should keep only the overlapping stats range: {trace:?}"
+        );
+        assert!(
+            trace.manifest_entries_pruned_by_data_stats >= 2,
+            "BETWEEN should prune files outside the min/max range: {trace:?}"
+        );
+    }
+
     #[test]
     fn test_data_file_matches_is_null_prunes_when_null_count_is_zero() {
         let fields = int_field();


Reply via email to