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 4e6c25ad [core] Support filesystem partitions in SystemRead (#1138)
4e6c25ad is described below

commit 4e6c25ad330e259c4a2f578f2a30292df788acc3
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 16 20:28:06 2026 +0800

    [core] Support filesystem partitions in SystemRead (#1138)
---
 .../datafusion/src/system_tables/partitions.rs     |  22 +-
 .../datafusion/tests/sql_context_tests.rs          |  12 +-
 .../integrations/datafusion/tests/system_tables.rs |  70 ++-
 crates/paimon/src/arrow/cast_text.rs               |   2 +-
 crates/paimon/src/arrow/evolution_string.rs        |  14 +-
 crates/paimon/src/arrow/mod.rs                     |   1 +
 crates/paimon/src/catalog/mod.rs                   |   3 +
 crates/paimon/src/catalog/partition_listing.rs     | 226 +++++---
 crates/paimon/src/common/date_time.rs              |  17 +
 crates/paimon/src/spec/mod.rs                      |   1 +
 crates/paimon/src/spec/partition_utils.rs          |   2 +-
 crates/paimon/src/table/partition_stat.rs          | 137 ++---
 crates/paimon/src/table/system_table/mod.rs        |  11 +-
 crates/paimon/src/table/system_table/partitions.rs | 626 +++++++++++++++++++++
 .../src/table/system_table/row_string_cast.rs      | 147 +++--
 crates/paimon/tests/partition_system_read_test.rs  | 221 ++++++++
 16 files changed, 1269 insertions(+), 243 deletions(-)

diff --git a/crates/integrations/datafusion/src/system_tables/partitions.rs 
b/crates/integrations/datafusion/src/system_tables/partitions.rs
index b87db4f8..b2313ffc 100644
--- a/crates/integrations/datafusion/src/system_tables/partitions.rs
+++ b/crates/integrations/datafusion/src/system_tables/partitions.rs
@@ -22,7 +22,8 @@ use std::sync::{Arc, OnceLock};
 
 use async_trait::async_trait;
 use datafusion::arrow::array::{
-    BooleanArray, Int32Array, Int64Array, RecordBatch, StringArray, 
TimestampMillisecondArray,
+    ArrayRef, BooleanArray, Int32Array, Int64Array, RecordBatch, StringArray,
+    TimestampMillisecondArray,
 };
 use datafusion::arrow::datatypes::{DataType, Field, Schema, SchemaRef, 
TimeUnit};
 use datafusion::catalog::Session;
@@ -32,7 +33,7 @@ use datafusion::error::{DataFusionError, Result as DFResult};
 use datafusion::logical_expr::Expr;
 use datafusion::physical_plan::ExecutionPlan;
 use paimon::catalog::{list_partitions_from_file_system, Catalog, Identifier};
-use paimon::spec::{CoreOptions, Partition};
+use paimon::spec::{CoreOptions, LocalZonedTimestampType, Partition, 
TimestampType};
 use paimon::table::Table;
 
 use crate::error::to_datafusion_error;
@@ -216,8 +217,8 @@ impl TableProvider for PartitionsTable {
                 Arc::new(Int64Array::from(record_counts)),
                 Arc::new(Int64Array::from(file_sizes)),
                 Arc::new(Int64Array::from(file_counts)),
-                Arc::new(TimestampMillisecondArray::from(last_update_times)),
-                Arc::new(TimestampMillisecondArray::from(created_ats)),
+                local_timestamp_column(last_update_times)?,
+                local_timestamp_column(created_ats)?,
                 Arc::new(StringArray::from(created_bys)),
                 Arc::new(StringArray::from(updated_bys)),
                 Arc::new(StringArray::from(options_jsons)),
@@ -234,6 +235,19 @@ impl TableProvider for PartitionsTable {
     }
 }
 
+fn local_timestamp_column(values: Vec<Option<i64>>) -> DFResult<ArrayRef> {
+    // Partition metadata stores epoch instants. Java 
PartitionsTable.toTimestamp
+    // converts these to the system-local wall clock for its TIMESTAMP columns.
+    let source = paimon::spec::DataType::LocalZonedTimestamp(
+        LocalZonedTimestampType::new(3).map_err(to_datafusion_error)?,
+    );
+    let target =
+        
paimon::spec::DataType::Timestamp(TimestampType::new(3).map_err(to_datafusion_error)?);
+    let values: ArrayRef = Arc::new(TimestampMillisecondArray::from(values));
+    paimon::arrow::cast_array_for_schema_evolution(&values, &source, &target)
+        .map_err(to_datafusion_error)
+}
+
 /// Format `spec` as `key1=val1/key2=val2` in `partition_keys` order. Empty
 /// string for non-partitioned tables. NULL spec values fall back to
 /// `default_partition_name`.
diff --git a/crates/integrations/datafusion/tests/sql_context_tests.rs 
b/crates/integrations/datafusion/tests/sql_context_tests.rs
index b4ddf07c..325048a0 100644
--- a/crates/integrations/datafusion/tests/sql_context_tests.rs
+++ b/crates/integrations/datafusion/tests/sql_context_tests.rs
@@ -629,7 +629,8 @@ async fn 
test_partitions_system_table_shows_unreported_statistics_as_null() {
     sql_context
         .sql(
             "CREATE TABLE paimon.default.unknown_stats_orders \
-             (id INT, name STRING) PARTITIONED BY (id)",
+             (id INT, name STRING) PARTITIONED BY (id) \
+             WITH ('bucket' = '4', 'bucket-key' = 'name')",
         )
         .await
         .unwrap()
@@ -658,6 +659,15 @@ async fn 
test_partitions_system_table_shows_unreported_statistics_as_null() {
             "column {column} should carry a measurement before the switch"
         );
     }
+    assert_eq!(
+        measured
+            .column(3)
+            .as_any()
+            .downcast_ref::<datafusion::arrow::array::Int32Array>()
+            .unwrap()
+            .value(0),
+        4
+    );
 
     catalog.set_unknown_statistics(true);
 
diff --git a/crates/integrations/datafusion/tests/system_tables.rs 
b/crates/integrations/datafusion/tests/system_tables.rs
index 48f299bc..717f9baa 100644
--- a/crates/integrations/datafusion/tests/system_tables.rs
+++ b/crates/integrations/datafusion/tests/system_tables.rs
@@ -1409,6 +1409,63 @@ fn single_int_partition_stat(value: &str) -> i32 {
         .expect("partition stats should contain one int partition value")
 }
 
+#[tokio::test]
+async fn test_partition_timestamps_match_local_file_creation_times() {
+    let (ctx, catalog, _tmp) = create_context().await;
+    let table = catalog
+        .get_table(&Identifier::new("default", FIXTURE_TABLE))
+        .await
+        .unwrap();
+    let core = table
+        .new_system_read("partitions")
+        .unwrap()
+        .read()
+        .await
+        .unwrap();
+    let sql = format!("SELECT last_update_time FROM 
paimon.default.{FIXTURE_TABLE}$partitions");
+    let batches = run_sql(&ctx, &sql).await;
+    let actual = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<TimestampMillisecondArray>()
+                .unwrap()
+                .iter()
+        })
+        .collect::<Vec<_>>();
+    let expected = core
+        .column_by_name("last_update_time")
+        .unwrap()
+        .as_any()
+        .downcast_ref::<TimestampMillisecondArray>()
+        .unwrap()
+        .iter()
+        .collect::<Vec<_>>();
+    assert!(!expected.is_empty());
+    assert_eq!(actual, expected);
+    let files = run_sql(
+        &ctx,
+        &format!("SELECT creation_time FROM 
paimon.default.{FIXTURE_TABLE}$files"),
+    )
+    .await;
+    let last_file = files
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<TimestampMillisecondArray>()
+                .unwrap()
+                .iter()
+                .flatten()
+        })
+        .max()
+        .unwrap();
+    assert_eq!(actual, vec![Some(last_file)]);
+}
+
 #[tokio::test]
 async fn test_partitions_system_table() {
     let (ctx, _catalog, _tmp) = create_context().await;
@@ -1790,6 +1847,7 @@ async fn 
test_resolved_system_table_reader_preserves_guards_and_schema() {
         "consumers",
         "file_key_ranges",
         "table_indexes",
+        "partitions",
     ] {
         let stream = read_metadata(table.clone(), name).await.unwrap();
         let schema = table.new_system_read(name).unwrap().schema();
@@ -1800,13 +1858,11 @@ async fn 
test_resolved_system_table_reader_preserves_guards_and_schema() {
             "{name}"
         );
     }
-    for name in ["partitions", "unknown"] {
-        let error = match read_metadata(table.clone(), name).await {
-            Ok(_) => panic!("{name} must require a catalog or be unknown"),
-            Err(error) => error.to_string(),
-        };
-        assert!(error.contains("requires a catalog or is unknown"));
-    }
+    let error = match read_metadata(table.clone(), "unknown").await {
+        Ok(_) => panic!("Unknown system table must be rejected"),
+        Err(error) => error.to_string(),
+    };
+    assert!(error.contains("requires a catalog or is unknown"));
     let denied = table.copy_with_options(std::collections::HashMap::from([(
         "query-auth.enabled".to_string(),
         "true".to_string(),
diff --git a/crates/paimon/src/arrow/cast_text.rs 
b/crates/paimon/src/arrow/cast_text.rs
index 424ec7cb..eeb45e48 100644
--- a/crates/paimon/src/arrow/cast_text.rs
+++ b/crates/paimon/src/arrow/cast_text.rs
@@ -139,7 +139,7 @@ fn time_components(text: &str, space: bool) -> 
Option<[Option<i32>; 4]> {
     Some(time)
 }
 
-pub(super) fn parse_timestamp(
+pub(crate) fn parse_timestamp(
     text: &str,
     precision: u32,
     zone: Option<&jiff::tz::TimeZone>,
diff --git a/crates/paimon/src/arrow/evolution_string.rs 
b/crates/paimon/src/arrow/evolution_string.rs
index 533cd90c..d000877b 100644
--- a/crates/paimon/src/arrow/evolution_string.rs
+++ b/crates/paimon/src/arrow/evolution_string.rs
@@ -23,7 +23,7 @@ use arrow_array::{cast::AsArray, types::Int64Type, ArrayRef, 
StringArray, Time32
 use arrow_schema::{DataType as ArrowType, TimeUnit};
 use jiff::tz::TimeZone;
 
-use crate::common::date_time::{format_epoch_timestamp, format_time, 
local_offset};
+use crate::common::date_time::{format_time, format_timestamp_millis};
 use crate::spec::DataType;
 
 /// Return None when this is not a temporal-to-character conversion.
@@ -112,17 +112,7 @@ fn render_timestamp(
         TimeUnit::Nanosecond => value.rem_euclid(1_000_000),
         _ => 0,
     };
-    let millis = match zone {
-        Some(zone) => {
-            let offset = local_offset(millis.div_euclid(1000), zone);
-            // Timestamp.fromLocalDateTime uses signed long millisecond 
storage.
-            // Applying an offset can wrap and change the millisecond fraction.
-            millis.wrapping_add(i64::from(offset) * 1000)
-        }
-        None => millis,
-    };
-    let nanos = (millis.rem_euclid(1000) * 1_000_000 + sub_milli) as u32;
-    format_epoch_timestamp(millis.div_euclid(1000), nanos, precision)
+    format_timestamp_millis(millis, sub_milli, precision, zone)
 }
 
 fn invalid(message: impl Into<String>) -> crate::Error {
diff --git a/crates/paimon/src/arrow/mod.rs b/crates/paimon/src/arrow/mod.rs
index a7119809..7a057c0a 100644
--- a/crates/paimon/src/arrow/mod.rs
+++ b/crates/paimon/src/arrow/mod.rs
@@ -24,6 +24,7 @@ pub(crate) use cast_executor::CastExecutor;
 #[cfg(test)]
 mod cast_completion_java_test;
 mod cast_text;
+pub(crate) use cast_text::parse_timestamp as parse_java_timestamp;
 mod evolution_cast;
 pub(crate) use evolution_cast::{cast_numeric, supports_numeric_cast};
 mod evolution_decimal;
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 20a13da0..5c48f454 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -36,7 +36,10 @@ pub use database::*;
 pub use factory::*;
 pub use filesystem::*;
 pub use function::*;
+#[cfg(test)]
+pub(crate) use partition_listing::aggregate_partition_entries;
 pub use partition_listing::list_partitions_from_file_system;
+pub(crate) use partition_listing::{list_partition_entries, 
FileSystemPartitionEntry};
 pub use rest::*;
 use serde::{Deserialize, Serialize};
 pub use view::*;
diff --git a/crates/paimon/src/catalog/partition_listing.rs 
b/crates/paimon/src/catalog/partition_listing.rs
index dec7702b..5557cf3b 100644
--- a/crates/paimon/src/catalog/partition_listing.rs
+++ b/crates/paimon/src/catalog/partition_listing.rs
@@ -20,43 +20,18 @@
 //! Used as the catalog-side fallback when a backend doesn't track partitions
 //! (e.g. `FileSystemCatalog`, or `RESTCatalog` against a non-metastore 
server).
 
-use std::collections::{BTreeMap, HashMap};
+use std::collections::BTreeMap;
 
 use crate::spec::{
-    merge_active_entries, BinaryRow, CoreOptions, Manifest, ManifestList, 
Partition,
+    merge_active_entries, BinaryRow, CoreOptions, Manifest, ManifestEntry, 
ManifestList, Partition,
     PartitionComputer,
 };
-use crate::table::{SnapshotManager, Table};
+use crate::table::Table;
 use crate::Result;
 
 /// Scan a table's manifest entries and aggregate them into [`Partition`] rows,
 /// matching the shape catalogs would otherwise return from a metastore.
 pub async fn list_partitions_from_file_system(table: &Table) -> 
Result<Vec<Partition>> {
-    // Manifests carry partition values and per-column stats.
-    
crate::spec::CoreOptions::new(table.schema().options()).ensure_read_authorized()?;
-    let file_io = table.file_io();
-    let manifest_sm = SnapshotManager::new(file_io.clone(), 
table.location().to_string());
-    let Some(snapshot) = table.resolve_read_snapshot().await? else {
-        return Ok(Vec::new());
-    };
-
-    let base_path = manifest_sm.manifest_path(snapshot.base_manifest_list());
-    let delta_path = manifest_sm.manifest_path(snapshot.delta_manifest_list());
-    let (base_metas, delta_metas) = futures::try_join!(
-        ManifestList::read(file_io, &base_path),
-        ManifestList::read(file_io, &delta_path),
-    )?;
-
-    let mut all_entries = Vec::new();
-    for meta in base_metas.into_iter().chain(delta_metas) {
-        let manifest_path = manifest_sm.manifest_path(meta.file_name());
-        let entries = Manifest::read(file_io, &manifest_path).await?;
-        all_entries.extend(entries);
-    }
-    // Mirror Java AbstractFileStoreScan.readAndMergeFileEntries: drop entries
-    // shadowed by a later DELETE so file_count/record_count reflect live 
files.
-    let all_entries = merge_active_entries(all_entries);
-
     let schema = table.schema();
     let core = CoreOptions::new(schema.options());
     let computer = PartitionComputer::new(
@@ -65,56 +40,175 @@ pub async fn list_partitions_from_file_system(table: 
&Table) -> Result<Vec<Parti
         core.partition_default_name(),
         core.legacy_partition_name(),
     )?;
+    list_partition_entries(table)
+        .await?
+        .into_iter()
+        .map(|entry| {
+            let spec = computer
+                .generate_part_values(&entry.partition)?
+                .into_iter()
+                .collect();
+            Ok(Partition {
+                spec,
+                record_count: entry.record_count,
+                file_size_in_bytes: entry.file_size_in_bytes,
+                file_count: entry.file_count,
+                last_file_creation_time: 
entry.last_file_creation_time.unwrap_or(0),
+                total_buckets: entry.total_buckets,
+                done: false,
+                created_at: None,
+                created_by: None,
+                updated_at: None,
+                updated_by: None,
+                options: None,
+            })
+        })
+        .collect()
+}
+
+/// Typed file statistics shared by filesystem catalog listings and metadata 
reads.
+#[derive(Debug)]
+pub(crate) struct FileSystemPartitionEntry {
+    pub partition: BinaryRow,
+    pub record_count: i64,
+    pub file_size_in_bytes: i64,
+    pub file_count: i64,
+    // Epoch instant, matching Java DataFileMeta.creationTimeEpochMillis.
+    pub last_file_creation_time: Option<i64>,
+    pub total_buckets: i32,
+}
 
-    #[derive(Default)]
-    struct Agg {
-        record_count: i64,
-        file_size: i64,
-        file_count: i64,
-        last_file_creation_time: i64,
+impl Default for FileSystemPartitionEntry {
+    fn default() -> Self {
+        Self {
+            partition: BinaryRow::from_datums(&[]),
+            record_count: 0,
+            file_size_in_bytes: 0,
+            file_count: 0,
+            last_file_creation_time: None,
+            total_buckets: 0,
+        }
     }
-    let mut buckets: BTreeMap<Vec<u8>, Agg> = BTreeMap::new();
-    for entry in &all_entries {
+}
+
+pub(crate) async fn list_partition_entries(table: &Table) -> 
Result<Vec<FileSystemPartitionEntry>> {
+    CoreOptions::new(table.schema().options()).ensure_read_authorized()?;
+    let Some(snapshot) = table.resolve_read_snapshot().await? else {
+        return Ok(Vec::new());
+    };
+    let file_io = table.file_io();
+    let manager = table.snapshot_manager();
+    let base_path = manager.manifest_path(snapshot.base_manifest_list());
+    let delta_path = manager.manifest_path(snapshot.delta_manifest_list());
+    let (base, delta) = futures::try_join!(
+        ManifestList::read(file_io, &base_path),
+        ManifestList::read(file_io, &delta_path),
+    )?;
+    let mut entries = Vec::new();
+    for meta in base.into_iter().chain(delta) {
+        entries.extend(Manifest::read(file_io, 
&manager.manifest_path(meta.file_name())).await?);
+    }
+    aggregate_partition_entries(entries)
+}
+
+pub(crate) fn aggregate_partition_entries(
+    entries: Vec<ManifestEntry>,
+) -> Result<Vec<FileSystemPartitionEntry>> {
+    aggregate_partition_entries_in_zone(entries, &jiff::tz::TimeZone::system())
+}
+
+fn aggregate_partition_entries_in_zone(
+    entries: Vec<ManifestEntry>,
+    zone: &jiff::tz::TimeZone,
+) -> Result<Vec<FileSystemPartitionEntry>> {
+    let mut grouped: BTreeMap<Vec<u8>, FileSystemPartitionEntry> = 
BTreeMap::new();
+    for entry in merge_active_entries(entries) {
         let file = entry.file();
-        let agg = buckets.entry(entry.partition().to_vec()).or_default();
-        agg.record_count += file.row_count;
-        agg.file_size += file.file_size;
+        let agg = grouped.entry(entry.partition().to_vec()).or_default();
+        agg.record_count = agg.record_count.wrapping_add(file.row_count);
+        agg.file_size_in_bytes = 
agg.file_size_in_bytes.wrapping_add(file.file_size);
         agg.file_count += 1;
-        if let Some(ct) = file.creation_time {
-            agg.last_file_creation_time = 
agg.last_file_creation_time.max(ct.timestamp_millis());
+        agg.total_buckets = entry.total_buckets();
+        if let Some(time) = file.creation_time {
+            let millis = time.timestamp_millis();
+            let time =
+                
crate::common::date_time::local_to_utc_seconds(millis.div_euclid(1000), zone)?
+                    * 1000
+                    + millis.rem_euclid(1000);
+            agg.last_file_creation_time = Some(
+                agg.last_file_creation_time
+                    .map_or(time, |old| old.max(time)),
+            );
         }
     }
-
-    let mut result = Vec::with_capacity(buckets.len());
-    for (bytes, agg) in buckets {
-        let spec = if bytes.is_empty() {
-            HashMap::new()
-        } else {
-            let row = BinaryRow::from_serialized_bytes(&bytes)?;
-            computer.generate_part_values(&row)?.into_iter().collect()
-        };
-        result.push(Partition {
-            spec,
-            record_count: agg.record_count,
-            file_size_in_bytes: agg.file_size,
-            file_count: agg.file_count,
-            last_file_creation_time: agg.last_file_creation_time,
-            total_buckets: 0,
-            done: false,
-            created_at: None,
-            created_by: None,
-            updated_at: None,
-            updated_by: None,
-            options: None,
-        });
-    }
-    Ok(result)
+    grouped
+        .into_iter()
+        .map(|(bytes, mut entry)| {
+            entry.partition = if bytes.is_empty() {
+                BinaryRow::from_datums(&[])
+            } else {
+                BinaryRow::from_serialized_bytes(&bytes)?
+            };
+            Ok(entry)
+        })
+        .collect()
 }
 
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    #[test]
+    fn creation_times_are_compared_as_instants_across_dst_gaps() {
+        use crate::spec::{stats::BinaryTableStats, DataFileMeta, FileKind};
+        let entries = ["2024-03-10T02:55:00Z", "2024-03-10T03:10:00Z"]
+            .into_iter()
+            .map(|time| {
+                let file = DataFileMeta {
+                    file_name: time.into(),
+                    file_size: 10,
+                    row_count: 1,
+                    min_key: vec![],
+                    max_key: vec![],
+                    key_stats: BinaryTableStats::empty(),
+                    value_stats: BinaryTableStats::empty(),
+                    min_sequence_number: 0,
+                    max_sequence_number: 0,
+                    schema_id: 0,
+                    level: 0,
+                    extra_files: vec![],
+                    creation_time: Some(time.parse().unwrap()),
+                    delete_row_count: None,
+                    embedded_index: None,
+                    file_source: None,
+                    value_stats_cols: None,
+                    external_path: None,
+                    first_row_id: None,
+                    write_cols: None,
+                    column_max_sequence_numbers: None,
+                };
+                ManifestEntry::new(
+                    FileKind::Add,
+                    BinaryRow::from_datums(&[]).to_serialized_bytes(),
+                    0,
+                    8,
+                    file,
+                    2,
+                )
+            })
+            .collect();
+        let rows = aggregate_partition_entries_in_zone(
+            entries,
+            &jiff::tz::TimeZone::get("America/New_York").unwrap(),
+        )
+        .unwrap();
+        assert_eq!(rows.len(), 1);
+        assert_eq!(rows[0].record_count, 2);
+        assert_eq!(rows[0].total_buckets, 8);
+        // 02:55 is shifted through the gap to 03:55 EDT (07:55 UTC), later 
than 03:10 EDT.
+        assert_eq!(rows[0].last_file_creation_time, Some(1_710_057_300_000));
+    }
+
     #[tokio::test]
     async fn test_query_auth_table_refuses_partition_listing() {
         let table = crate::table::query_auth_table();
diff --git a/crates/paimon/src/common/date_time.rs 
b/crates/paimon/src/common/date_time.rs
index ecb9fd53..e80b1dfc 100644
--- a/crates/paimon/src/common/date_time.rs
+++ b/crates/paimon/src/common/date_time.rs
@@ -82,6 +82,23 @@ pub(crate) fn format_epoch_timestamp(seconds: i64, nanos: 
u32, precision: u32) -
     )
 }
 
+/// SQL timestamp CAST from Paimon's full millisecond and sub-millisecond 
storage.
+pub(crate) fn format_timestamp_millis(
+    millis: i64,
+    sub_milli: i64,
+    precision: u32,
+    zone: Option<&jiff::tz::TimeZone>,
+) -> String {
+    let millis = match zone {
+        Some(zone) => {
+            
millis.wrapping_add(i64::from(local_offset(millis.div_euclid(1000), zone)) * 
1000)
+        }
+        None => millis,
+    };
+    let nanos = (millis.rem_euclid(1000) * 1_000_000 + sub_milli) as u32;
+    format_epoch_timestamp(millis.div_euclid(1000), nanos, precision)
+}
+
 /// Java Timestamp.toString uses LocalDateTime's ISO form, not a SQL CAST form.
 pub fn format_iso_timestamp(seconds: i64, nanos: u32) -> String {
     let (year, month, day) = 
epoch_day_to_date(i64::from(seconds.div_euclid(86_400) as i32));
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 02c50c9c..ba45b051 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -119,6 +119,7 @@ pub use types::*;
 mod partition;
 pub use partition::Partition;
 mod partition_utils;
+pub(crate) use partition_utils::decode_java_utf8;
 pub(crate) use partition_utils::{
     bucket_path, bucket_path_under, data_file_path, escape_path_name, 
relative_bucket_path,
     unescape_path_name, PartitionComputer,
diff --git a/crates/paimon/src/spec/partition_utils.rs 
b/crates/paimon/src/spec/partition_utils.rs
index f534c1f4..85c1bf3a 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -495,7 +495,7 @@ pub fn format_java_float(value: f32) -> String {
 /// Decode as Java's UTF-8 decoder does for `BinaryString.toString()`.
 /// Rust's lossy decoder replaces each byte of a UTF-8 encoded surrogate,
 /// while Java replaces the complete malformed surrogate sequence once.
-fn decode_java_utf8(mut bytes: &[u8]) -> String {
+pub(crate) fn decode_java_utf8(mut bytes: &[u8]) -> String {
     let mut decoded = String::with_capacity(bytes.len());
     loop {
         match std::str::from_utf8(bytes) {
diff --git a/crates/paimon/src/table/partition_stat.rs 
b/crates/paimon/src/table/partition_stat.rs
index c7663a8a..c59c5737 100644
--- a/crates/paimon/src/table/partition_stat.rs
+++ b/crates/paimon/src/table/partition_stat.rs
@@ -15,21 +15,20 @@
 // specific language governing permissions and limitations
 // under the License.
 
-//! Per-partition statistics computed by scanning the latest snapshot's 
manifest entries.
+//! Per-partition statistics computed by scanning the selected snapshot's 
manifest entries.
 //!
 //! Mirrors what Java Paimon exposes via the `$partitions` system table for 
runtime introspection.
 
-use std::collections::{BTreeMap, HashMap};
+use std::collections::HashMap;
 
-use crate::io::FileIO;
-use crate::spec::{
-    avro::from_avro_bytes_fast, merge_active_entries, BinaryRow, CoreOptions, 
ManifestEntry,
-    ManifestFileMeta, PartitionComputer, Snapshot,
-};
+#[cfg(test)]
+use crate::catalog::aggregate_partition_entries;
+use crate::catalog::{list_partition_entries, FileSystemPartitionEntry};
+#[cfg(test)]
+use crate::spec::ManifestEntry;
+use crate::spec::{CoreOptions, PartitionComputer};
 use crate::table::Table;
 
-const MANIFEST_DIR: &str = "manifest";
-
 /// Per-partition aggregated statistics.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct PartitionStat {
@@ -43,36 +42,19 @@ pub struct PartitionStat {
     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.
+    /// Compute per-partition statistics from the selected 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.
+    /// **Warning:** This method reads all manifest lists and entries from the 
selected 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>> {
-        // Manifests carry partition values and per-column stats.
-        CoreOptions::new(self.schema().options()).ensure_read_authorized()?;
-        let sm = self.snapshot_manager();
-        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(
@@ -82,10 +64,10 @@ impl Table {
             core.legacy_partition_name(),
         )?;
 
-        aggregate_partition_stats(entries, &computer)
+        partition_stats_from_entries(list_partition_entries(self).await?, 
&computer)
     }
 
-    /// List all partition values present in the latest snapshot.
+    /// List all partition values present in the selected snapshot.
     ///
     /// **Warning:** This method computes partition statistics which reads all 
manifest lists
     /// and entries. For large tables, this operation can be expensive.
@@ -101,87 +83,32 @@ impl Table {
     }
 }
 
-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)
+fn partition_stats_from_entries(
+    entries: Vec<FileSystemPartitionEntry>,
+    computer: &PartitionComputer,
+) -> crate::Result<Vec<PartitionStat>> {
+    entries
+        .into_iter()
+        .map(|entry| {
+            Ok(PartitionStat {
+                partition: computer
+                    .generate_part_values(&entry.partition)?
+                    .into_iter()
+                    .collect(),
+                record_count: entry.record_count,
+                file_count: entry.file_count as u64,
+                total_size_bytes: entry.file_size_in_bytes as u64,
+            })
+        })
+        .collect()
 }
 
-/// 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.
+#[cfg(test)]
 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)
+    partition_stats_from_entries(aggregate_partition_entries(entries)?, 
computer)
 }
 
 #[cfg(test)]
diff --git a/crates/paimon/src/table/system_table/mod.rs 
b/crates/paimon/src/table/system_table/mod.rs
index b7893dbe..d63b33d4 100644
--- a/crates/paimon/src/table/system_table/mod.rs
+++ b/crates/paimon/src/table/system_table/mod.rs
@@ -30,6 +30,7 @@ mod consumers;
 mod files;
 mod manifests;
 mod options;
+mod partitions;
 mod row_string_cast;
 mod schemas;
 mod snapshots;
@@ -44,6 +45,7 @@ const NAMES: &[&str] = &[
     "file_key_ranges",
     "manifests",
     "options",
+    "partitions",
     "schemas",
     "snapshots",
     "table_indexes",
@@ -202,6 +204,7 @@ impl SystemRead {
             "options" => {
                 options::read(&self.table, needed.as_deref(), 
self.predicate.as_ref()).await?
             }
+            "partitions" => partitions::read(&self.table, 
needed.as_deref()).await?,
             "schemas" => {
                 schemas::read(&self.table, needed.as_deref(), 
self.predicate.as_ref()).await?
             }
@@ -282,6 +285,7 @@ fn schema_for_name(name: &str) -> SchemaRef {
         "file_key_ranges" => files::key_ranges_schema(),
         "manifests" => manifests::manifests_schema(),
         "options" => options::options_schema(),
+        "partitions" => partitions::partitions_schema(),
         "schemas" => schemas::schemas_schema(),
         "snapshots" => snapshots::snapshots_schema(),
         "table_indexes" => table_indexes::table_indexes_schema(),
@@ -294,10 +298,11 @@ fn record_batch(schema: SchemaRef, columns: 
Vec<ArrayRef>) -> Result<RecordBatch
     RecordBatch::try_new(schema, columns).map_err(arrow_error)
 }
 
-fn local_timestamp_column(values: Vec<i64>) -> Result<ArrayRef> {
+fn local_timestamp_column(
+    values: impl Into<arrow_array::TimestampMillisecondArray>,
+) -> Result<ArrayRef> {
     use crate::spec::{DataType, LocalZonedTimestampType, TimestampType};
-    use arrow_array::TimestampMillisecondArray;
-    let values: ArrayRef = Arc::new(TimestampMillisecondArray::from(values));
+    let values: ArrayRef = Arc::new(values.into());
     crate::arrow::cast_array_for_schema_evolution(
         &values,
         &DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3)?),
diff --git a/crates/paimon/src/table/system_table/partitions.rs 
b/crates/paimon/src/table/system_table/partitions.rs
new file mode 100644
index 00000000..dd793cbf
--- /dev/null
+++ b/crates/paimon/src/table/system_table/partitions.rs
@@ -0,0 +1,626 @@
+// 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.
+
+//! Filesystem partition metadata, matching Java PartitionsTable's manifest 
path.
+
+use std::collections::HashMap;
+use std::sync::{Arc, OnceLock};
+
+use arrow_array::{
+    ArrayRef, BooleanArray, Int32Array, Int64Array, RecordBatch, StringArray,
+    TimestampMillisecondArray,
+};
+use arrow_schema::{DataType, Field, Schema, SchemaRef, TimeUnit};
+use jiff::tz::TimeZone;
+
+use crate::catalog::{list_partition_entries, FileSystemPartitionEntry};
+use crate::spec::{
+    BinaryRow, CoreOptions, DataField, Datum, FileKind, IndexManifest, 
IndexManifestEntry,
+};
+use crate::table::Table;
+use crate::Result;
+
+use super::row_string_cast::{format_field_in_zone, 
format_row_field_as_java_object_string};
+
+const DELETED_RECORD_COUNT: usize = 11;
+
+pub(super) fn partitions_schema() -> SchemaRef {
+    static SCHEMA: OnceLock<SchemaRef> = OnceLock::new();
+    SCHEMA
+        .get_or_init(|| {
+            Arc::new(Schema::new(vec![
+                Field::new("partition", DataType::Utf8, true),
+                Field::new("record_count", DataType::Int64, false),
+                Field::new("file_size_in_bytes", DataType::Int64, false),
+                Field::new("file_count", DataType::Int64, false),
+                Field::new(
+                    "last_update_time",
+                    DataType::Timestamp(TimeUnit::Millisecond, None),
+                    true,
+                ),
+                Field::new(
+                    "created_at",
+                    DataType::Timestamp(TimeUnit::Millisecond, None),
+                    true,
+                ),
+                Field::new("created_by", DataType::Utf8, true),
+                Field::new("updated_by", DataType::Utf8, true),
+                Field::new("options", DataType::Utf8, true),
+                Field::new("total_buckets", DataType::Int32, false),
+                Field::new("done", DataType::Boolean, false),
+                Field::new("deleted_record_count", DataType::Int64, true),
+            ]))
+        })
+        .clone()
+}
+
+pub(super) async fn read(table: &Table, projection: Option<&[usize]>) -> 
Result<RecordBatch> {
+    // Resolve once: files and deletion-vector counts must describe the same 
commit.
+    let snapshot = table.resolve_read_snapshot().await?;
+    let pinned = table.copy_with_pinned_snapshot(snapshot.as_ref());
+    let entries = list_partition_entries(&pinned).await?;
+    let needs_deletions = projection.is_none_or(|columns| 
columns.contains(&DELETED_RECORD_COUNT));
+    let deletions = if needs_deletions
+        && 
CoreOptions::new(table.schema().options()).deletion_vectors_enabled()
+    {
+        deleted_record_counts(&pinned).await?
+    } else {
+        HashMap::new()
+    };
+    let fields = table.schema().partition_fields();
+    let options = CoreOptions::new(table.schema().options());
+    partition_batch(
+        entries,
+        &fields,
+        options.partition_default_name(),
+        options.legacy_partition_name(),
+        &deletions,
+    )
+}
+
+async fn deleted_record_counts(table: &Table) -> Result<HashMap<Vec<u8>, 
Option<i64>>> {
+    let Some(snapshot) = table.resolve_read_snapshot().await? else {
+        return Ok(HashMap::new());
+    };
+    let Some(name) = snapshot.index_manifest() else {
+        return Ok(HashMap::new());
+    };
+    let path = table.snapshot_manager().manifest_path(name);
+    let entries = IndexManifest::read(table.file_io(), &path).await?;
+    Ok(accumulate_deletions(entries))
+}
+
+fn accumulate_deletions(entries: Vec<IndexManifestEntry>) -> HashMap<Vec<u8>, 
Option<i64>> {
+    let mut counts = HashMap::new();
+    for entry in entries {
+        if entry.kind != FileKind::Add || entry.index_file.index_type != 
"DELETION_VECTORS" {
+            continue;
+        }
+        let Some(ranges) = entry.index_file.deletion_vectors_ranges else {
+            continue;
+        };
+        if ranges.is_empty() {
+            continue;
+        }
+        let count = counts.entry(entry.partition).or_insert(Some(0_i64));
+        for meta in ranges.into_values() {
+            *count = count
+                .zip(meta.cardinality)
+                .map(|(sum, value)| sum.wrapping_add(value));
+        }
+    }
+    counts
+}
+
+fn partition_string(
+    row: &BinaryRow,
+    fields: &[DataField],
+    default_name: &str,
+    zone: &TimeZone,
+) -> Result<String> {
+    fields
+        .iter()
+        .enumerate()
+        .map(|(index, field)| {
+            let value = if row.is_null_at(index) {
+                default_name.to_string()
+            } else {
+                format_field_in_zone(row, index, field.data_type(), zone)?
+            };
+            let value = if crate::spec::is_java_whitespace_only(&value) {
+                default_name
+            } else {
+                &value
+            };
+            Ok(format!("{}={value}", field.name()))
+        })
+        .collect::<Result<Vec<_>>>()
+        .map(|parts| parts.join("/"))
+}
+
+// Java generates a spec using partition.legacy-name, then reconstructs a typed
+// row for BOTH display and deletion lookup. Keep this conversion independent 
of
+// whether deletion metadata was requested (notably for LTZ and DST overlaps).
+fn normalize_partition(
+    row: &BinaryRow,
+    fields: &[DataField],
+    default_name: &str,
+    legacy: bool,
+    zone: &TimeZone,
+) -> Result<BinaryRow> {
+    let values = fields
+        .iter()
+        .enumerate()
+        .map(|(index, field)| {
+            let value = row.get_datum(index, field.data_type())?;
+            let Some(value) = value else {
+                return Ok(None);
+            };
+            let text = if legacy {
+                format_row_field_as_java_object_string(row, index, 
field.data_type())?
+                    .expect("non-null partition field")
+            } else {
+                format_field_in_zone(row, index, field.data_type(), zone)?
+            };
+            if text == default_name || 
crate::spec::is_java_whitespace_only(&text) {
+                return Ok(None);
+            }
+            Ok(Some(match value {
+                Datum::Bytes(_) => Datum::Bytes(text.into_bytes()),
+                Datum::Time(_) => Datum::Time(parse_time_spec(&text)?),
+                Datum::Timestamp { .. } => {
+                    let crate::spec::DataType::Timestamp(typ) = 
field.data_type() else {
+                        unreachable!()
+                    };
+                    let (millis, nanos) =
+                        crate::arrow::parse_java_timestamp(&text, 
typ.precision(), None)?;
+                    Datum::Timestamp {
+                        millis,
+                        nanos: nanos as i32,
+                    }
+                }
+                Datum::LocalZonedTimestamp { .. } => {
+                    let crate::spec::DataType::LocalZonedTimestamp(typ) = 
field.data_type() else {
+                        unreachable!()
+                    };
+                    let (millis, nanos) =
+                        crate::arrow::parse_java_timestamp(&text, 
typ.precision(), Some(zone))?;
+                    Datum::LocalZonedTimestamp {
+                        millis,
+                        nanos: nanos as i32,
+                    }
+                }
+                // The remaining scalar formatters round-trip the typed value
+                // unchanged; reuse it without narrowing through Arrow storage.
+                value => value,
+            }))
+        })
+        .collect::<Result<Vec<_>>>()?;
+    let refs: Vec<_> = values
+        .iter()
+        .zip(fields)
+        .map(|(value, field)| (value.as_ref(), field.data_type()))
+        .collect();
+    Ok(BinaryRow::from_datums(&refs))
+}
+
+fn parse_time_spec(text: &str) -> Result<i32> {
+    // BinaryStringUtils.toTime preserves the integer legacy spec. Otherwise
+    // parse the formatter's HH:mm:ss[.fraction] without chrono's hour limit.
+    if let Ok(value) = text.parse::<i32>() {
+        return Ok(value);
+    }
+    let (time, fraction) = text.split_once('.').unwrap_or((text, ""));
+    let parts = time
+        .split(':')
+        .map(|part| {
+            part.parse::<i32>()
+                .map_err(|error| super::invalid(error.to_string()))
+        })
+        .collect::<Result<Vec<_>>>()?;
+    let [hours, minutes, seconds] = parts.as_slice() else {
+        return Err(super::invalid(format!(
+            "Invalid TIME partition spec '{text}'"
+        )));
+    };
+    let fraction = if fraction.is_empty() {
+        0
+    } else {
+        let digits = &fraction[..fraction.len().min(3)];
+        digits
+            .parse::<i32>()
+            .map_err(|error| super::invalid(error.to_string()))?
+            * 10_i32.pow(3 - digits.len() as u32)
+    };
+    Ok((hours * 3600 + minutes * 60 + seconds) * 1000 + fraction)
+}
+
+fn partition_batch(
+    entries: Vec<FileSystemPartitionEntry>,
+    fields: &[DataField],
+    default_name: &str,
+    legacy: bool,
+    deletions: &HashMap<Vec<u8>, Option<i64>>,
+) -> Result<RecordBatch> {
+    let zone = TimeZone::system();
+    let mut rows = entries
+        .into_iter()
+        .map(|mut entry| {
+            entry.partition =
+                normalize_partition(&entry.partition, fields, default_name, 
legacy, &zone)?;
+            Ok((
+                partition_string(&entry.partition, fields, default_name, 
&zone)?,
+                entry,
+            ))
+        })
+        .collect::<Result<Vec<_>>>()?;
+    rows.sort_by(|a, b| a.0.cmp(&b.0));
+    let n = rows.len();
+    let mut names = Vec::with_capacity(n);
+    let mut records = Vec::with_capacity(n);
+    let mut sizes = Vec::with_capacity(n);
+    let mut files = Vec::with_capacity(n);
+    let mut times = Vec::with_capacity(n);
+    let mut buckets = Vec::with_capacity(n);
+    let mut deleted = Vec::with_capacity(n);
+    for (name, entry) in rows {
+        names.push(name);
+        records.push(entry.record_count);
+        sizes.push(entry.file_size_in_bytes);
+        files.push(entry.file_count);
+        times.push(entry.last_file_creation_time);
+        buckets.push(entry.total_buckets);
+        deleted.push(if deletions.is_empty() {
+            Some(0)
+        } else {
+            deletions
+                .get(&entry.partition.to_serialized_bytes())
+                .copied()
+                .unwrap_or(Some(0))
+        });
+    }
+    super::record_batch(
+        partitions_schema(),
+        vec![
+            Arc::new(StringArray::from(names)),
+            Arc::new(Int64Array::from(records)),
+            Arc::new(Int64Array::from(sizes)),
+            Arc::new(Int64Array::from(files)),
+            super::local_timestamp_column(times)?,
+            Arc::new(TimestampMillisecondArray::from(vec![None::<i64>; n])),
+            Arc::new(StringArray::from(vec![None::<&str>; n])),
+            Arc::new(StringArray::from(vec![None::<&str>; n])),
+            Arc::new(StringArray::from(vec![None::<&str>; n])),
+            Arc::new(Int32Array::from(buckets)),
+            Arc::new(BooleanArray::from(vec![false; n])) as ArrayRef,
+            Arc::new(Int64Array::from(deleted)),
+        ],
+    )
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{
+        DataType as PaimonType, Datum, DeletionVectorMeta, IndexFileMeta, 
VarCharType,
+    };
+
+    fn index(partition: &str, cardinalities: Option<&[Option<i64>]>) -> 
IndexManifestEntry {
+        let typ = PaimonType::VarChar(VarCharType::string_type());
+        let value = Datum::String(partition.into());
+        let row = BinaryRow::from_datums(&[(Some(&value), &typ)]);
+        IndexManifestEntry {
+            version: 1,
+            kind: FileKind::Add,
+            partition: row.to_serialized_bytes(),
+            bucket: 0,
+            index_file: IndexFileMeta {
+                index_type: "DELETION_VECTORS".into(),
+                file_name: "index".into(),
+                file_size: 1,
+                row_count: 1,
+                deletion_vectors_ranges: cardinalities.map(|values| {
+                    values
+                        .iter()
+                        .enumerate()
+                        .map(|(i, count)| {
+                            (
+                                format!("file-{i}"),
+                                DeletionVectorMeta {
+                                    offset: 0,
+                                    length: 1,
+                                    cardinality: *count,
+                                },
+                            )
+                        })
+                        .collect()
+                }),
+                external_path: None,
+                global_index_meta: None,
+            },
+        }
+    }
+
+    #[test]
+    fn deletion_counts_sum_buckets_and_keep_unknown_cardinality() {
+        let known = index("known", Some(&[Some(2), Some(3)]));
+        let mut other_bucket = index("known", Some(&[Some(4)]));
+        other_bucket.bucket = 1;
+        let unknown = index("unknown", Some(&[Some(2), None]));
+        let later = index("unknown", Some(&[Some(7)]));
+        let absent = index("absent", None);
+        let empty = index("empty", Some(&[]));
+        let mut deleted = index("known", Some(&[Some(100)]));
+        deleted.kind = FileKind::Delete;
+        let mut unrelated = index("known", Some(&[Some(100)]));
+        unrelated.index_file.index_type = "HASH".into();
+        let counts = accumulate_deletions(vec![
+            known.clone(),
+            other_bucket,
+            unknown.clone(),
+            later,
+            absent.clone(),
+            empty,
+            deleted,
+            unrelated,
+        ]);
+        assert_eq!(counts[&known.partition], Some(9));
+        assert_eq!(counts[&unknown.partition], None);
+        assert!(!counts.contains_key(&absent.partition));
+        assert_eq!(counts.len(), 2);
+    }
+
+    #[test]
+    fn deletion_lookup_uses_typed_partition_specs() {
+        let zone = TimeZone::UTC;
+        let typ = PaimonType::VarChar(VarCharType::string_type());
+        let fields = [DataField::new(0, "p".into(), typ.clone())];
+        let null = BinaryRow::from_datums(&[(None, 
&typ)]).to_serialized_bytes();
+        for value in ["DEFAULT", "\u{1c}"] {
+            let datum = Datum::String(value.into());
+            let row = BinaryRow::from_datums(&[(Some(&datum), &typ)]);
+            assert_eq!(
+                normalize_partition(&row, &fields, "DEFAULT", false, &zone)
+                    .unwrap()
+                    .to_serialized_bytes(),
+                null
+            );
+        }
+        let typ = 
PaimonType::VarBinary(crate::spec::VarBinaryType::new(3).unwrap());
+        let fields = [DataField::new(0, "p".into(), typ.clone())];
+        let original = Datum::Bytes(vec![0xed, 0xa0, 0x80]);
+        let canonical = Datum::Bytes("�".as_bytes().to_vec());
+        let row = BinaryRow::from_datums(&[(Some(&original), &typ)]);
+        assert_eq!(
+            normalize_partition(&row, &fields, "DEFAULT", false, &zone)
+                .unwrap()
+                .to_serialized_bytes(),
+            BinaryRow::from_datums(&[(Some(&canonical), 
&typ)]).to_serialized_bytes()
+        );
+    }
+
+    #[test]
+    fn time_spec_parser_preserves_java_integer_and_clock_forms() {
+        for (spec, millis) in [
+            ("0", 0),
+            ("00:00:00", 0),
+            ("45296120", 45_296_120),
+            ("12:34:56.12", 45_296_120),
+            ("23:59:59.999", 86_399_999),
+            ("24:00:00", 86_400_000),
+        ] {
+            assert_eq!(parse_time_spec(spec).unwrap(), millis);
+        }
+    }
+
+    #[test]
+    fn time_spec_round_trip_respects_legacy_names() {
+        use crate::spec::TimeType;
+        for precision in 0..=3 {
+            let typ = PaimonType::Time(TimeType::new(precision).unwrap());
+            let fields = [DataField::new(0, "p".into(), typ.clone())];
+            let datum = Datum::Time(45_296_123);
+            let row = BinaryRow::from_datums(&[(Some(&datum), &typ)]);
+            for legacy in [true, false] {
+                let normalized =
+                    normalize_partition(&row, &fields, "DEFAULT", legacy, 
&TimeZone::UTC).unwrap();
+                let quantum = if legacy { 1 } else { 10_i32.pow(3 - precision) 
};
+                assert_eq!(
+                    normalized.get_int(0).unwrap(),
+                    45_296_123 / quantum * quantum
+                );
+                let counts = HashMap::from([(row.to_serialized_bytes(), 
Some(1))]);
+                let batch = partition_batch(
+                    vec![FileSystemPartitionEntry {
+                        partition: row.clone(),
+                        ..Default::default()
+                    }],
+                    &fields,
+                    "DEFAULT",
+                    legacy,
+                    &counts,
+                )
+                .unwrap();
+                let count = batch
+                    .column(DELETED_RECORD_COUNT)
+                    .as_any()
+                    .downcast_ref::<Int64Array>()
+                    .unwrap()
+                    .value(0);
+                assert_eq!(count, i64::from(legacy || precision == 3));
+            }
+        }
+    }
+
+    #[test]
+    fn ltz_spec_round_trip_matches_java_legacy_and_dst_rules() {
+        use crate::spec::LocalZonedTimestampType;
+        let zone = TimeZone::get("America/New_York").unwrap();
+        let typ = 
PaimonType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap());
+        let fields = [DataField::new(0, "p".into(), typ.clone())];
+        // Expected keys and names were obtained with Java's partition 
computer,
+        // convertSpecToInternalRow and CastExecutors.resolveToString.
+        for (instant, legacy, key, display) in [
+            (
+                1_704_067_200_000,
+                true,
+                1_704_085_200_000,
+                "2024-01-01 00:00:00.000",
+            ),
+            (
+                1_704_067_200_000,
+                false,
+                1_704_067_200_000,
+                "2023-12-31 19:00:00.000",
+            ),
+            (
+                1_730_615_400_000,
+                true,
+                1_730_633_400_000,
+                "2024-11-03 06:30:00.000",
+            ),
+            // Parsing 01:30 during the overlap chooses the earlier EDT offset.
+            (
+                1_730_615_400_000,
+                false,
+                1_730_611_800_000,
+                "2024-11-03 01:30:00.000",
+            ),
+            // A legacy wall time inside the spring gap shifts forward an hour.
+            (
+                1_710_038_700_000,
+                true,
+                1_710_056_700_000,
+                "2024-03-10 03:45:00.000",
+            ),
+        ] {
+            let datum = Datum::LocalZonedTimestamp {
+                millis: instant,
+                nanos: 0,
+            };
+            let row = BinaryRow::from_datums(&[(Some(&datum), &typ)]);
+            let normalized = normalize_partition(&row, &fields, "DEFAULT", 
legacy, &zone).unwrap();
+            assert_eq!(normalized.get_timestamp_raw(0, 3).unwrap(), (key, 0));
+            assert_eq!(
+                partition_string(&normalized, &fields, "DEFAULT", 
&zone).unwrap(),
+                format!("p={display}")
+            );
+            let counts = HashMap::from([(row.to_serialized_bytes(), Some(1))]);
+            assert_eq!(
+                counts
+                    .get(&normalized.to_serialized_bytes())
+                    .copied()
+                    .unwrap_or(Some(0)),
+                Some(i64::from(key == instant))
+            );
+        }
+    }
+
+    #[test]
+    fn 
timestamp_spec_round_trip_uses_declared_precision_without_arrow_overflow() {
+        use crate::spec::TimestampType;
+        for (precision, millis, nanos) in [
+            (1, 16_725_225_600_100, 0),
+            (3, 16_725_225_600_123, 0),
+            (6, 16_725_225_600_123, 456_000),
+            (7, 16_725_225_600_123, 456_700),
+            (9, 16_725_225_600_123, 456_789),
+        ] {
+            let typ = 
PaimonType::Timestamp(TimestampType::new(precision).unwrap());
+            let fields = [DataField::new(0, "p".into(), typ.clone())];
+            let datum = Datum::Timestamp {
+                millis: 16_725_225_600_123,
+                nanos: 456_789,
+            };
+            let row = BinaryRow::from_datums(&[(Some(&datum), &typ)]);
+            for legacy in [true, false] {
+                let normalized =
+                    normalize_partition(&row, &fields, "DEFAULT", legacy, 
&TimeZone::UTC).unwrap();
+                assert_eq!(
+                    normalized.get_timestamp_raw(0, precision).unwrap(),
+                    (millis, nanos)
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn partition_defaults_use_java_whitespace() {
+        let typ = PaimonType::VarChar(VarCharType::string_type());
+        let fields = [DataField::new(0, "p".into(), typ.clone())];
+        for (input, expected) in [
+            ("\u{a0}", "\u{a0}"),
+            ("\u{85}", "\u{85}"),
+            ("\u{2007}", "\u{2007}"),
+            ("\u{202f}", "\u{202f}"),
+            ("\u{1c}", "DEFAULT"),
+            ("", "DEFAULT"),
+        ] {
+            let datum = Datum::String(input.into());
+            let row = BinaryRow::from_datums(&[(Some(&datum), &typ)]);
+            assert_eq!(
+                partition_string(&row, &fields, "DEFAULT", 
&TimeZone::UTC).unwrap(),
+                format!("p={expected}")
+            );
+        }
+    }
+
+    #[test]
+    fn partition_names_use_casts_default_name_and_unescaped_values() {
+        use crate::spec::{BooleanType, DecimalType, TimestampType};
+        let types = [
+            PaimonType::Boolean(BooleanType::new()),
+            PaimonType::Decimal(DecimalType::new(10, 2).unwrap()),
+            PaimonType::Timestamp(TimestampType::new(9).unwrap()),
+            PaimonType::VarChar(VarCharType::string_type()),
+            PaimonType::VarChar(VarCharType::string_type()),
+        ];
+        let fields: Vec<_> = types
+            .iter()
+            .enumerate()
+            .map(|(i, t)| DataField::new(i as i32, format!("p{i}"), t.clone()))
+            .collect();
+        let values = [
+            Some(Datum::Bool(true)),
+            Some(Datum::Decimal {
+                unscaled: 120,
+                precision: 10,
+                scale: 2,
+            }),
+            Some(Datum::Timestamp {
+                millis: 16_725_225_600_123,
+                nanos: 456_789,
+            }),
+            Some(Datum::String("a/b=c".into())),
+            None,
+        ];
+        let refs: Vec<_> = values
+            .iter()
+            .zip(types.iter())
+            .map(|(v, t)| (v.as_ref(), t))
+            .collect();
+        let row = BinaryRow::from_datums(&refs);
+        assert_eq!(
+            partition_string(&row, &fields, "DEFAULT", 
&TimeZone::UTC).unwrap(),
+            "p0=true/p1=1.20/p2=2500-01-01 
00:00:00.123456789/p3=a/b=c/p4=DEFAULT"
+        );
+        assert_eq!(
+            partition_string(&BinaryRow::from_datums(&[]), &[], "DEFAULT", 
&TimeZone::UTC).unwrap(),
+            ""
+        );
+    }
+}
diff --git a/crates/paimon/src/table/system_table/row_string_cast.rs 
b/crates/paimon/src/table/system_table/row_string_cast.rs
index 1101f606..74a5d829 100644
--- a/crates/paimon/src/table/system_table/row_string_cast.rs
+++ b/crates/paimon/src/table/system_table/row_string_cast.rs
@@ -15,20 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use std::num::NonZeroI32;
-
+use crate::common::number::format_java_float;
 use crate::spec::{BinaryRow, DataField, DataType, Datum};
 use crate::{Error, Result};
 use chrono::NaiveDate;
-use lexical_write_float::{format::STANDARD, Options, ToLexicalWithOptions};
 
 const MILLIS_PER_DAY: i64 = 86_400_000;
-const JAVA_FLOAT_OPTIONS: Options = Options::builder()
-    .positive_exponent_break(NonZeroI32::new(6))
-    .negative_exponent_break(NonZeroI32::new(-3))
-    .exponent(b'E')
-    .inf_string(Some(b"Infinity"))
-    .build_strict();
 
 pub(super) fn format_row_as_java_cast_string(
     row: &BinaryRow,
@@ -150,7 +142,16 @@ fn validate_row_has_field(row: &BinaryRow, pos: usize) -> 
Result<()> {
     Ok(())
 }
 
-fn format_field(row: &BinaryRow, pos: usize, data_type: &DataType) -> 
Result<String> {
+pub(super) fn format_field(row: &BinaryRow, pos: usize, data_type: &DataType) 
-> Result<String> {
+    format_field_in_zone(row, pos, data_type, &jiff::tz::TimeZone::system())
+}
+
+pub(super) fn format_field_in_zone(
+    row: &BinaryRow,
+    pos: usize,
+    data_type: &DataType,
+    zone: &jiff::tz::TimeZone,
+) -> Result<String> {
     let Some(datum) = row.get_datum(pos, data_type)? else {
         return Ok("null".to_string());
     };
@@ -161,11 +162,11 @@ fn format_field(row: &BinaryRow, pos: usize, data_type: 
&DataType) -> Result<Str
         (Datum::SmallInt(v), DataType::SmallInt(_)) => Ok(v.to_string()),
         (Datum::Int(v), DataType::Int(_)) => Ok(v.to_string()),
         (Datum::Long(v), DataType::BigInt(_)) => Ok(v.to_string()),
-        (Datum::Float(v), DataType::Float(_)) => Ok(format_float(v)),
-        (Datum::Double(v), DataType::Double(_)) => Ok(format_double(v)),
+        (Datum::Float(v), DataType::Float(_)) => Ok(format_java_float(v)),
+        (Datum::Double(v), DataType::Double(_)) => Ok(format_java_float(v)),
         (Datum::String(v), DataType::Char(_) | DataType::VarChar(_)) => Ok(v),
         (Datum::Bytes(v), DataType::Binary(_) | DataType::VarBinary(_)) => {
-            Ok(String::from_utf8_lossy(&v).into_owned())
+            Ok(crate::spec::decode_java_utf8(&v))
         }
         (Datum::Date(v), DataType::Date(_)) => format_date(v),
         (Datum::Time(v), DataType::Time(t)) => format_time(v, t.precision()),
@@ -175,18 +176,21 @@ fn format_field(row: &BinaryRow, pos: usize, data_type: 
&DataType) -> Result<Str
             },
             DataType::Decimal(_),
         ) => Ok(format_decimal_plain(unscaled, scale)),
-        (Datum::Timestamp { .. } | Datum::LocalZonedTimestamp { .. }, _) => {
-            let source = row.field_to_arrow(pos, data_type)?;
-            let target = 
DataType::VarChar(crate::spec::VarCharType::string_type());
-            let strings =
-                crate::arrow::cast_array_for_schema_evolution(&source, 
data_type, &target)?;
-            let strings = strings
-                .as_any()
-                .downcast_ref::<arrow_array::StringArray>()
-                .ok_or_else(|| {
-                    data_invalid("Timestamp string cast returned a non-string 
array".into())
-                })?;
-            Ok(strings.value(0).to_string())
+        (Datum::Timestamp { millis, nanos }, DataType::Timestamp(typ)) => {
+            Ok(crate::common::date_time::format_timestamp_millis(
+                millis,
+                i64::from(nanos),
+                typ.precision(),
+                None,
+            ))
+        }
+        (Datum::LocalZonedTimestamp { millis, nanos }, 
DataType::LocalZonedTimestamp(typ)) => {
+            Ok(crate::common::date_time::format_timestamp_millis(
+                millis,
+                i64::from(nanos),
+                typ.precision(),
+                Some(zone),
+            ))
         }
         (datum, _) => Err(data_invalid(format!(
             "Decoded row string cast datum {datum:?} does not match type 
{data_type:?}"
@@ -194,24 +198,6 @@ fn format_field(row: &BinaryRow, pos: usize, data_type: 
&DataType) -> Result<Str
     }
 }
 
-fn format_float(value: f32) -> String {
-    const BUFFER_SIZE: usize = JAVA_FLOAT_OPTIONS.buffer_size_const::<f32, 
STANDARD>();
-    let mut buffer = [0u8; BUFFER_SIZE];
-    let bytes = value.to_lexical_with_options::<STANDARD>(&mut buffer, 
&JAVA_FLOAT_OPTIONS);
-    std::str::from_utf8(bytes)
-        .expect("lexical float output is valid UTF-8")
-        .to_string()
-}
-
-fn format_double(value: f64) -> String {
-    const BUFFER_SIZE: usize = JAVA_FLOAT_OPTIONS.buffer_size_const::<f64, 
STANDARD>();
-    let mut buffer = [0u8; BUFFER_SIZE];
-    let bytes = value.to_lexical_with_options::<STANDARD>(&mut buffer, 
&JAVA_FLOAT_OPTIONS);
-    std::str::from_utf8(bytes)
-        .expect("lexical double output is valid UTF-8")
-        .to_string()
-}
-
 fn format_date(epoch_days: i32) -> Result<String> {
     let ce_days = epoch_days.checked_add(719_163).ok_or_else(|| {
         data_invalid(format!(
@@ -432,6 +418,24 @@ mod tests {
         );
     }
 
+    #[test]
+    fn floating_partition_casts_use_java_significant_digits() {
+        assert_eq!(
+            format_value(&[
+                (
+                    Some(Datum::Float(f32::from_bits(1))),
+                    DataType::Float(FloatType::new())
+                ),
+                (
+                    Some(Datum::Double(f64::from_bits(1))),
+                    DataType::Double(DoubleType::new())
+                )
+            ])
+            .unwrap(),
+            "{1.4E-45, 4.9E-324}"
+        );
+    }
+
     #[test]
     fn test_format_binary_invalid_utf8_uses_lossy_string() {
         let bytes = vec![0xff];
@@ -444,6 +448,15 @@ mod tests {
         );
     }
 
+    #[test]
+    fn binary_surrogates_use_java_utf8_replacement() {
+        let typ = DataType::VarBinary(VarBinaryType::new(3).unwrap());
+        assert_eq!(
+            format_value(&[(Some(Datum::Bytes(vec![0xed, 0xa0, 0x80])), 
typ)]).unwrap(),
+            "{�}"
+        );
+    }
+
     #[test]
     fn test_format_null_values() {
         let values = vec![
@@ -479,6 +492,54 @@ mod tests {
         );
     }
 
+    #[test]
+    fn high_precision_timestamps_do_not_narrow_to_arrow_ticks() {
+        for precision in 7..=9 {
+            let typ = 
DataType::Timestamp(TimestampType::new(precision).unwrap());
+            for (millis, expected) in [
+                (16_725_225_600_123, "2500-01-01 00:00:00.123456789"),
+                (-8_520_335_999_877, "1700-01-01 00:00:00.123456789"),
+            ] {
+                let expected = &expected[..expected.len() - (9 - precision) as 
usize];
+                let quantum = 10_i32.pow(9 - precision);
+                let nanos = 456_789 / quantum * quantum;
+                assert_eq!(
+                    format_value(&[(Some(Datum::Timestamp { millis, nanos }), 
typ.clone())])
+                        .unwrap(),
+                    format!("{{{expected}}}")
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn high_precision_ltz_timestamps_keep_submillisecond_values() {
+        let millis = 16_725_225_600_123;
+        let local = 
chrono::Local.timestamp_millis_opt(millis).single().unwrap();
+        for precision in 7..=9 {
+            let quantum = 10_i32.pow(9 - precision);
+            let nanos = 456_789 / quantum * quantum;
+            let expected = format!(
+                "{{{}.{:09}}}",
+                local.format("%Y-%m-%d %H:%M:%S"),
+                123_000_000 + nanos
+            );
+            let expected = format!(
+                "{}{}",
+                &expected[..expected.len() - 1 - (9 - precision) as usize],
+                "}"
+            );
+            assert_eq!(
+                format_value(&[(
+                    Some(Datum::LocalZonedTimestamp { millis, nanos }),
+                    
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(precision).unwrap())
+                )])
+                .unwrap(),
+                expected
+            );
+        }
+    }
+
     #[test]
     fn test_format_local_zoned_timestamp() {
         let data_type = 
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(3).unwrap());
diff --git a/crates/paimon/tests/partition_system_read_test.rs 
b/crates/paimon/tests/partition_system_read_test.rs
new file mode 100644
index 00000000..ae1f7d0a
--- /dev/null
+++ b/crates/paimon/tests/partition_system_read_test.rs
@@ -0,0 +1,221 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod common;
+
+use arrow_array::{Int32Array, Int64Array, RecordBatch, StringArray};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs, write_batch};
+use paimon::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
+use paimon::table::Table;
+use std::sync::Arc;
+
+async fn table() -> Table {
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("pt", DataType::VarChar(VarCharType::string_type()))
+        .partition_keys(["pt"])
+        .option("bucket", "8")
+        .option("bucket-key", "id")
+        .option("deletion-vectors.enabled", "true")
+        .build()
+        .unwrap();
+    let (io, table) = memory_table("memory:/partition_metadata", 
TableSchema::new(0, &schema));
+    setup_dirs(&io, table.location()).await;
+    persist_table_schema(&io, table.location(), table.schema()).await;
+    table
+}
+
+async fn append(table: &Table, ids: Vec<i32>, partitions: Vec<Option<&str>>) {
+    let batch = RecordBatch::try_from_iter([
+        (
+            "id",
+            Arc::new(Int32Array::from(ids)) as arrow_array::ArrayRef,
+        ),
+        (
+            "pt",
+            Arc::new(StringArray::from(partitions)) as arrow_array::ArrayRef,
+        ),
+    ])
+    .unwrap();
+    write_batch(table, &batch).await;
+}
+
+fn strings(batch: &RecordBatch, column: &str) -> Vec<String> {
+    batch
+        .column_by_name(column)
+        .unwrap()
+        .as_any()
+        .downcast_ref::<StringArray>()
+        .unwrap()
+        .iter()
+        .map(|value| value.unwrap().to_string())
+        .collect()
+}
+
+fn longs(batch: &RecordBatch, column: &str) -> Vec<Option<i64>> {
+    batch
+        .column_by_name(column)
+        .unwrap()
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .unwrap()
+        .iter()
+        .collect()
+}
+
+#[tokio::test]
+async fn partition_statistics_follow_captured_snapshots_and_declared_buckets() 
{
+    let table = table().await;
+    let read = table.new_system_read("partitions").unwrap();
+    let empty = read.read().await.unwrap();
+    assert_eq!(empty.num_rows(), 0);
+    assert_eq!(empty.schema(), read.schema());
+    let fixed_empty = table.copy_with_pinned_snapshot(None);
+    append(&table, vec![1, 2, 3], vec![Some("z"), None, Some("z")]).await;
+    let first = table.resolve_read_snapshot().await.unwrap().unwrap();
+    let historical = table.copy_with_pinned_snapshot(Some(&first));
+    append(&table, vec![4], vec![Some("a")]).await;
+    let batch = read.read().await.unwrap();
+    assert_eq!(
+        strings(&batch, "partition"),
+        ["pt=__DEFAULT_PARTITION__", "pt=a", "pt=z"]
+    );
+    assert_eq!(longs(&batch, "record_count"), [Some(1), Some(1), Some(2)]);
+    let buckets = batch
+        .column_by_name("total_buckets")
+        .unwrap()
+        .as_any()
+        .downcast_ref::<Int32Array>()
+        .unwrap();
+    assert!(buckets.iter().all(|value| value == Some(8)));
+    assert_eq!(longs(&batch, "deleted_record_count"), [Some(0); 3]);
+    assert_eq!(
+        historical
+            .new_system_read("partitions")
+            .unwrap()
+            .read()
+            .await
+            .unwrap()
+            .num_rows(),
+        2
+    );
+    assert_eq!(historical.partition_stats().await.unwrap().len(), 2);
+    assert!(fixed_empty.partition_stats().await.unwrap().is_empty());
+    assert!(
+        paimon::catalog::list_partitions_from_file_system(&fixed_empty)
+            .await
+            .unwrap()
+            .is_empty()
+    );
+    assert_eq!(
+        fixed_empty
+            .new_system_read("partitions")
+            .unwrap()
+            .read()
+            .await
+            .unwrap()
+            .num_rows(),
+        0
+    );
+    for partition in paimon::catalog::list_partitions_from_file_system(&table)
+        .await
+        .unwrap()
+    {
+        assert_eq!(partition.total_buckets, 8);
+    }
+}
+
+#[tokio::test]
+async fn 
deletion_count_filter_reads_hidden_column_and_projection_skips_index_io() {
+    use paimon::spec::{
+        DeletionVectorMeta, FileKind, IndexFileMeta, IndexManifest, 
IndexManifestEntry, Predicate,
+        PredicateOperator,
+    };
+    let table = table().await;
+    append(&table, vec![1, 2], vec![Some("a"), Some("b")]).await;
+    let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+    let mut entries = Vec::new();
+    for split in plan.splits() {
+        let cardinality = if split.partition().get_string(0).unwrap() == "a" {
+            Some(1)
+        } else {
+            None
+        };
+        entries.push(IndexManifestEntry {
+            version: 1,
+            kind: FileKind::Add,
+            partition: split.partition().to_serialized_bytes(),
+            bucket: split.bucket(),
+            index_file: IndexFileMeta {
+                index_type: "DELETION_VECTORS".into(),
+                file_name: "unused-index-file".into(),
+                file_size: 1,
+                row_count: 1,
+                deletion_vectors_ranges: Some(
+                    [(
+                        split.data_files()[0].file_name.clone(),
+                        DeletionVectorMeta {
+                            offset: 0,
+                            length: 1,
+                            cardinality,
+                        },
+                    )]
+                    .into_iter()
+                    .collect(),
+                ),
+                external_path: None,
+                global_index_meta: None,
+            },
+        });
+    }
+    let manager = table.snapshot_manager();
+    let path = manager.manifest_path("test-index-manifest");
+    IndexManifest::write(table.file_io(), &path, &entries)
+        .await
+        .unwrap();
+    let snapshot = table.resolve_read_snapshot().await.unwrap().unwrap();
+    let mut json = serde_json::to_value(&snapshot).unwrap();
+    json["indexManifest"] = serde_json::json!("test-index-manifest");
+    table
+        .file_io()
+        .new_output(&manager.snapshot_path(snapshot.id()))
+        .unwrap()
+        .write(serde_json::to_vec(&json).unwrap().into())
+        .await
+        .unwrap();
+    let mut read = table.new_system_read("partitions").unwrap();
+    assert_eq!(
+        longs(&read.read().await.unwrap(), "deleted_record_count"),
+        [Some(1), None]
+    );
+    read.with_projection(&["partition"]).unwrap();
+    read.with_filter(Predicate::Leaf {
+        column: "deleted_record_count".into(),
+        index: 999,
+        data_type: DataType::Int(IntType::new()),
+        op: PredicateOperator::IsNull,
+        literals: vec![],
+    })
+    .unwrap();
+    read.with_limit(1);
+    assert_eq!(strings(&read.read().await.unwrap(), "partition"), ["pt=b"]);
+    table.file_io().delete_file(&path).await.unwrap();
+    let mut projected = table.new_system_read("partitions").unwrap();
+    projected.with_projection(&["partition"]).unwrap();
+    assert_eq!(projected.read().await.unwrap().num_rows(), 2);
+    assert!(read.read().await.is_err());
+}

Reply via email to