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 ddc1a55 expose table observability API for pypaimon_rust (#307)
ddc1a55 is described below
commit ddc1a558146c8a38d14bad09b61604e3754c3e2e
Author: SeungMin <[email protected]>
AuthorDate: Mon Jul 13 14:15:23 2026 +0900
expose table observability API for pypaimon_rust (#307)
---
.../python/python/pypaimon_rust/datafusion.pyi | 50 ++++
bindings/python/src/context.rs | 3 +
bindings/python/src/lib.rs | 4 +
bindings/python/src/{lib.rs => partition.rs} | 45 ++--
bindings/python/src/{lib.rs => snapshot.rs} | 47 ++--
bindings/python/src/table.rs | 54 +++++
bindings/python/src/{lib.rs => tag.rs} | 33 ++-
bindings/python/tests/test_datafusion.py | 148 ++++++++++++
crates/paimon/src/table/mod.rs | 2 +
crates/paimon/src/table/partition_stat.rs | 269 +++++++++++++++++++++
10 files changed, 614 insertions(+), 41 deletions(-)
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index b0d9e4b..07586e6 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -70,12 +70,62 @@ class ReadBuilder:
def new_scan(self) -> TableScan: ...
def new_read(self) -> "TableRead": ...
+# ---- #285: observability ----
+class Snapshot:
+ def id(self) -> int: ...
+ def commit_time_ms(self) -> int: ...
+ def total_record_count(self) -> Optional[int]: ...
+ def delta_record_count(self) -> Optional[int]: ...
+ def commit_kind(self) -> str: ...
+
+class Tag:
+ def name(self) -> str: ...
+ def snapshot_id(self) -> int: ...
+
+class PartitionStat:
+ def partition(self) -> Dict[str, str]: ...
+ def record_count(self) -> int: ...
+ def file_count(self) -> int: ...
+ def total_size_bytes(self) -> int: ...
+
class Table:
def identifier(self) -> str: ...
def location(self) -> str: ...
def schema(self) -> TableSchema: ...
def new_read_builder(self, options: Optional[Dict[str, str]] = None) ->
ReadBuilder: ...
def new_write_builder(self) -> "WriteBuilder": ...
+ def latest_snapshot(self) -> Optional[Snapshot]:
+ """
+ Warning: This method blocks on a DataFusion runtime.
+ Calling this from an active asyncio event loop will result in a panic.
+ """
+ ...
+ def list_snapshots(self) -> List[Snapshot]:
+ """
+ Returns all snapshots ordered newest first (descending by ID).
+
+ Warning: This method blocks on a DataFusion runtime.
+ Calling this from an active asyncio event loop will result in a panic.
+ """
+ ...
+ def list_tags(self) -> List[Tag]:
+ """
+ Warning: This method blocks on a DataFusion runtime.
+ Calling this from an active asyncio event loop will result in a panic.
+ """
+ ...
+ def list_partitions(self) -> List[Dict[str, str]]:
+ """
+ Warning: This method blocks on a DataFusion runtime.
+ Calling this from an active asyncio event loop will result in a panic.
+ """
+ ...
+ def partition_stats(self) -> List[PartitionStat]:
+ """
+ Warning: This method blocks on a DataFusion runtime.
+ Calling this from an active asyncio event loop will result in a panic.
+ """
+ ...
class CommitMessage: ...
diff --git a/bindings/python/src/context.rs b/bindings/python/src/context.rs
index d92d8ee..b2f00f6 100644
--- a/bindings/python/src/context.rs
+++ b/bindings/python/src/context.rs
@@ -388,6 +388,9 @@ pub fn register_module(py: Python<'_>, m: &Bound<'_,
PyModule>) -> PyResult<()>
this.add_class::<crate::write::PyTableCommit>()?;
this.add_class::<crate::write::PyCommitMessage>()?;
this.add_function(wrap_pyfunction!(udf, &this)?)?;
+ this.add_class::<crate::snapshot::PySnapshot>()?;
+ this.add_class::<crate::tag::PyTag>()?;
+ this.add_class::<crate::partition::PyPartitionStat>()?;
m.add_submodule(&this)?;
py.import("sys")?
.getattr("modules")?
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs
index dd7177b..f256ff0 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/lib.rs
@@ -26,6 +26,10 @@ mod schema;
mod table;
mod udf;
mod write;
+// ---- #285: observability ----
+mod partition;
+mod snapshot;
+mod tag;
#[pymodule]
fn pypaimon_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/partition.rs
similarity index 54%
copy from bindings/python/src/lib.rs
copy to bindings/python/src/partition.rs
index dd7177b..f962443 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/partition.rs
@@ -15,20 +15,37 @@
// specific language governing permissions and limitations
// under the License.
+use std::collections::HashMap;
+
+use paimon::table::PartitionStat;
use pyo3::prelude::*;
-mod blob;
-mod context;
-mod error;
-mod predicate;
-mod read;
-mod schema;
-mod table;
-mod udf;
-mod write;
-
-#[pymodule]
-fn pypaimon_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
- context::register_module(py, m)?;
- Ok(())
+#[pyclass(name = "PartitionStat", module = "pypaimon_rust.datafusion")]
+pub struct PyPartitionStat {
+ inner: PartitionStat,
+}
+
+impl From<PartitionStat> for PyPartitionStat {
+ fn from(inner: PartitionStat) -> Self {
+ Self { inner }
+ }
+}
+
+#[pymethods]
+impl PyPartitionStat {
+ fn partition(&self) -> HashMap<String, String> {
+ self.inner.partition.clone()
+ }
+
+ fn record_count(&self) -> i64 {
+ self.inner.record_count
+ }
+
+ fn file_count(&self) -> u64 {
+ self.inner.file_count
+ }
+
+ fn total_size_bytes(&self) -> u64 {
+ self.inner.total_size_bytes
+ }
}
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/snapshot.rs
similarity index 54%
copy from bindings/python/src/lib.rs
copy to bindings/python/src/snapshot.rs
index dd7177b..5850518 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/snapshot.rs
@@ -15,20 +15,39 @@
// specific language governing permissions and limitations
// under the License.
+use paimon::spec::Snapshot;
use pyo3::prelude::*;
-mod blob;
-mod context;
-mod error;
-mod predicate;
-mod read;
-mod schema;
-mod table;
-mod udf;
-mod write;
-
-#[pymodule]
-fn pypaimon_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
- context::register_module(py, m)?;
- Ok(())
+#[pyclass(name = "Snapshot", module = "pypaimon_rust.datafusion")]
+pub struct PySnapshot {
+ inner: Snapshot,
+}
+
+impl PySnapshot {
+ pub fn new(inner: Snapshot) -> Self {
+ Self { inner }
+ }
+}
+
+#[pymethods]
+impl PySnapshot {
+ fn id(&self) -> i64 {
+ self.inner.id()
+ }
+
+ fn commit_time_ms(&self) -> u64 {
+ self.inner.time_millis()
+ }
+
+ fn total_record_count(&self) -> Option<i64> {
+ self.inner.total_record_count()
+ }
+
+ fn delta_record_count(&self) -> Option<i64> {
+ self.inner.delta_record_count()
+ }
+
+ fn commit_kind(&self) -> String {
+ self.inner.commit_kind().to_string()
+ }
}
diff --git a/bindings/python/src/table.rs b/bindings/python/src/table.rs
index a59ca8f..3fef611 100644
--- a/bindings/python/src/table.rs
+++ b/bindings/python/src/table.rs
@@ -15,13 +15,20 @@
// specific language governing permissions and limitations
// under the License.
+use std::collections::HashMap;
use std::sync::Arc;
+use paimon::table::{SnapshotManager, TagManager};
+use paimon_datafusion::runtime::runtime;
use pyo3::prelude::*;
use pyo3::types::PyDict;
+use crate::error::to_py_err;
+use crate::partition::PyPartitionStat;
use crate::read::PyReadBuilder;
use crate::schema::PyTableSchema;
+use crate::snapshot::PySnapshot;
+use crate::tag::PyTag;
use crate::write::PyWriteBuilder;
#[pyclass(name = "Table", module = "pypaimon_rust.datafusion")]
@@ -68,4 +75,51 @@ impl PyTable {
fn new_write_builder(&self) -> PyWriteBuilder {
PyWriteBuilder::new(Arc::clone(&self.inner))
}
+
+ // ---------------- #285: observability ----------------
+ fn latest_snapshot(&self) -> PyResult<Option<PySnapshot>> {
+ let sm = SnapshotManager::new(
+ self.inner.file_io().clone(),
+ self.inner.location().to_string(),
+ );
+ let snap = runtime()
+ .block_on(sm.get_latest_snapshot())
+ .map_err(to_py_err)?;
+ Ok(snap.map(PySnapshot::new))
+ }
+
+ fn list_snapshots(&self) -> PyResult<Vec<PySnapshot>> {
+ let sm = SnapshotManager::new(
+ self.inner.file_io().clone(),
+ self.inner.location().to_string(),
+ );
+ let snaps = runtime().block_on(sm.list_all()).map_err(to_py_err)?;
+ Ok(snaps.into_iter().rev().map(PySnapshot::new).collect())
+ }
+
+ fn list_tags(&self) -> PyResult<Vec<PyTag>> {
+ let tm = TagManager::new(
+ self.inner.file_io().clone(),
+ self.inner.location().to_string(),
+ );
+ let tags = runtime().block_on(tm.list_all()).map_err(to_py_err)?;
+ Ok(tags
+ .into_iter()
+ .map(|(name, snap)| PyTag::new(name, snap.id()))
+ .collect())
+ }
+
+ fn list_partitions(&self) -> PyResult<Vec<HashMap<String, String>>> {
+ let stats = runtime()
+ .block_on(self.inner.partition_stats())
+ .map_err(to_py_err)?;
+ Ok(stats.into_iter().map(|s| s.partition).collect())
+ }
+
+ fn partition_stats(&self) -> PyResult<Vec<PyPartitionStat>> {
+ let stats = runtime()
+ .block_on(self.inner.partition_stats())
+ .map_err(to_py_err)?;
+ Ok(stats.into_iter().map(PyPartitionStat::from).collect())
+ }
}
diff --git a/bindings/python/src/lib.rs b/bindings/python/src/tag.rs
similarity index 67%
copy from bindings/python/src/lib.rs
copy to bindings/python/src/tag.rs
index dd7177b..c679dd0 100644
--- a/bindings/python/src/lib.rs
+++ b/bindings/python/src/tag.rs
@@ -17,18 +17,25 @@
use pyo3::prelude::*;
-mod blob;
-mod context;
-mod error;
-mod predicate;
-mod read;
-mod schema;
-mod table;
-mod udf;
-mod write;
+#[pyclass(name = "Tag", module = "pypaimon_rust.datafusion")]
+pub struct PyTag {
+ name: String,
+ snapshot_id: i64,
+}
+
+impl PyTag {
+ pub fn new(name: String, snapshot_id: i64) -> Self {
+ Self { name, snapshot_id }
+ }
+}
+
+#[pymethods]
+impl PyTag {
+ fn name(&self) -> String {
+ self.name.clone()
+ }
-#[pymodule]
-fn pypaimon_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
- context::register_module(py, m)?;
- Ok(())
+ fn snapshot_id(&self) -> i64 {
+ self.snapshot_id
+ }
}
diff --git a/bindings/python/tests/test_datafusion.py
b/bindings/python/tests/test_datafusion.py
index e68698c..ca4b64a 100644
--- a/bindings/python/tests/test_datafusion.py
+++ b/bindings/python/tests/test_datafusion.py
@@ -868,3 +868,151 @@ def test_list_databases_and_tables():
assert "name" in field_names
# simple_log_table is non-partitioned, so partition keys are empty.
assert schema.partition_keys() == []
+
+# ---------------- #285: observability ----------------
+def test_snapshots_for_simple_table():
+ catalog = PaimonCatalog({"warehouse": WAREHOUSE})
+ table = catalog.get_table("default.simple_log_table")
+
+ snap = table.latest_snapshot()
+ assert snap is not None
+ assert snap.id() >= 1
+ assert snap.commit_time_ms() > 0
+ assert snap.commit_kind() in {"APPEND", "COMPACT", "OVERWRITE", "ANALYZE"}
+
+ snaps = table.list_snapshots()
+ assert len(snaps) >= 1
+ # Newest first.
+ assert snaps[0].id() == snap.id()
+
+
+def test_partitions_and_tags_smoke():
+ catalog = PaimonCatalog({"warehouse": WAREHOUSE})
+ table = catalog.get_table("default.simple_log_table")
+
+ # Non-partitioned, non-tagged table: both should be empty but well-typed.
+ parts = table.list_partitions()
+ stats = table.partition_stats()
+ tags = table.list_tags()
+
+ assert isinstance(parts, list)
+ assert isinstance(stats, list)
+ assert isinstance(tags, list)
+ # simple_log_table has no partition keys -> partition_stats yields a single
+ # empty-partition bucket or zero buckets depending on how the snapshot was
+ # written. Either is acceptable; we just check the shape.
+ for p in parts:
+ assert isinstance(p, dict)
+ for t in tags:
+ assert isinstance(t.name(), str)
+ assert isinstance(t.snapshot_id(), int)
+
+
+def test_partition_stats_with_partitioned_table():
+ """Validates partition_stats() on a real partitioned table created in a
+ temporary warehouse:
+ - list_partitions() returns the correct partition values
+ - partition_stats() returns correct record / file / size counts per
partition
+ This exercises PartitionComputer.generate_part_values and the per-partition
+ aggregation in aggregate_partition_stats.
+ """
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+
+ ctx.sql(
+ "CREATE TABLE paimon.default.events "
+ "(id INT, name STRING, dt STRING) "
+ "PARTITIONED BY (dt)"
+ )
+ ctx.sql(
+ "INSERT INTO paimon.default.events VALUES "
+ "(1, 'alice', '2024-01-01'), "
+ "(2, 'bob', '2024-01-01'), "
+ "(3, 'carol', '2024-01-02')"
+ )
+
+ catalog = PaimonCatalog({"warehouse": warehouse})
+ table = catalog.get_table("default.events")
+
+ # list_partitions() must return exactly the two inserted partitions.
+ parts = table.list_partitions()
+ assert len(parts) == 2
+ part_values = sorted(p["dt"] for p in parts)
+ assert part_values == ["2024-01-01", "2024-01-02"]
+
+ # partition_stats() must report concrete record / file / size counts.
+ stats = table.partition_stats()
+ assert len(stats) == 2
+
+ by_dt = {s.partition()["dt"]: s for s in stats}
+
+ s1 = by_dt["2024-01-01"]
+ assert s1.record_count() == 2
+ assert s1.file_count() >= 1
+ assert s1.total_size_bytes() > 0
+
+ s2 = by_dt["2024-01-02"]
+ assert s2.record_count() == 1
+ assert s2.file_count() >= 1
+ assert s2.total_size_bytes() > 0
+
+ ctx.sql("DROP TABLE paimon.default.events")
+
+
+def test_partition_stats_excludes_overwritten_partition():
+ """Validates the merge_active_entries (FileKind::Add / Delete sign-flip)
logic.
+
+ INSERT OVERWRITE on a specific partition replaces the old data files with
+ new ones. The old files become FileKind::Delete entries in the manifest, so
+ aggregate_partition_stats() must net them out correctly:
+ - The overwritten partition reflects the NEW row count, not the old one.
+ - A partition that is overwritten with zero rows must not appear in the
+ output (file_count nets to 0, triggering the `<= 0` guard).
+ """
+ with tempfile.TemporaryDirectory() as warehouse:
+ ctx = SQLContext()
+ ctx.register_catalog("paimon", {"warehouse": warehouse})
+
+ ctx.sql(
+ "CREATE TABLE paimon.default.events "
+ "(id INT, name STRING, dt STRING) "
+ "PARTITIONED BY (dt)"
+ )
+ # Initial state: 2 rows in 2024-01-01, 1 row in 2024-01-02.
+ ctx.sql(
+ "INSERT INTO paimon.default.events VALUES "
+ "(1, 'alice', '2024-01-01'), "
+ "(2, 'bob', '2024-01-01'), "
+ "(3, 'carol', '2024-01-02')"
+ )
+
+ # Overwrite 2024-01-01 with a single new row.
+ # This generates FileKind::Delete entries for the old files of that
+ # partition and a FileKind::Add entry for the new file.
+ ctx.sql(
+ "INSERT OVERWRITE paimon.default.events "
+ "VALUES (10, 'dave', '2024-01-01')"
+ )
+
+ catalog = PaimonCatalog({"warehouse": warehouse})
+ table = catalog.get_table("default.events")
+
+ # Both partitions must still be present.
+ parts = table.list_partitions()
+ part_values = sorted(p["dt"] for p in parts)
+ assert part_values == ["2024-01-01", "2024-01-02"]
+
+ stats = table.partition_stats()
+ by_dt = {s.partition()["dt"]: s for s in stats}
+
+ # 2024-01-01: overwritten to 1 row — old Delete entries must be
+ # netted out so record_count reflects only the new file.
+ s1 = by_dt["2024-01-01"]
+ assert s1.record_count() == 1
+
+ # 2024-01-02: untouched.
+ s2 = by_dt["2024-01-02"]
+ assert s2.record_count() == 1
+
+ ctx.sql("DROP TABLE paimon.default.events")
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 5a7030a..617c1fb 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -54,6 +54,7 @@ mod kv_file_writer;
mod lumina_index_build_builder;
pub(crate) mod merge_tree_split_generator;
mod partition_filter;
+mod partition_stat;
mod postpone_file_writer;
mod prepared_files;
mod read_builder;
@@ -100,6 +101,7 @@ pub use incremental_scan::{
IncrementalPlan, IncrementalScan, IncrementalScanMode, IncrementalSplit,
};
pub use lumina_index_build_builder::LuminaIndexBuildBuilder;
+pub use partition_stat::PartitionStat;
pub use read_builder::ReadBuilder;
pub use rest_env::RESTEnv;
pub use scan_trace::ScanTrace;
diff --git a/crates/paimon/src/table/partition_stat.rs
b/crates/paimon/src/table/partition_stat.rs
new file mode 100644
index 0000000..9d78b58
--- /dev/null
+++ b/crates/paimon/src/table/partition_stat.rs
@@ -0,0 +1,269 @@
+// 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.
+
+//! Per-partition statistics computed by scanning the latest snapshot's
manifest entries.
+//!
+//! Mirrors what Java Paimon exposes via the `$partitions` system table for
runtime introspection.
+
+use std::collections::{BTreeMap, HashMap};
+
+use crate::io::FileIO;
+use crate::spec::{
+ avro::from_avro_bytes_fast, merge_active_entries, BinaryRow, CoreOptions,
ManifestEntry,
+ ManifestFileMeta, PartitionComputer, Snapshot,
+};
+use crate::table::SnapshotManager;
+use crate::table::Table;
+
+const MANIFEST_DIR: &str = "manifest";
+
+/// Per-partition aggregated statistics.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PartitionStat {
+ /// Partition key/value mapping (e.g. `{"dt": "2024-01-01", "hr": "10"}`).
+ pub partition: HashMap<String, String>,
+ /// Total record count across all live data files.
+ pub record_count: i64,
+ /// Live data file count.
+ pub file_count: u64,
+ /// Total bytes for live data files.
+ pub total_size_bytes: u64,
+}
+
+#[derive(Default)]
+struct Accum {
+ record_count: i64,
+ file_count: i64,
+ total_size_bytes: i64,
+}
+
+impl Table {
+ /// Compute per-partition statistics from the latest snapshot.
+ ///
+ /// Mirrors [`catalog::list_partitions_from_file_system`]: calls
+ /// `merge_active_entries` first to deduplicate entries that appear in both
+ /// the base and delta manifests, then aggregates the remaining live ADD
+ /// files in a `BTreeMap` for a deterministic result order.
+ ///
+ /// **Warning:** This method reads all manifest lists and entries from the
latest snapshot.
+ /// For tables with a large number of manifests, this operation can be
expensive.
+ ///
+ /// Returns an empty Vec when the table has no snapshots yet.
+ pub async fn partition_stats(&self) -> crate::Result<Vec<PartitionStat>> {
+ let sm = SnapshotManager::new(self.file_io().clone(),
self.location().to_string());
+ let snapshot = match sm.get_latest_snapshot().await? {
+ Some(s) => s,
+ None => return Ok(Vec::new()),
+ };
+
+ let entries = read_all_manifest_entries(self.file_io(),
self.location(), &snapshot).await?;
+
+ let schema = self.schema();
+ let core = CoreOptions::new(schema.options());
+ let computer = PartitionComputer::new(
+ schema.partition_keys(),
+ schema.fields(),
+ core.partition_default_name(),
+ core.legacy_partition_name(),
+ )?;
+
+ aggregate_partition_stats(entries, &computer)
+ }
+
+ /// List all partition values present in the latest snapshot.
+ ///
+ /// **Warning:** This method computes partition statistics which reads all
manifest lists
+ /// and entries. For large tables, this operation can be expensive.
+ ///
+ /// Returns an empty Vec when the table has no snapshots yet.
+ pub async fn list_partitions(&self) -> crate::Result<Vec<HashMap<String,
String>>> {
+ Ok(self
+ .partition_stats()
+ .await?
+ .into_iter()
+ .map(|s| s.partition)
+ .collect())
+ }
+}
+
+async fn read_manifest_list(
+ file_io: &FileIO,
+ table_path: &str,
+ list_name: &str,
+) -> crate::Result<Vec<ManifestFileMeta>> {
+ if list_name.is_empty() {
+ return Ok(Vec::new());
+ }
+ let path = format!(
+ "{}/{}/{}",
+ table_path.trim_end_matches('/'),
+ MANIFEST_DIR,
+ list_name
+ );
+ let input = file_io.new_input(&path)?;
+ let bytes = input.read().await?;
+ from_avro_bytes_fast::<ManifestFileMeta>(&bytes)
+}
+
+async fn read_all_manifest_entries(
+ file_io: &FileIO,
+ table_path: &str,
+ snapshot: &Snapshot,
+) -> crate::Result<Vec<ManifestEntry>> {
+ let mut metas = read_manifest_list(file_io, table_path,
snapshot.base_manifest_list()).await?;
+ metas.extend(read_manifest_list(file_io, table_path,
snapshot.delta_manifest_list()).await?);
+
+ let manifest_dir = format!("{}/{}", table_path.trim_end_matches('/'),
MANIFEST_DIR);
+ let mut all_entries = Vec::new();
+ for meta in metas {
+ let path = format!("{}/{}", manifest_dir, meta.file_name());
+ let input = file_io.new_input(&path)?;
+ let bytes = input.read().await?;
+ let entries = from_avro_bytes_fast::<ManifestEntry>(&bytes)?;
+ all_entries.extend(entries);
+ }
+ Ok(all_entries)
+}
+
+/// Aggregate live manifest entries into per-partition statistics.
+///
+/// Mirrors `catalog::list_partitions_from_file_system`:
+/// 1. Call `merge_active_entries` to collapse ADD/DELETE pairs and remove
+/// entries shadowed by a later DELETE, including duplicate ADD entries
+/// that may appear in both base and delta manifests after compaction.
+/// 2. Accumulate the remaining live ADD entries in a `BTreeMap` keyed by
+/// raw partition bytes for a deterministic, sorted result order.
+fn aggregate_partition_stats(
+ entries: Vec<ManifestEntry>,
+ computer: &PartitionComputer,
+) -> crate::Result<Vec<PartitionStat>> {
+ // Step 1: deduplicate — collapse ADD/DELETE pairs and drop files that have
+ // been deleted. After this point every remaining entry is a live ADD.
+ let live_entries = merge_active_entries(entries);
+
+ // Step 2: accumulate into a BTreeMap for deterministic partition ordering.
+ let mut grouped: BTreeMap<Vec<u8>, Accum> = BTreeMap::new();
+ for entry in &live_entries {
+ let file = entry.file();
+ let accum = grouped.entry(entry.partition().to_vec()).or_default();
+ accum.record_count += file.row_count;
+ accum.file_count += 1;
+ accum.total_size_bytes += file.file_size;
+ }
+
+ let mut out = Vec::with_capacity(grouped.len());
+ for (partition_bytes, accum) in grouped {
+ let partition = if partition_bytes.is_empty() {
+ HashMap::new()
+ } else {
+ let row = BinaryRow::from_serialized_bytes(&partition_bytes)?;
+ computer.generate_part_values(&row)?.into_iter().collect()
+ };
+ out.push(PartitionStat {
+ partition,
+ record_count: accum.record_count,
+ file_count: accum.file_count as u64,
+ total_size_bytes: accum.total_size_bytes as u64,
+ });
+ }
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::spec::stats::BinaryTableStats;
+ use crate::spec::{DataFileMeta, FileKind, ManifestEntry};
+
+ /// Build a minimal synthetic ManifestEntry for unit testing.
+ /// Mirrors the helper used in `spec::manifest` tests.
+ fn make_entry(
+ kind: FileKind,
+ partition: Vec<u8>,
+ file_name: &str,
+ row_count: i64,
+ ) -> ManifestEntry {
+ let stats = BinaryTableStats::empty();
+ let file = DataFileMeta {
+ file_name: file_name.to_string(),
+ file_size: row_count * 100,
+ row_count,
+ min_key: vec![],
+ max_key: vec![],
+ key_stats: stats.clone(),
+ value_stats: stats,
+ min_sequence_number: 0,
+ max_sequence_number: 0,
+ schema_id: 0,
+ level: 0,
+ extra_files: vec![],
+ creation_time: None,
+ delete_row_count: None,
+ embedded_index: None,
+ file_source: None,
+ value_stats_cols: None,
+ external_path: None,
+ first_row_id: None,
+ write_cols: None,
+ };
+ ManifestEntry::new(kind, partition, 0, 1, file, 2)
+ }
+
+ /// Duplicate ADD entries (same file appearing in both base and delta
+ /// manifests after compaction) must not be double-counted.
+ /// `merge_active_entries` collapses identical identifiers to one.
+ #[test]
+ fn test_duplicate_add_entries_are_not_double_counted() {
+ let computer =
+ PartitionComputer::new(&[] as &[String], &[], "__DEFAULT_PT__",
false).unwrap();
+
+ // Same file_name / level appears twice (base manifest + delta
manifest).
+ let entries = vec![
+ make_entry(FileKind::Add, vec![], "file-001.parquet", 10),
+ make_entry(FileKind::Add, vec![], "file-001.parquet", 10), //
duplicate
+ ];
+
+ let stats = aggregate_partition_stats(entries, &computer).unwrap();
+
+ // Must produce exactly 1 entry with record_count == 10, not 20.
+ assert_eq!(stats.len(), 1, "expected 1 partition entry");
+ assert_eq!(
+ stats[0].record_count, 10,
+ "duplicate ADD must be collapsed to a single count, not
double-counted"
+ );
+ assert_eq!(stats[0].file_count, 1);
+ }
+
+ /// A DELETE entry that follows an ADD for the same file must cancel it
+ /// out completely; the partition must not appear in the result.
+ #[test]
+ fn test_add_then_delete_removes_partition() {
+ let computer =
+ PartitionComputer::new(&[] as &[String], &[], "__DEFAULT_PT__",
false).unwrap();
+
+ let entries = vec![
+ make_entry(FileKind::Add, vec![], "file-002.parquet", 5),
+ make_entry(FileKind::Delete, vec![], "file-002.parquet", 5),
+ ];
+
+ let stats = aggregate_partition_stats(entries, &computer).unwrap();
+ assert!(
+ stats.is_empty(),
+ "partition should be gone after its only file is deleted"
+ );
+ }
+}