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 20cdfe9  feat: write value stats for append data files (#477)
20cdfe9 is described below

commit 20cdfe9a8a37ce4fd8f91eaf8ab746c712f66602
Author: QuakeWang <[email protected]>
AuthorDate: Tue Jul 14 11:25:40 2026 +0800

    feat: write value stats for append data files (#477)
---
 crates/integrations/datafusion/src/merge_into.rs   | 125 ++++-
 crates/paimon/src/arrow/format/blob.rs             |   6 +-
 crates/paimon/src/arrow/format/mod.rs              |  40 +-
 crates/paimon/src/arrow/format/parquet.rs          | 431 +++++++++++++++--
 crates/paimon/src/arrow/format/row.rs              |   6 +-
 crates/paimon/src/arrow/format/shredding.rs        |   6 +-
 crates/paimon/src/arrow/format/vortex.rs           |  30 +-
 crates/paimon/src/spec/binary_row.rs               | 332 +++++++------
 crates/paimon/src/spec/core_options.rs             | 174 +++++++
 crates/paimon/src/table/data_evolution_reader.rs   |   2 +-
 crates/paimon/src/table/data_file_reader.rs        |  21 +-
 crates/paimon/src/table/data_file_writer.rs        |  35 +-
 .../src/table/dedicated_format_file_writer.rs      |   6 +-
 crates/paimon/src/table/kv_file_writer.rs          |   2 +-
 crates/paimon/src/table/postpone_file_writer.rs    |   4 +-
 crates/paimon/src/table/table_write.rs             | 530 ++++++++++++++++++++-
 16 files changed, 1493 insertions(+), 257 deletions(-)

diff --git a/crates/integrations/datafusion/src/merge_into.rs 
b/crates/integrations/datafusion/src/merge_into.rs
index b73b155..2620d9d 100644
--- a/crates/integrations/datafusion/src/merge_into.rs
+++ b/crates/integrations/datafusion/src/merge_into.rs
@@ -26,7 +26,9 @@ use std::collections::{HashMap, HashSet};
 use std::sync::atomic::{AtomicU64, Ordering};
 use std::sync::Arc;
 
-use datafusion::arrow::array::{Array, Int32Array, RecordBatch, UInt32Array, 
UInt64Array};
+use datafusion::arrow::array::{
+    Array, ArrayRef, Int32Array, RecordBatch, UInt32Array, UInt64Array,
+};
 use datafusion::arrow::compute;
 use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
 use datafusion::error::{DataFusionError, Result as DFResult};
@@ -37,7 +39,7 @@ use datafusion::sql::sqlparser::ast::{
 };
 use futures::TryStreamExt;
 
-use paimon::spec::{datums_to_binary_row, extract_datum_from_arrow, 
CoreOptions};
+use paimon::spec::{datums_to_binary_row, extract_datum_from_arrow, 
CoreOptions, DataField};
 use paimon::table::{CopyOnWriteMergeWriter, DataSplitBuilder, Table, 
WriteBuilder};
 
 use crate::error::to_datafusion_error;
@@ -569,13 +571,6 @@ async fn execute_cow_merge_inner(
 
     // Handle NOT MATCHED → INSERT
     if !clauses.inserts.is_empty() {
-        let table_fields: Vec<String> = table
-            .schema()
-            .fields()
-            .iter()
-            .map(|f| f.name().to_string())
-            .collect();
-
         let insert_sql = if has_target_data {
             format!(
                 "SELECT {s_alias}.* FROM {source_ref} AS {s_alias} \
@@ -595,7 +590,7 @@ async fn execute_cow_merge_inner(
                 &clauses.inserts,
                 s_alias,
                 &[],
-                &table_fields,
+                table.schema().fields(),
                 temp_tracker,
             )
             .await?;
@@ -734,13 +729,6 @@ async fn execute_merge_into_once(
                 injected_columns.push(format!("__upd_{col}"));
             }
         }
-        // Table schema field names for reordering INSERT columns
-        let table_fields: Vec<String> = table
-            .schema()
-            .fields()
-            .iter()
-            .map(|f| f.name().to_string())
-            .collect();
         let mut temp_tracker = TempTableTracker::new(ctx);
         let insert_batches = build_insert_batches(
             ctx,
@@ -748,7 +736,7 @@ async fn execute_merge_into_once(
             &parsed.inserts,
             s_alias,
             &injected_columns,
-            &table_fields,
+            table.schema().fields(),
             &mut temp_tracker,
         )
         .await?;
@@ -854,7 +842,7 @@ async fn build_insert_batches(
     inserts: &[MergeInsertClause],
     s_alias: &str,
     injected_columns: &[String],
-    table_fields: &[String],
+    table_fields: &[DataField],
     temp_tracker: &mut TempTableTracker<'_>,
 ) -> DFResult<Vec<RecordBatch>> {
     if not_matched_batches.is_empty() || not_matched_batches.iter().all(|b| 
b.num_rows() == 0) {
@@ -883,7 +871,7 @@ async fn build_insert_batches_inner(
     inserts: &[MergeInsertClause],
     s_alias: &str,
     tmp_name: &str,
-    table_fields: &[String],
+    table_fields: &[DataField],
 ) -> DFResult<Vec<RecordBatch>> {
     let mut all_batches = Vec::new();
     let mut consumed_predicates: Vec<String> = Vec::new();
@@ -910,12 +898,61 @@ async fn build_insert_batches_inner(
         let sql = format!("SELECT {select_clause} FROM {tmp_name} AS 
{s_alias}{where_clause}");
 
         let batches = ctx.ctx().sql(&sql).await?.collect().await?;
-        all_batches.extend(batches);
+        for batch in batches {
+            all_batches.push(normalize_insert_batch_to_table_schema(
+                &batch,
+                table_fields,
+            )?);
+        }
     }
 
     Ok(all_batches)
 }
 
+fn normalize_insert_batch_to_table_schema(
+    batch: &RecordBatch,
+    table_fields: &[DataField],
+) -> DFResult<RecordBatch> {
+    if batch.num_columns() != table_fields.len() {
+        return Err(DataFusionError::Plan(format!(
+            "MERGE INSERT output has {} columns but target table has {}",
+            batch.num_columns(),
+            table_fields.len()
+        )));
+    }
+
+    let target_schema =
+        
paimon::arrow::build_target_arrow_schema(table_fields).map_err(to_datafusion_error)?;
+    let mut columns = Vec::with_capacity(table_fields.len());
+
+    for (target_idx, field) in table_fields.iter().enumerate() {
+        let column = batch.column(target_idx).clone();
+        let target_type = target_schema.field(target_idx).data_type();
+        let column = cast_insert_column(field.name(), column, target_type)?;
+        columns.push(column);
+    }
+
+    RecordBatch::try_new(target_schema, columns).map_err(DataFusionError::from)
+}
+
+fn cast_insert_column(
+    name: &str,
+    column: ArrayRef,
+    target_type: &ArrowDataType,
+) -> DFResult<ArrayRef> {
+    if column.data_type() == target_type {
+        return Ok(column);
+    }
+
+    compute::cast(column.as_ref(), target_type).map_err(|e| {
+        DataFusionError::Plan(format!(
+            "Cannot cast MERGE INSERT column '{name}' from {:?} to {:?}: {e}",
+            column.data_type(),
+            target_type
+        ))
+    })
+}
+
 /// Remove injected columns from batches, keeping only source columns.
 fn strip_non_source_columns(
     batches: &[RecordBatch],
@@ -946,7 +983,7 @@ fn strip_non_source_columns(
 /// When the INSERT specifies explicit columns (`INSERT (col2, col1) VALUES 
(expr2, expr1)`),
 /// the output must be reordered to match the table schema so that 
`write_arrow_batch`
 /// (which reads columns by positional index) maps them correctly.
-fn insert_select_clause(ins: &MergeInsertClause, table_fields: &[String]) -> 
String {
+fn insert_select_clause(ins: &MergeInsertClause, table_fields: &[DataField]) 
-> String {
     if ins.columns.is_empty() && ins.value_exprs.is_empty() {
         "*".to_string()
     } else {
@@ -962,11 +999,11 @@ fn insert_select_clause(ins: &MergeInsertClause, 
table_fields: &[String]) -> Str
         table_fields
             .iter()
             .map(|field| {
-                let key = field.to_lowercase();
+                let key = field.name().to_lowercase();
                 match col_expr_map.get(&key) {
-                    Some(expr) => format!("{expr} AS {}", 
quote_identifier(field)),
+                    Some(expr) => format!("{expr} AS {}", 
quote_identifier(field.name())),
                     // Column not in INSERT list — fill with NULL
-                    None => format!("NULL AS {}", quote_identifier(field)),
+                    None => format!("NULL AS {}", 
quote_identifier(field.name())),
                 }
             })
             .collect::<Vec<_>>()
@@ -1546,7 +1583,7 @@ mod tests {
     use datafusion::sql::sqlparser::parser::Parser;
     use paimon::catalog::{Catalog, Identifier};
     use paimon::io::FileIOBuilder;
-    use paimon::spec::{DataType, IntType, Schema as PaimonSchema, TableSchema};
+    use paimon::spec::{DataField, DataType, IntType, Schema as PaimonSchema, 
TableSchema};
     use paimon::{CatalogOptions, FileSystemCatalog, Options};
     use tempfile::TempDir;
 
@@ -1613,6 +1650,42 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_normalize_merge_insert_batch_uses_position() {
+        let table_fields = vec![
+            DataField::new(0, "a".to_string(), DataType::Int(IntType::new())),
+            DataField::new(1, "b".to_string(), DataType::Int(IntType::new())),
+        ];
+        let batch = RecordBatch::try_new(
+            Arc::new(Schema::new(vec![
+                Field::new("b", ArrowDataType::Int32, false),
+                Field::new("x", ArrowDataType::Int32, false),
+            ])),
+            vec![
+                Arc::new(Int32Array::from(vec![100])),
+                Arc::new(Int32Array::from(vec![7])),
+            ],
+        )
+        .unwrap();
+
+        let normalized = normalize_insert_batch_to_table_schema(&batch, 
&table_fields).unwrap();
+        let first = normalized
+            .column(0)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+        let second = normalized
+            .column(1)
+            .as_any()
+            .downcast_ref::<Int32Array>()
+            .unwrap();
+
+        assert_eq!(normalized.schema().field(0).name(), "a");
+        assert_eq!(normalized.schema().field(1).name(), "b");
+        assert_eq!(first.value(0), 100);
+        assert_eq!(second.value(0), 7);
+    }
+
     #[test]
     fn test_source_partition_pruning_requires_partition_equality() {
         let merge = parse_merge(
diff --git a/crates/paimon/src/arrow/format/blob.rs 
b/crates/paimon/src/arrow/format/blob.rs
index 65bd2f3..136170e 100644
--- a/crates/paimon/src/arrow/format/blob.rs
+++ b/crates/paimon/src/arrow/format/blob.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use super::{FilePredicates, FormatFileReader, FormatFileWriter};
+use super::{FilePredicates, FormatFileReader, FormatFileWriter, 
FormatWriteResult};
 use crate::arrow::build_target_arrow_schema;
 use crate::io::{FileRead, FileWrite};
 use crate::spec::{BlobDescriptor, DataField, DataType};
@@ -725,7 +725,7 @@ impl FormatFileWriter for BlobFormatWriter {
         Ok(())
     }
 
-    async fn close(mut self: Box<Self>) -> crate::Result<u64> {
+    async fn close(mut self: Box<Self>) -> crate::Result<FormatWriteResult> {
         let index_bytes = encode_delta_varints_write(&self.lengths);
         let index_length = index_bytes.len() as i32;
 
@@ -739,7 +739,7 @@ impl FormatFileWriter for BlobFormatWriter {
 
         let total = self.bytes_written + index_length as u64 + 
BLOB_FOOTER_SIZE;
         self.writer.close().await?;
-        Ok(total)
+        Ok(FormatWriteResult::new(total))
     }
 }
 
diff --git a/crates/paimon/src/arrow/format/mod.rs 
b/crates/paimon/src/arrow/format/mod.rs
index 2a2fcbd..cd981b8 100644
--- a/crates/paimon/src/arrow/format/mod.rs
+++ b/crates/paimon/src/arrow/format/mod.rs
@@ -19,7 +19,7 @@ mod avro;
 pub(crate) mod blob;
 mod mosaic;
 mod orc;
-mod parquet;
+pub(crate) mod parquet;
 mod row;
 mod shredding;
 #[cfg(feature = "vortex")]
@@ -29,6 +29,7 @@ mod vortex;
 pub(crate) use parquet::ParquetFormatWriter;
 
 use crate::io::{FileRead, OutputFile};
+use crate::spec::stats::BinaryTableStats;
 use crate::spec::{DataField, Predicate};
 use crate::table::{ArrowRecordBatchStream, RowRange};
 use crate::Error;
@@ -102,8 +103,40 @@ pub(crate) trait FormatFileWriter: Send {
     async fn flush(&mut self) -> crate::Result<()>;
 
     /// Flush and close the writer, finalizing the file on storage.
-    /// Returns the total number of bytes written.
-    async fn close(self: Box<Self>) -> crate::Result<u64>;
+    async fn close(self: Box<Self>) -> crate::Result<FormatWriteResult>;
+}
+
+pub(crate) struct FormatWriteResult {
+    pub(crate) file_size: u64,
+    pub(crate) value_stats: Option<FormatValueStats>,
+}
+
+pub(crate) struct FormatValueStats {
+    pub(crate) stats: BinaryTableStats,
+    pub(crate) columns: Option<Vec<String>>,
+}
+
+impl FormatWriteResult {
+    pub(crate) fn new(file_size: u64) -> Self {
+        Self {
+            file_size,
+            value_stats: None,
+        }
+    }
+
+    pub(crate) fn with_value_stats(
+        file_size: u64,
+        value_stats: BinaryTableStats,
+        columns: Option<Vec<String>>,
+    ) -> Self {
+        Self {
+            file_size,
+            value_stats: Some(FormatValueStats {
+                stats: value_stats,
+                columns,
+            }),
+        }
+    }
 }
 
 /// Create a format reader based on the file extension.
@@ -180,6 +213,7 @@ pub(crate) async fn create_format_writer(
             output,
             compression,
             zstd_level,
+            format_options.cloned().unwrap_or_default(),
         ));
         shredding::ShreddingFormatWriter::create(
             writer_factory,
diff --git a/crates/paimon/src/arrow/format/parquet.rs 
b/crates/paimon/src/arrow/format/parquet.rs
index 5da5eee..cf55e0b 100644
--- a/crates/paimon/src/arrow/format/parquet.rs
+++ b/crates/paimon/src/arrow/format/parquet.rs
@@ -16,10 +16,14 @@
 // under the License.
 
 use super::shredding::PhysicalFormatWriterFactory;
-use super::{FilePredicates, FormatFileReader, FormatFileWriter};
+use super::{FilePredicates, FormatFileReader, FormatFileWriter, 
FormatWriteResult};
 use crate::arrow::filtering::{predicates_may_match_with_schema, StatsAccessor};
 use crate::io::{FileRead, OutputFile};
-use crate::spec::{DataField, DataType, Datum, Predicate, PredicateOperator};
+use crate::spec::stats::BinaryTableStats;
+use crate::spec::{
+    BinaryRowBuilder, CoreOptions, DataField, DataType, Datum, 
MetadataStatsMode, Predicate,
+    PredicateOperator,
+};
 use crate::table::{ArrowRecordBatchStream, RowRange};
 use crate::Error;
 use arrow_array::{BooleanArray, RecordBatch};
@@ -39,6 +43,7 @@ use parquet::file::metadata::{
 use parquet::file::page_index::column_index::ColumnIndexMetaData;
 use parquet::file::properties::WriterProperties;
 use parquet::file::statistics::Statistics as ParquetStatistics;
+use std::cmp::Ordering;
 use std::collections::HashMap;
 use std::ops::Range;
 use std::sync::Arc;
@@ -49,20 +54,30 @@ pub(crate) struct ParquetFormatReader;
 /// Streams data directly to storage via `AsyncArrowWriter` + opendal.
 pub(crate) struct ParquetFormatWriter {
     inner: AsyncArrowWriter<Box<dyn crate::io::AsyncFileWrite>>,
+    write_fields: Option<Vec<DataField>>,
+    stats_modes: Option<Vec<MetadataStatsMode>>,
+    stats_dense_store: bool,
 }
 
 pub(crate) struct ParquetPhysicalWriterFactory {
     output: OutputFile,
     compression: String,
     zstd_level: i32,
+    format_options: HashMap<String, String>,
 }
 
 impl ParquetPhysicalWriterFactory {
-    pub(crate) fn new(output: &OutputFile, compression: &str, zstd_level: i32) 
-> Self {
+    pub(crate) fn new(
+        output: &OutputFile,
+        compression: &str,
+        zstd_level: i32,
+        format_options: HashMap<String, String>,
+    ) -> Self {
         Self {
             output: output.clone(),
             compression: compression.to_string(),
             zstd_level,
+            format_options,
         }
     }
 }
@@ -72,11 +87,18 @@ impl PhysicalFormatWriterFactory for 
ParquetPhysicalWriterFactory {
     async fn create_writer(
         &mut self,
         schema: arrow_schema::SchemaRef,
-        _write_fields: Option<&[DataField]>,
+        write_fields: Option<&[DataField]>,
     ) -> crate::Result<Box<dyn FormatFileWriter>> {
         Ok(Box::new(
-            ParquetFormatWriter::new(&self.output, schema, &self.compression, 
self.zstd_level)
-                .await?,
+            ParquetFormatWriter::new(
+                &self.output,
+                schema,
+                &self.compression,
+                self.zstd_level,
+                write_fields,
+                &self.format_options,
+            )
+            .await?,
         ))
     }
 }
@@ -87,11 +109,22 @@ impl ParquetFormatWriter {
         schema: arrow_schema::SchemaRef,
         compression: &str,
         zstd_level: i32,
+        write_fields: Option<&[DataField]>,
+        format_options: &HashMap<String, String>,
     ) -> crate::Result<Self> {
         let async_write = output.async_writer().await?;
         let codec = parse_compression(compression, zstd_level);
         let inner = create_parquet_arrow_writer(async_write, schema, codec)?;
-        Ok(Self { inner })
+        let core_options = CoreOptions::new(format_options);
+        let stats_modes = write_fields
+            .map(|fields| 
core_options.metadata_stats_modes(fields.iter().map(DataField::name)))
+            .transpose()?;
+        Ok(Self {
+            inner,
+            write_fields: write_fields.map(|fields| fields.to_vec()),
+            stats_modes,
+            stats_dense_store: core_options.metadata_stats_dense_store(),
+        })
     }
 }
 
@@ -154,15 +187,27 @@ impl FormatFileWriter for ParquetFormatWriter {
             })
     }
 
-    async fn close(mut self: Box<Self>) -> crate::Result<u64> {
-        self.inner
+    async fn close(mut self: Box<Self>) -> crate::Result<FormatWriteResult> {
+        let metadata = self
+            .inner
             .finish()
             .await
             .map_err(|e| crate::Error::DataInvalid {
                 message: format!("Failed to close parquet writer: {e}"),
                 source: None,
             })?;
-        Ok(self.inner.bytes_written() as u64)
+        let file_size = self.inner.bytes_written() as u64;
+        if let (Some(write_fields), Some(stats_modes)) = (&self.write_fields, 
&self.stats_modes) {
+            let (value_stats, value_stats_cols) =
+                extract_value_stats(&metadata, write_fields, stats_modes, 
self.stats_dense_store);
+            Ok(FormatWriteResult::with_value_stats(
+                file_size,
+                value_stats,
+                value_stats_cols,
+            ))
+        } else {
+            Ok(FormatWriteResult::new(file_size))
+        }
     }
 }
 
@@ -625,6 +670,272 @@ fn build_row_group_column_indices(
         .collect()
 }
 
+fn extract_value_stats(
+    metadata: &ParquetMetaData,
+    write_fields: &[DataField],
+    stats_modes: &[MetadataStatsMode],
+    stats_dense_store: bool,
+) -> (BinaryTableStats, Option<Vec<String>>) {
+    debug_assert_eq!(write_fields.len(), stats_modes.len());
+    let row_groups = metadata.row_groups();
+    let column_indices = row_groups
+        .first()
+        .map(|row_group| build_row_group_column_indices(row_group.columns(), 
write_fields))
+        .unwrap_or_else(|| vec![None; write_fields.len()]);
+    let mut column_names = Vec::new();
+    let mut column_types = Vec::new();
+    let mut min_datums = Vec::new();
+    let mut max_datums = Vec::new();
+    let mut null_counts = Vec::new();
+
+    for (field_idx, field) in write_fields.iter().enumerate() {
+        let mode = stats_modes
+            .get(field_idx)
+            .copied()
+            .unwrap_or(MetadataStatsMode::None);
+        let column_stats = if mode == MetadataStatsMode::None {
+            None
+        } else {
+            column_indices
+                .get(field_idx)
+                .copied()
+                .flatten()
+                .and_then(|column_idx| {
+                    extract_column_value_stats(row_groups, column_idx, 
field.data_type(), mode)
+                })
+        };
+
+        // Non-dense stats stay aligned with write_fields, including 
unavailable stats.
+        match column_stats {
+            Some((min_datum, max_datum, null_count)) => {
+                if stats_dense_store {
+                    column_names.push(field.name().to_string());
+                }
+                column_types.push(field.data_type().clone());
+                min_datums.push(min_datum);
+                max_datums.push(max_datum);
+                null_counts.push(null_count);
+            }
+            None if !stats_dense_store => {
+                column_types.push(field.data_type().clone());
+                min_datums.push(None);
+                max_datums.push(None);
+                null_counts.push(None);
+            }
+            None => {}
+        }
+    }
+
+    let stats = if column_types.is_empty() {
+        BinaryTableStats::empty()
+    } else {
+        binary_table_stats_from_datums(&column_types, &min_datums, 
&max_datums, null_counts)
+    };
+    // Java omits the dense mapping when stats already cover every write field.
+    let value_stats_cols = if stats_dense_store && column_types.len() != 
write_fields.len() {
+        Some(column_names)
+    } else {
+        None
+    };
+    (stats, value_stats_cols)
+}
+
+fn extract_column_value_stats(
+    row_groups: &[RowGroupMetaData],
+    column_idx: usize,
+    data_type: &DataType,
+    mode: MetadataStatsMode,
+) -> Option<(Option<Datum>, Option<Datum>, Option<i64>)> {
+    let collect_min_max = matches!(
+        mode,
+        MetadataStatsMode::Full | MetadataStatsMode::Truncate(_)
+    ) && supports_manifest_min_max(data_type);
+    let mut min_datum: Option<Datum> = None;
+    let mut max_datum: Option<Datum> = None;
+    let mut min_complete = true;
+    let mut max_complete = true;
+    let mut null_count = Some(0_i64);
+    let mut has_stats = false;
+
+    for row_group in row_groups {
+        let Some(stats) = row_group.column(column_idx).statistics() else {
+            min_complete = false;
+            max_complete = false;
+            null_count = None;
+            continue;
+        };
+        has_stats = true;
+
+        match stats
+            .null_count_opt()
+            .and_then(|count| i64::try_from(count).ok())
+        {
+            Some(count) => {
+                if let Some(total) = null_count.as_mut() {
+                    *total += count;
+                }
+            }
+            None => null_count = None,
+        }
+
+        let row_group_all_null = stats.null_count_opt() == 
Some(row_group.num_rows().max(0) as u64);
+        if !collect_min_max || row_group_all_null {
+            continue;
+        }
+
+        match parquet_stats_to_datum(stats, data_type, true) {
+            Some(candidate) => {
+                if let Some(current) = &min_datum {
+                    match candidate.partial_cmp(current) {
+                        Some(Ordering::Less) => min_datum = Some(candidate),
+                        Some(Ordering::Equal | Ordering::Greater) => {}
+                        None => min_complete = false,
+                    }
+                } else {
+                    min_datum = Some(candidate);
+                }
+            }
+            None => min_complete = false,
+        }
+
+        match parquet_stats_to_datum(stats, data_type, false) {
+            Some(candidate) => {
+                if let Some(current) = &max_datum {
+                    match candidate.partial_cmp(current) {
+                        Some(Ordering::Greater) => max_datum = Some(candidate),
+                        Some(Ordering::Less | Ordering::Equal) => {}
+                        None => max_complete = false,
+                    }
+                } else {
+                    max_datum = Some(candidate);
+                }
+            }
+            None => max_complete = false,
+        }
+    }
+
+    if !has_stats {
+        return None;
+    }
+    if !min_complete {
+        min_datum = None;
+    }
+    if !max_complete {
+        max_datum = None;
+    }
+    let (min_datum, max_datum) = apply_stats_mode(data_type, mode, min_datum, 
max_datum);
+    if min_datum.is_none() && max_datum.is_none() && null_count.is_none() {
+        None
+    } else {
+        Some((min_datum, max_datum, null_count))
+    }
+}
+
+fn supports_manifest_min_max(data_type: &DataType) -> bool {
+    matches!(
+        data_type,
+        DataType::Boolean(_)
+            | DataType::TinyInt(_)
+            | DataType::SmallInt(_)
+            | DataType::Int(_)
+            | DataType::BigInt(_)
+            | DataType::Char(_)
+            | DataType::VarChar(_)
+            | DataType::Decimal(_)
+            | DataType::Double(_)
+            | DataType::Float(_)
+            | DataType::Date(_)
+            | DataType::Time(_)
+            | DataType::LocalZonedTimestamp(_)
+            | DataType::Timestamp(_)
+    )
+}
+
+fn apply_stats_mode(
+    data_type: &DataType,
+    mode: MetadataStatsMode,
+    min_datum: Option<Datum>,
+    max_datum: Option<Datum>,
+) -> (Option<Datum>, Option<Datum>) {
+    let MetadataStatsMode::Truncate(length) = mode else {
+        return (min_datum, max_datum);
+    };
+    match data_type {
+        DataType::Char(_) | DataType::VarChar(_) => {
+            let min = min_datum.map(|datum| truncate_string_min_datum(datum, 
length));
+            let max = match max_datum {
+                Some(datum) => match truncate_string_max_datum(datum, length) {
+                    Some(max) => Some(max),
+                    None => return (None, None),
+                },
+                None => None,
+            };
+            (min, max)
+        }
+        _ => (min_datum, max_datum),
+    }
+}
+
+fn truncate_string_min_datum(datum: Datum, length: usize) -> Datum {
+    match datum {
+        Datum::String(value) => Datum::String(truncate_string_min(&value, 
length)),
+        other => other,
+    }
+}
+
+fn truncate_string_max_datum(datum: Datum, length: usize) -> Option<Datum> {
+    match datum {
+        Datum::String(value) => truncate_string_max(&value, 
length).map(Datum::String),
+        other => Some(other),
+    }
+}
+
+fn truncate_string_min(value: &str, length: usize) -> String {
+    value.chars().take(length).collect()
+}
+
+fn truncate_string_max(value: &str, length: usize) -> Option<String> {
+    let char_count = value.chars().count();
+    if char_count <= length {
+        return Some(value.to_string());
+    }
+
+    let mut chars: Vec<char> = value.chars().take(length).collect();
+    for idx in (0..chars.len()).rev() {
+        if let Some(next) = char::from_u32(chars[idx] as u32 + 1) {
+            chars.truncate(idx);
+            chars.push(next);
+            return Some(chars.into_iter().collect());
+        }
+    }
+    None
+}
+
+fn binary_table_stats_from_datums(
+    column_types: &[DataType],
+    min_datums: &[Option<Datum>],
+    max_datums: &[Option<Datum>],
+    null_counts: Vec<Option<i64>>,
+) -> BinaryTableStats {
+    let mut min_builder = BinaryRowBuilder::new(column_types.len() as i32);
+    let mut max_builder = BinaryRowBuilder::new(column_types.len() as i32);
+    for (pos, data_type) in column_types.iter().enumerate() {
+        match &min_datums[pos] {
+            Some(datum) => min_builder.write_datum(pos, datum, data_type),
+            None => min_builder.set_null_at(pos),
+        }
+        match &max_datums[pos] {
+            Some(datum) => max_builder.write_datum(pos, datum, data_type),
+            None => max_builder.set_null_at(pos),
+        }
+    }
+    BinaryTableStats::new(
+        min_builder.build_serialized(),
+        max_builder.build_serialized(),
+        null_counts,
+    )
+}
+
 // ---------------------------------------------------------------------------
 // Page-index (ColumnIndex / OffsetIndex) pruning
 // ---------------------------------------------------------------------------
@@ -984,17 +1295,33 @@ fn parquet_stats_to_datum(
                 .copied()
                 .map(Datum::Long)
         }
-        (ParquetStatistics::Int64(stats), DataType::Timestamp(ts)) if 
ts.precision() <= 3 => {
+        (ParquetStatistics::Int32(stats), DataType::Decimal(d)) => {
             exact_parquet_value(is_min, stats.min_opt(), stats.max_opt())
                 .copied()
-                .map(|millis| Datum::Timestamp { millis, nanos: 0 })
+                .map(|unscaled| Datum::Decimal {
+                    unscaled: unscaled as i128,
+                    precision: d.precision(),
+                    scale: d.scale(),
+                })
         }
-        (ParquetStatistics::Int64(stats), DataType::LocalZonedTimestamp(ts))
-            if ts.precision() <= 3 =>
-        {
+        (ParquetStatistics::Int64(stats), DataType::Decimal(d)) => {
             exact_parquet_value(is_min, stats.min_opt(), stats.max_opt())
                 .copied()
-                .map(|millis| Datum::LocalZonedTimestamp { millis, nanos: 0 })
+                .map(|unscaled| Datum::Decimal {
+                    unscaled: unscaled as i128,
+                    precision: d.precision(),
+                    scale: d.scale(),
+                })
+        }
+        (ParquetStatistics::Int64(stats), DataType::Timestamp(ts)) => {
+            exact_parquet_value(is_min, stats.min_opt(), stats.max_opt())
+                .copied()
+                .and_then(|value| timestamp_datum_from_parquet_i64(value, 
ts.precision(), false))
+        }
+        (ParquetStatistics::Int64(stats), DataType::LocalZonedTimestamp(ts)) 
=> {
+            exact_parquet_value(is_min, stats.min_opt(), stats.max_opt())
+                .copied()
+                .and_then(|value| timestamp_datum_from_parquet_i64(value, 
ts.precision(), true))
         }
         (ParquetStatistics::Float(stats), DataType::Float(_)) => {
             exact_parquet_value(is_min, stats.min_opt(), stats.max_opt())
@@ -1022,10 +1349,58 @@ fn parquet_stats_to_datum(
             exact_parquet_value(is_min, stats.min_opt(), stats.max_opt())
                 .map(|value| Datum::Bytes(value.data().to_vec()))
         }
+        (ParquetStatistics::ByteArray(stats), DataType::Decimal(d)) => {
+            exact_parquet_value(is_min, stats.min_opt(), stats.max_opt())
+                .and_then(|value| signed_be_bytes_to_i128(value.data()))
+                .map(|unscaled| Datum::Decimal {
+                    unscaled,
+                    precision: d.precision(),
+                    scale: d.scale(),
+                })
+        }
+        (ParquetStatistics::FixedLenByteArray(stats), DataType::Decimal(d)) => 
{
+            exact_parquet_value(is_min, stats.min_opt(), stats.max_opt())
+                .and_then(|value| signed_be_bytes_to_i128(value.data()))
+                .map(|unscaled| Datum::Decimal {
+                    unscaled,
+                    precision: d.precision(),
+                    scale: d.scale(),
+                })
+        }
         _ => None,
     }
 }
 
+fn timestamp_datum_from_parquet_i64(
+    value: i64,
+    precision: u32,
+    local_zoned: bool,
+) -> Option<Datum> {
+    let (millis, nanos) = match precision {
+        0..=3 => (value, 0),
+        4..=6 => (
+            value.div_euclid(1_000),
+            (value.rem_euclid(1_000) * 1_000) as i32,
+        ),
+        _ => return None,
+    };
+    if local_zoned {
+        Some(Datum::LocalZonedTimestamp { millis, nanos })
+    } else {
+        Some(Datum::Timestamp { millis, nanos })
+    }
+}
+
+fn signed_be_bytes_to_i128(bytes: &[u8]) -> Option<i128> {
+    if bytes.is_empty() || bytes.len() > 16 {
+        return None;
+    }
+    let sign_extend = if bytes[0] & 0x80 == 0 { 0 } else { 0xff };
+    let mut padded = [sign_extend; 16];
+    padded[16 - bytes.len()..].copy_from_slice(bytes);
+    Some(i128::from_be_bytes(padded))
+}
+
 fn exact_parquet_value<'a, T>(
     is_min: bool,
     min: Option<&'a T>,
@@ -1766,14 +2141,14 @@ mod tests {
         let schema = writer_arrow_schema();
 
         let mut writer: Box<dyn FormatFileWriter> = Box::new(
-            ParquetFormatWriter::new(&output, schema.clone(), "zstd", 1)
+            ParquetFormatWriter::new(&output, schema.clone(), "zstd", 1, None, 
&HashMap::new())
                 .await
                 .unwrap(),
         );
 
         let batch = writer_test_batch(&schema, vec![1, 2, 3], vec![10, 20, 
30]);
         writer.write(&batch).await.unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         // Verify valid parquet by reading back
         let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
@@ -1791,7 +2166,7 @@ mod tests {
         let schema = writer_arrow_schema();
 
         let mut writer: Box<dyn FormatFileWriter> = Box::new(
-            ParquetFormatWriter::new(&output, schema.clone(), "zstd", 1)
+            ParquetFormatWriter::new(&output, schema.clone(), "zstd", 1, None, 
&HashMap::new())
                 .await
                 .unwrap(),
         );
@@ -1804,7 +2179,7 @@ mod tests {
             .write(&writer_test_batch(&schema, vec![3, 4, 5], vec![30, 40, 
50]))
             .await
             .unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
         let reader =
@@ -1844,12 +2219,12 @@ mod tests {
         let path = "memory:/test_parquet_inline_vector.parquet";
         let output = file_io.new_output(path).unwrap();
         let mut writer: Box<dyn FormatFileWriter> = Box::new(
-            ParquetFormatWriter::new(&output, schema.clone(), "zstd", 1)
+            ParquetFormatWriter::new(&output, schema.clone(), "zstd", 1, None, 
&HashMap::new())
                 .await
                 .unwrap(),
         );
         writer.write(&batch).await.unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let bytes = file_io.new_input(path).unwrap().read().await.unwrap();
         let reader =
@@ -1936,7 +2311,7 @@ mod tests {
         .await
         .unwrap();
         writer.write(&batch).await.unwrap();
-        let file_size = writer.close().await.unwrap();
+        let file_size = writer.close().await.unwrap().file_size;
 
         let raw_bytes = 
file_io.new_input(&path).unwrap().read().await.unwrap();
         let raw_batches =
@@ -2069,7 +2444,7 @@ mod tests {
             .write(&make_batch(vec![3], &late_variants))
             .await
             .unwrap();
-        let file_size = writer.close().await.unwrap();
+        let file_size = writer.close().await.unwrap().file_size;
 
         let raw_bytes = 
file_io.new_input(&path).unwrap().read().await.unwrap();
         let raw_batches =
@@ -2153,7 +2528,7 @@ mod tests {
                 .write(&writer_test_batch(&schema, ids, values))
                 .await
                 .unwrap();
-            writer.close().await.unwrap();
+            let _ = writer.close().await.unwrap();
         }
         buf
     }
@@ -2296,7 +2671,7 @@ mod tests {
                 .write(&writer_test_batch(&schema, ids, values))
                 .await
                 .unwrap();
-            writer.close().await.unwrap();
+            let _ = writer.close().await.unwrap();
         }
         buf
     }
@@ -2361,7 +2736,7 @@ mod tests {
                 .write(&writer_test_batch(&schema, ids, values))
                 .await
                 .unwrap();
-            writer.close().await.unwrap();
+            let _ = writer.close().await.unwrap();
         }
         let metadata = load_metadata_with_page_index(&buf, true);
         let fields = vec![int_field("id"), int_field("value")];
@@ -2417,7 +2792,7 @@ mod tests {
             let mut writer =
                 AsyncArrowWriter::try_new(&mut buf, schema.clone(), 
Some(props)).unwrap();
             writer.write(&batch).await.unwrap();
-            writer.close().await.unwrap();
+            let _ = writer.close().await.unwrap();
         }
         buf
     }
diff --git a/crates/paimon/src/arrow/format/row.rs 
b/crates/paimon/src/arrow/format/row.rs
index 0f86993..94b740f 100644
--- a/crates/paimon/src/arrow/format/row.rs
+++ b/crates/paimon/src/arrow/format/row.rs
@@ -20,7 +20,7 @@
 //! Layout reference:
 //! `org.apache.paimon.format.row.RowFormatWriter` in paimon-java.
 
-use super::{FilePredicates, FormatFileReader, FormatFileWriter};
+use super::{FilePredicates, FormatFileReader, FormatFileWriter, 
FormatWriteResult};
 use crate::arrow::{
     arrow_to_paimon_type, build_target_arrow_schema, is_variant_arrow_fields, 
paimon_type_to_arrow,
     variant_arrow_type,
@@ -172,7 +172,7 @@ impl FormatFileWriter for RowFormatWriter {
         self.flush_block().await
     }
 
-    async fn close(mut self: Box<Self>) -> crate::Result<u64> {
+    async fn close(mut self: Box<Self>) -> crate::Result<FormatWriteResult> {
         self.flush_block().await?;
 
         let index_offset = self.bytes_written;
@@ -206,7 +206,7 @@ impl FormatFileWriter for RowFormatWriter {
         self.writer.write(Bytes::from(footer_bytes)).await?;
         self.bytes_written += FOOTER_SIZE;
         self.writer.close().await?;
-        Ok(self.bytes_written)
+        Ok(FormatWriteResult::new(self.bytes_written))
     }
 }
 
diff --git a/crates/paimon/src/arrow/format/shredding.rs 
b/crates/paimon/src/arrow/format/shredding.rs
index d33e98a..df283a9 100644
--- a/crates/paimon/src/arrow/format/shredding.rs
+++ b/crates/paimon/src/arrow/format/shredding.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use super::{FilePredicates, FormatFileReader, FormatFileWriter};
+use super::{FilePredicates, FormatFileReader, FormatFileWriter, 
FormatWriteResult};
 use crate::arrow::build_target_arrow_schema;
 use crate::arrow::shredding::{
     assemble_shredded_variant_batch, batch_to_shredded_physical,
@@ -289,12 +289,12 @@ impl FormatFileWriter for ShreddingFormatWriter {
         }
     }
 
-    async fn close(mut self: Box<Self>) -> crate::Result<u64> {
+    async fn close(mut self: Box<Self>) -> crate::Result<FormatWriteResult> {
         self.finalize_inferred_writer().await?;
         match std::mem::replace(&mut self.state, ShreddingWriterState::Closed) 
{
             ShreddingWriterState::Ready { inner, .. } => inner.close().await,
             ShreddingWriterState::Infer { .. } => unreachable!("infer writer 
finalized above"),
-            ShreddingWriterState::Closed => Ok(0),
+            ShreddingWriterState::Closed => Ok(FormatWriteResult::new(0)),
         }
     }
 }
diff --git a/crates/paimon/src/arrow/format/vortex.rs 
b/crates/paimon/src/arrow/format/vortex.rs
index 12250a7..631f8ad 100644
--- a/crates/paimon/src/arrow/format/vortex.rs
+++ b/crates/paimon/src/arrow/format/vortex.rs
@@ -15,7 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use super::{FilePredicates, FormatFileReader, FormatFileWriter};
+use super::{FilePredicates, FormatFileReader, FormatFileWriter, 
FormatWriteResult};
 use crate::arrow::residual::{
     filter_record_batch_by_predicates, same_data_field, widen_scan_fields,
 };
@@ -480,7 +480,7 @@ impl FormatFileWriter for VortexFormatWriter {
         Ok(())
     }
 
-    async fn close(self: Box<Self>) -> crate::Result<u64> {
+    async fn close(self: Box<Self>) -> crate::Result<FormatWriteResult> {
         let this = *self;
         let VortexFormatWriter {
             dtype,
@@ -502,7 +502,7 @@ impl FormatFileWriter for VortexFormatWriter {
         output.write(bytes::Bytes::from(buffer)).await?;
         bytes_written.store(size, Ordering::Relaxed);
 
-        Ok(size)
+        Ok(FormatWriteResult::new(size))
     }
 }
 
@@ -628,10 +628,10 @@ mod tests {
             .enable_all()
             .build()
             .unwrap();
-        let bytes = verifier_runtime
+        let result = verifier_runtime
             .block_on(async { Box::new(writer).close().await })
             .unwrap();
-        assert!(bytes > 0);
+        assert!(result.file_size > 0);
     }
 
     #[test]
@@ -701,8 +701,8 @@ mod tests {
 
         let batch = test_batch(&schema, vec![1, 2, 3], vec![10, 20, 30]);
         writer.write(&batch).await.unwrap();
-        let bytes = writer.close().await.unwrap();
-        assert!(bytes > 0);
+        let result = writer.close().await.unwrap();
+        assert!(result.file_size > 0);
 
         // Read back using VortexFormatReader.
         let input = file_io.new_input(path).unwrap();
@@ -767,7 +767,7 @@ mod tests {
         )
         .unwrap();
         writer.write(&batch).await.unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let input = file_io.new_input(path).unwrap();
         let file_reader = input.reader().await.unwrap();
@@ -836,7 +836,7 @@ mod tests {
             .write(&test_batch(&schema, vec![3, 4, 5], vec![30, 40, 50]))
             .await
             .unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let input = file_io.new_input(path).unwrap();
         let file_reader = input.reader().await.unwrap();
@@ -897,7 +897,7 @@ mod tests {
             ))
             .await
             .unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let input = file_io.new_input(path).unwrap();
         let file_reader = input.reader().await.unwrap();
@@ -965,7 +965,7 @@ mod tests {
             ))
             .await
             .unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let input = file_io.new_input(path).unwrap();
         let file_reader = input.reader().await.unwrap();
@@ -1022,7 +1022,7 @@ mod tests {
         )
         .unwrap();
         writer.write(&batch).await.unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let input = file_io.new_input(path).unwrap();
         let file_bytes = input.read().await.unwrap();
@@ -1098,7 +1098,7 @@ mod tests {
             ))
             .await
             .unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let input = file_io.new_input(path).unwrap();
         let file_reader = input.reader().await.unwrap();
@@ -1229,7 +1229,7 @@ mod tests {
             ))
             .await
             .unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let fields = test_file_fields();
         let builder = PredicateBuilder::new(&fields);
@@ -1349,7 +1349,7 @@ mod tests {
             ))
             .await
             .unwrap();
-        writer.close().await.unwrap();
+        let _ = writer.close().await.unwrap();
 
         let input = file_io.new_input(path).unwrap();
         let file_reader = input.reader().await.unwrap();
diff --git a/crates/paimon/src/spec/binary_row.rs 
b/crates/paimon/src/spec/binary_row.rs
index d96fa95..4a4017f 100644
--- a/crates/paimon/src/spec/binary_row.rs
+++ b/crates/paimon/src/spec/binary_row.rs
@@ -859,48 +859,24 @@ pub fn extract_datum_from_arrow(
         }
         DataType::Variant(_) => extract_variant_datum_from_arrow(col, row_idx, 
col_idx)?,
         DataType::Timestamp(ts) => {
-            if ts.precision() <= 3 {
-                let arr = col
-                    .as_any()
-                    .downcast_ref::<arrow_array::TimestampMillisecondArray>()
-                    .ok_or_else(|| type_mismatch_err("Timestamp(ms)", 
col_idx))?;
-                Datum::Timestamp {
-                    millis: arr.value(row_idx),
-                    nanos: 0,
-                }
-            } else {
-                let arr = col
-                    .as_any()
-                    .downcast_ref::<arrow_array::TimestampMicrosecondArray>()
-                    .ok_or_else(|| type_mismatch_err("Timestamp(us)", 
col_idx))?;
-                let micros = arr.value(row_idx);
-                Datum::Timestamp {
-                    millis: micros.div_euclid(1_000),
-                    nanos: (micros.rem_euclid(1_000) * 1_000) as i32,
-                }
-            }
+            let (millis, nanos) = extract_timestamp_parts_from_arrow(
+                col,
+                row_idx,
+                col_idx,
+                ts.precision(),
+                "Timestamp",
+            )?;
+            Datum::Timestamp { millis, nanos }
         }
         DataType::LocalZonedTimestamp(ts) => {
-            if ts.precision() <= 3 {
-                let arr = col
-                    .as_any()
-                    .downcast_ref::<arrow_array::TimestampMillisecondArray>()
-                    .ok_or_else(|| 
type_mismatch_err("LocalZonedTimestamp(ms)", col_idx))?;
-                Datum::LocalZonedTimestamp {
-                    millis: arr.value(row_idx),
-                    nanos: 0,
-                }
-            } else {
-                let arr = col
-                    .as_any()
-                    .downcast_ref::<arrow_array::TimestampMicrosecondArray>()
-                    .ok_or_else(|| 
type_mismatch_err("LocalZonedTimestamp(us)", col_idx))?;
-                let micros = arr.value(row_idx);
-                Datum::LocalZonedTimestamp {
-                    millis: micros.div_euclid(1_000),
-                    nanos: (micros.rem_euclid(1_000) * 1_000) as i32,
-                }
-            }
+            let (millis, nanos) = extract_timestamp_parts_from_arrow(
+                col,
+                row_idx,
+                col_idx,
+                ts.precision(),
+                "LocalZonedTimestamp",
+            )?;
+            Datum::LocalZonedTimestamp { millis, nanos }
         }
         _ => {
             return Err(crate::Error::Unsupported {
@@ -915,6 +891,55 @@ pub fn extract_datum_from_arrow(
     Ok(Some(datum))
 }
 
+fn extract_timestamp_parts_from_arrow(
+    col: &std::sync::Arc<dyn arrow_array::Array>,
+    row_idx: usize,
+    col_idx: usize,
+    precision: u32,
+    expected: &str,
+) -> crate::Result<(i64, i32)> {
+    match precision {
+        0..=3 => {
+            let arr = col
+                .as_any()
+                .downcast_ref::<arrow_array::TimestampMillisecondArray>()
+                .ok_or_else(|| type_mismatch_err(&format!("{expected}(ms)"), 
col_idx))?;
+            Ok((arr.value(row_idx), 0))
+        }
+        4..=6 => {
+            let arr = col
+                .as_any()
+                .downcast_ref::<arrow_array::TimestampMicrosecondArray>()
+                .ok_or_else(|| type_mismatch_err(&format!("{expected}(us)"), 
col_idx))?;
+            Ok(timestamp_parts_from_micros(arr.value(row_idx)))
+        }
+        7..=9 => {
+            let arr = col
+                .as_any()
+                .downcast_ref::<arrow_array::TimestampNanosecondArray>()
+                .ok_or_else(|| type_mismatch_err(&format!("{expected}(ns)"), 
col_idx))?;
+            Ok(timestamp_parts_from_nanos(arr.value(row_idx)))
+        }
+        _ => Err(crate::Error::Unsupported {
+            message: format!("Unsupported {expected} precision {precision}"),
+        }),
+    }
+}
+
+fn timestamp_parts_from_micros(micros: i64) -> (i64, i32) {
+    (
+        micros.div_euclid(1_000),
+        (micros.rem_euclid(1_000) * 1_000) as i32,
+    )
+}
+
+fn timestamp_parts_from_nanos(nanos: i64) -> (i64, i32) {
+    (
+        nanos.div_euclid(1_000_000),
+        nanos.rem_euclid(1_000_000) as i32,
+    )
+}
+
 fn encode_variant_bytes(value: &[u8], metadata: &[u8]) -> 
crate::Result<Vec<u8>> {
     VariantType::validate_payload(value, metadata)?;
     let mut bytes = Vec::with_capacity(4 + value.len() + metadata.len());
@@ -1045,6 +1070,7 @@ enum TypedColumn<'a> {
     Variant(&'a arrow_array::StructArray),
     TimestampMs(&'a arrow_array::TimestampMillisecondArray),
     TimestampUs(&'a arrow_array::TimestampMicrosecondArray),
+    TimestampNs(&'a arrow_array::TimestampNanosecondArray),
 }
 
 /// Downcast Arrow columns once, returning typed references paired with their 
DataType.
@@ -1059,118 +1085,134 @@ fn downcast_columns<'a>(
         .map(|&col_idx| {
             let field = &fields[col_idx];
             let col = batch.column(col_idx);
-            let typed =
-                match field.data_type() {
-                    DataType::Boolean(_) => TypedColumn::Boolean(
-                        col.as_any()
-                            .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("Boolean", 
col_idx))?,
-                    ),
-                    DataType::TinyInt(_) => TypedColumn::Int8(
-                        col.as_any()
-                            .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("TinyInt", 
col_idx))?,
-                    ),
-                    DataType::SmallInt(_) => TypedColumn::Int16(
-                        col.as_any()
-                            .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("SmallInt", 
col_idx))?,
-                    ),
-                    DataType::Int(_) => TypedColumn::Int32(
-                        col.as_any()
-                            .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("Int", col_idx))?,
-                    ),
-                    DataType::BigInt(_) => TypedColumn::Int64(
-                        col.as_any()
-                            .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("BigInt", 
col_idx))?,
-                    ),
-                    DataType::Float(_) => TypedColumn::Float32(
-                        col.as_any()
-                            .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("Float", 
col_idx))?,
-                    ),
-                    DataType::Double(_) => TypedColumn::Float64(
-                        col.as_any()
-                            .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("Double", 
col_idx))?,
-                    ),
-                    DataType::Char(_) | DataType::VarChar(_) => {
-                        if let Some(arr) = 
col.as_any().downcast_ref::<arrow_array::StringArray>() {
-                            TypedColumn::Utf8(arr)
-                        } else if let Some(arr) =
-                            
col.as_any().downcast_ref::<arrow_array::StringViewArray>()
-                        {
-                            TypedColumn::Utf8View(arr)
-                        } else if let Some(arr) =
-                            
col.as_any().downcast_ref::<arrow_array::LargeStringArray>()
-                        {
-                            TypedColumn::LargeUtf8(arr)
-                        } else {
-                            return Err(type_mismatch_err("String", col_idx));
-                        }
+            let typed = match field.data_type() {
+                DataType::Boolean(_) => TypedColumn::Boolean(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("Boolean", col_idx))?,
+                ),
+                DataType::TinyInt(_) => TypedColumn::Int8(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("TinyInt", col_idx))?,
+                ),
+                DataType::SmallInt(_) => TypedColumn::Int16(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("SmallInt", 
col_idx))?,
+                ),
+                DataType::Int(_) => TypedColumn::Int32(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("Int", col_idx))?,
+                ),
+                DataType::BigInt(_) => TypedColumn::Int64(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("BigInt", col_idx))?,
+                ),
+                DataType::Float(_) => TypedColumn::Float32(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("Float", col_idx))?,
+                ),
+                DataType::Double(_) => TypedColumn::Float64(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("Double", col_idx))?,
+                ),
+                DataType::Char(_) | DataType::VarChar(_) => {
+                    if let Some(arr) = 
col.as_any().downcast_ref::<arrow_array::StringArray>() {
+                        TypedColumn::Utf8(arr)
+                    } else if let Some(arr) =
+                        
col.as_any().downcast_ref::<arrow_array::StringViewArray>()
+                    {
+                        TypedColumn::Utf8View(arr)
+                    } else if let Some(arr) =
+                        
col.as_any().downcast_ref::<arrow_array::LargeStringArray>()
+                    {
+                        TypedColumn::LargeUtf8(arr)
+                    } else {
+                        return Err(type_mismatch_err("String", col_idx));
                     }
-                    DataType::Date(_) => TypedColumn::Date32(
+                }
+                DataType::Date(_) => TypedColumn::Date32(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("Date", col_idx))?,
+                ),
+                DataType::Decimal(d) => TypedColumn::Decimal128(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("Decimal", col_idx))?,
+                    d.precision(),
+                    d.scale(),
+                ),
+                DataType::Binary(_) | DataType::VarBinary(_) => 
TypedColumn::Binary(
+                    col.as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("Binary", col_idx))?,
+                ),
+                DataType::Variant(_) => {
+                    let arr = col
+                        .as_any()
+                        .downcast_ref()
+                        .ok_or_else(|| type_mismatch_err("Variant", col_idx))?;
+                    validate_variant_struct_array(arr, col_idx)?;
+                    TypedColumn::Variant(arr)
+                }
+                DataType::Timestamp(ts) => match ts.precision() {
+                    0..=3 => TypedColumn::TimestampMs(
                         col.as_any()
                             .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("Date", 
col_idx))?,
+                            .ok_or_else(|| type_mismatch_err("Timestamp(ms)", 
col_idx))?,
                     ),
-                    DataType::Decimal(d) => TypedColumn::Decimal128(
+                    4..=6 => TypedColumn::TimestampUs(
                         col.as_any()
                             .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("Decimal", 
col_idx))?,
-                        d.precision(),
-                        d.scale(),
+                            .ok_or_else(|| type_mismatch_err("Timestamp(us)", 
col_idx))?,
                     ),
-                    DataType::Binary(_) | DataType::VarBinary(_) => 
TypedColumn::Binary(
+                    7..=9 => TypedColumn::TimestampNs(
                         col.as_any()
                             .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("Binary", 
col_idx))?,
+                            .ok_or_else(|| type_mismatch_err("Timestamp(ns)", 
col_idx))?,
                     ),
-                    DataType::Variant(_) => {
-                        let arr = col
-                            .as_any()
-                            .downcast_ref()
-                            .ok_or_else(|| type_mismatch_err("Variant", 
col_idx))?;
-                        validate_variant_struct_array(arr, col_idx)?;
-                        TypedColumn::Variant(arr)
-                    }
-                    DataType::Timestamp(ts) => {
-                        if ts.precision() <= 3 {
-                            TypedColumn::TimestampMs(
-                                col.as_any()
-                                    .downcast_ref()
-                                    .ok_or_else(|| 
type_mismatch_err("Timestamp(ms)", col_idx))?,
-                            )
-                        } else {
-                            TypedColumn::TimestampUs(
-                                col.as_any()
-                                    .downcast_ref()
-                                    .ok_or_else(|| 
type_mismatch_err("Timestamp(us)", col_idx))?,
-                            )
-                        }
-                    }
-                    DataType::LocalZonedTimestamp(ts) => {
-                        if ts.precision() <= 3 {
-                            
TypedColumn::TimestampMs(col.as_any().downcast_ref().ok_or_else(
-                                || 
type_mismatch_err("LocalZonedTimestamp(ms)", col_idx),
-                            )?)
-                        } else {
-                            
TypedColumn::TimestampUs(col.as_any().downcast_ref().ok_or_else(
-                                || 
type_mismatch_err("LocalZonedTimestamp(us)", col_idx),
-                            )?)
-                        }
-                    }
-                    other => {
+                    _ => {
                         return Err(crate::Error::Unsupported {
-                            message: format!(
-                                "Unsupported data type {:?} for batch column 
downcast at column {}",
-                                other, col_idx
-                            ),
+                            message: format!("Unsupported Timestamp precision 
{}", ts.precision()),
                         });
                     }
-                };
+                },
+                DataType::LocalZonedTimestamp(ts) => {
+                    match ts.precision() {
+                        0..=3 => 
TypedColumn::TimestampMs(col.as_any().downcast_ref().ok_or_else(
+                            || type_mismatch_err("LocalZonedTimestamp(ms)", 
col_idx),
+                        )?),
+                        4..=6 => 
TypedColumn::TimestampUs(col.as_any().downcast_ref().ok_or_else(
+                            || type_mismatch_err("LocalZonedTimestamp(us)", 
col_idx),
+                        )?),
+                        7..=9 => 
TypedColumn::TimestampNs(col.as_any().downcast_ref().ok_or_else(
+                            || type_mismatch_err("LocalZonedTimestamp(ns)", 
col_idx),
+                        )?),
+                        _ => {
+                            return Err(crate::Error::Unsupported {
+                                message: format!(
+                                    "Unsupported LocalZonedTimestamp precision 
{}",
+                                    ts.precision()
+                                ),
+                            });
+                        }
+                    }
+                }
+                other => {
+                    return Err(crate::Error::Unsupported {
+                        message: format!(
+                            "Unsupported data type {:?} for batch column 
downcast at column {}",
+                            other, col_idx
+                        ),
+                    });
+                }
+            };
             Ok((typed, field))
         })
         .collect()
@@ -1339,9 +1381,15 @@ fn write_typed_value(
             if arr.is_null(row_idx) {
                 builder.set_null_at(pos);
             } else {
-                let micros = arr.value(row_idx);
-                let millis = micros.div_euclid(1_000);
-                let nanos = (micros.rem_euclid(1_000) * 1_000) as i32;
+                let (millis, nanos) = 
timestamp_parts_from_micros(arr.value(row_idx));
+                builder.write_timestamp_non_compact(pos, millis, nanos);
+            }
+        }
+        TypedColumn::TimestampNs(arr) => {
+            if arr.is_null(row_idx) {
+                builder.set_null_at(pos);
+            } else {
+                let (millis, nanos) = 
timestamp_parts_from_nanos(arr.value(row_idx));
                 builder.write_timestamp_non_compact(pos, millis, nanos);
             }
         }
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 1e1df3e..60d33ff 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -55,6 +55,14 @@ const CHANGELOG_FILE_PREFIX_OPTION: &str = 
"changelog-file.prefix";
 const CHANGELOG_FILE_FORMAT_OPTION: &str = "changelog-file.format";
 const CHANGELOG_FILE_COMPRESSION_OPTION: &str = "changelog-file.compression";
 const CHANGELOG_FILE_STATS_MODE_OPTION: &str = "changelog-file.stats-mode";
+const METADATA_STATS_MODE_OPTION: &str = "metadata.stats-mode";
+const METADATA_STATS_DENSE_STORE_OPTION: &str = "metadata.stats-dense-store";
+const METADATA_STATS_KEEP_FIRST_N_COLUMNS_OPTION: &str = 
"metadata.stats-keep-first-n-columns";
+const DEFAULT_METADATA_STATS_MODE: &str = "truncate(16)";
+const DEFAULT_METADATA_STATS_DENSE_STORE: bool = true;
+const DEFAULT_METADATA_STATS_KEEP_FIRST_N_COLUMNS: i32 = -1;
+const FIELDS_PREFIX: &str = "fields";
+const STATS_MODE_SUFFIX: &str = "stats-mode";
 const ROW_TRACKING_ENABLED_OPTION: &str = "row-tracking.enabled";
 pub(crate) const TABLE_TYPE_OPTION: &str = "type";
 pub(crate) const FORMAT_TABLE_TYPE: &str = "format-table";
@@ -160,6 +168,56 @@ pub enum GlobalIndexSearchMode {
     Detail,
 }
 
+/// Metadata stats collection mode.
+///
+/// Reference: Java `SimpleColStatsCollector.from`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum MetadataStatsMode {
+    None,
+    Counts,
+    Full,
+    Truncate(usize),
+}
+
+impl MetadataStatsMode {
+    pub(crate) fn parse(option_name: &str, value: &str) -> crate::Result<Self> 
{
+        let value = value.trim();
+        let upper = value.to_ascii_uppercase();
+        match upper.as_str() {
+            "NONE" => Ok(Self::None),
+            "COUNTS" => Ok(Self::Counts),
+            "FULL" => Ok(Self::Full),
+            _ => {
+                let Some(length) = upper
+                    .strip_prefix("TRUNCATE(")
+                    .and_then(|value| value.strip_suffix(')'))
+                else {
+                    return Err(crate::Error::Unsupported {
+                        message: format!("Unsupported {option_name}: 
'{value}'"),
+                    });
+                };
+                let length = length
+                    .parse::<usize>()
+                    .map_err(|e| crate::Error::DataInvalid {
+                        message: format!(
+                            "Option '{option_name}' must use truncate(N) with 
a positive integer, got: {value}"
+                        ),
+                        source: Some(Box::new(e)),
+                    })?;
+                if length == 0 {
+                    return Err(crate::Error::DataInvalid {
+                        message: format!(
+                            "Option '{option_name}' must use truncate(N) with 
N > 0, got: {value}"
+                        ),
+                        source: None,
+                    });
+                }
+                Ok(Self::Truncate(length))
+            }
+        }
+    }
+}
+
 /// Bucket function used to map bucket keys to fixed bucket ids.
 ///
 /// Reference: Java `CoreOptions.BucketFunctionType`.
@@ -846,6 +904,79 @@ impl<'a> CoreOptions<'a> {
             .map(String::as_str)
     }
 
+    /// Whether metadata stats should omit columns without collected stats.
+    pub(crate) fn metadata_stats_dense_store(&self) -> bool {
+        self.options
+            .get(METADATA_STATS_DENSE_STORE_OPTION)
+            .map(|value| value.eq_ignore_ascii_case("true"))
+            .unwrap_or(DEFAULT_METADATA_STATS_DENSE_STORE)
+    }
+
+    /// Table-wide metadata stats mode.
+    pub(crate) fn metadata_stats_mode(&self) -> 
crate::Result<MetadataStatsMode> {
+        let value = self
+            .options
+            .get(METADATA_STATS_MODE_OPTION)
+            .map(String::as_str)
+            .unwrap_or(DEFAULT_METADATA_STATS_MODE);
+        MetadataStatsMode::parse(METADATA_STATS_MODE_OPTION, value)
+    }
+
+    /// Number of leading columns whose stats should be kept.
+    ///
+    /// A negative value means the option is ignored, matching Java Paimon.
+    pub(crate) fn metadata_stats_keep_first_n_columns(&self) -> 
crate::Result<i32> {
+        self.options
+            .get(METADATA_STATS_KEEP_FIRST_N_COLUMNS_OPTION)
+            .map(|value| {
+                value.parse::<i32>().map_err(|e| crate::Error::DataInvalid {
+                    message: format!(
+                        "Invalid value for 
{METADATA_STATS_KEEP_FIRST_N_COLUMNS_OPTION}: '{value}'"
+                    ),
+                    source: Some(Box::new(e)),
+                })
+            })
+            .transpose()
+            .map(|value| 
value.unwrap_or(DEFAULT_METADATA_STATS_KEEP_FIRST_N_COLUMNS))
+    }
+
+    /// Per-field metadata stats mode.
+    pub(crate) fn field_metadata_stats_mode(
+        &self,
+        field_name: &str,
+    ) -> crate::Result<Option<MetadataStatsMode>> {
+        let option_name = 
format!("{FIELDS_PREFIX}.{field_name}.{STATS_MODE_SUFFIX}");
+        self.options
+            .get(&option_name)
+            .map(|value| MetadataStatsMode::parse(&option_name, value))
+            .transpose()
+    }
+
+    /// Resolve metadata stats modes for fields using Java's priority:
+    /// field override > keep-first-n > table-wide mode.
+    pub(crate) fn metadata_stats_modes<'b, I>(
+        &self,
+        field_names: I,
+    ) -> crate::Result<Vec<MetadataStatsMode>>
+    where
+        I: IntoIterator<Item = &'b str>,
+    {
+        let table_mode = self.metadata_stats_mode()?;
+        let keep_first_n = self.metadata_stats_keep_first_n_columns()?;
+        let mut modes = Vec::new();
+        for (column_count, field_name) in field_names.into_iter().enumerate() {
+            let mode = if let Some(field_mode) = 
self.field_metadata_stats_mode(field_name)? {
+                field_mode
+            } else if keep_first_n >= 0 && column_count >= keep_first_n as 
usize {
+                MetadataStatsMode::None
+            } else {
+                table_mode
+            };
+            modes.push(mode);
+        }
+        Ok(modes)
+    }
+
     /// Avro compression codec for manifest, manifest-list and index-manifest 
files.
     /// Default is `"zstd"`, matching Java Paimon 
`CoreOptions.MANIFEST_COMPRESSION`.
     pub fn manifest_compression(&self) -> &str {
@@ -1351,6 +1482,49 @@ mod tests {
         assert_eq!(custom_core.changelog_file_stats_mode(), Some("counts"));
     }
 
+    #[test]
+    fn test_metadata_stats_modes_follow_java_priority() {
+        let options = HashMap::from([
+            (METADATA_STATS_MODE_OPTION.to_string(), "counts".to_string()),
+            (
+                METADATA_STATS_KEEP_FIRST_N_COLUMNS_OPTION.to_string(),
+                "2".to_string(),
+            ),
+            ("fields.name.stats-mode".to_string(), "full".to_string()),
+            (
+                "fields.payload.stats-mode".to_string(),
+                "truncate(8)".to_string(),
+            ),
+        ]);
+        let core = CoreOptions::new(&options);
+
+        assert_eq!(
+            core.metadata_stats_modes(["id", "name", "payload", "extra"])
+                .unwrap(),
+            vec![
+                MetadataStatsMode::Counts,
+                MetadataStatsMode::Full,
+                MetadataStatsMode::Truncate(8),
+                MetadataStatsMode::None,
+            ]
+        );
+    }
+
+    #[test]
+    fn test_metadata_stats_mode_rejects_invalid_values() {
+        let options = HashMap::from([(
+            METADATA_STATS_MODE_OPTION.to_string(),
+            "truncate(0)".to_string(),
+        )]);
+        let core = CoreOptions::new(&options);
+
+        let err = core
+            .metadata_stats_mode()
+            .expect_err("zero truncate length should fail");
+        assert!(matches!(err, crate::Error::DataInvalid { message, .. }
+            if message.contains(METADATA_STATS_MODE_OPTION)));
+    }
+
     #[test]
     fn test_vector_file_options_defaults_and_overrides() {
         let default_options =
diff --git a/crates/paimon/src/table/data_evolution_reader.rs 
b/crates/paimon/src/table/data_evolution_reader.rs
index 1c5e9a8..513d4db 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -3281,7 +3281,7 @@ mod tests {
                     .await
                     .unwrap();
             writer.write(&batch).await.unwrap();
-            writer.close().await.unwrap();
+            let _ = writer.close().await.unwrap();
         }
 
         let table_schema = TableSchema::new(
diff --git a/crates/paimon/src/table/data_file_reader.rs 
b/crates/paimon/src/table/data_file_reader.rs
index 8410992..05df8b3 100644
--- a/crates/paimon/src/table/data_file_reader.rs
+++ b/crates/paimon/src/table/data_file_reader.rs
@@ -752,7 +752,7 @@ mod row_tests {
             .await
             .unwrap();
         writer.write(&batch).await.unwrap();
-        let file_size = writer.close().await.unwrap() as i64;
+        let file_size = writer.close().await.unwrap().file_size as i64;
 
         let schema_id = 1;
         let split = DataSplitBuilder::new()
@@ -823,7 +823,7 @@ mod row_tests {
             .await
             .unwrap();
         writer.write(&batch).await.unwrap();
-        let file_size = writer.close().await.unwrap() as i64;
+        let file_size = writer.close().await.unwrap().file_size as i64;
 
         let schema_id = 1;
         let split = DataSplitBuilder::new()
@@ -904,7 +904,7 @@ mod row_tests {
             .await
             .unwrap();
         writer.write(&batch).await.unwrap();
-        let file_size = writer.close().await.unwrap() as i64;
+        let file_size = writer.close().await.unwrap().file_size as i64;
 
         let split = DataSplitBuilder::new()
             .with_snapshot(1)
@@ -1545,12 +1545,19 @@ mod vector_parquet_tests {
         let file_path = format!("{bucket_path}/{file_name}");
         let output = file_io.new_output(&file_path).unwrap();
         let mut writer: Box<dyn FormatFileWriter> = Box::new(
-            ParquetFormatWriter::new(&output, arrow_schema.clone(), "zstd", 1)
-                .await
-                .unwrap(),
+            ParquetFormatWriter::new(
+                &output,
+                arrow_schema.clone(),
+                "zstd",
+                1,
+                None,
+                &std::collections::HashMap::new(),
+            )
+            .await
+            .unwrap(),
         );
         writer.write(&batch).await.unwrap();
-        let file_size = writer.close().await.unwrap();
+        let file_size = writer.close().await.unwrap().file_size;
 
         // Build a split whose data file's schema_id matches the table 
schema_id, so the
         // read path uses `read_type` directly (no SchemaManager lookup 
needed).
diff --git a/crates/paimon/src/table/data_file_writer.rs 
b/crates/paimon/src/table/data_file_writer.rs
index 58824ed..75f17c6 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -22,7 +22,7 @@
 //! handles file rolling when `target_file_size` is reached, and collects
 //! [`DataFileMeta`] for the commit path.
 
-use crate::arrow::format::{create_format_writer, FormatFileWriter};
+use crate::arrow::format::{create_format_writer, FormatFileWriter, 
FormatValueStats};
 use crate::io::FileIO;
 use crate::spec::stats::BinaryTableStats;
 use crate::spec::{bucket_dir_name, DataField, DataFileMeta, 
EMPTY_SERIALIZED_ROW};
@@ -117,8 +117,8 @@ impl DataFileWriter {
             self.open_new_file(batch.schema()).await?;
         }
 
-        self.current_row_count += batch.num_rows() as i64;
         self.current_writer.as_mut().unwrap().write(batch).await?;
+        self.current_row_count += batch.num_rows() as i64;
 
         // Roll to a new file if target size is reached — close in background
         if self.current_writer.as_ref().unwrap().num_bytes() as i64 >= 
self.target_file_size {
@@ -142,7 +142,6 @@ impl DataFileWriter {
             self.written_files.len(),
             self.file_format,
         );
-
         let bucket_dir = if self.partition_path.is_empty() {
             format!("{}/{}", self.table_location, bucket_dir_name(self.bucket))
         } else {
@@ -183,16 +182,17 @@ impl DataFileWriter {
 
         let row_count = self.current_row_count;
         self.current_row_count = 0;
-        let file_size = writer.close().await? as i64;
+        let write_result = writer.close().await?;
 
         let meta = Self::build_meta(
             file_name,
-            file_size,
+            write_result.file_size as i64,
             row_count,
             self.schema_id,
             self.file_source,
             self.first_row_id,
             self.write_cols.clone(),
+            write_result.value_stats,
         );
         self.written_files.push(meta);
         Ok(())
@@ -213,15 +213,16 @@ impl DataFileWriter {
         let write_cols = self.write_cols.clone();
 
         self.in_flight_closes.spawn(async move {
-            let file_size = writer.close().await? as i64;
+            let write_result = writer.close().await?;
             Ok(Self::build_meta(
                 file_name,
-                file_size,
+                write_result.file_size as i64,
                 row_count,
                 schema_id,
                 file_source,
                 first_row_id,
                 write_cols,
+                write_result.value_stats,
             ))
         });
     }
@@ -239,6 +240,7 @@ impl DataFileWriter {
         Ok(std::mem::take(&mut self.written_files))
     }
 
+    #[allow(clippy::too_many_arguments)]
     fn build_meta(
         file_name: String,
         file_size: i64,
@@ -247,23 +249,20 @@ impl DataFileWriter {
         file_source: Option<i32>,
         first_row_id: Option<i64>,
         write_cols: Option<Vec<String>>,
+        format_value_stats: Option<FormatValueStats>,
     ) -> DataFileMeta {
+        let (value_stats, value_stats_cols) = match format_value_stats {
+            Some(stats) => (stats.stats, stats.columns),
+            None => (BinaryTableStats::empty(), Some(Vec::new())),
+        };
         DataFileMeta {
             file_name,
             file_size,
             row_count,
             min_key: EMPTY_SERIALIZED_ROW.clone(),
             max_key: EMPTY_SERIALIZED_ROW.clone(),
-            key_stats: BinaryTableStats::new(
-                EMPTY_SERIALIZED_ROW.clone(),
-                EMPTY_SERIALIZED_ROW.clone(),
-                vec![],
-            ),
-            value_stats: BinaryTableStats::new(
-                EMPTY_SERIALIZED_ROW.clone(),
-                EMPTY_SERIALIZED_ROW.clone(),
-                vec![],
-            ),
+            key_stats: BinaryTableStats::empty(),
+            value_stats,
             min_sequence_number: 0,
             max_sequence_number: 0,
             schema_id,
@@ -273,7 +272,7 @@ impl DataFileWriter {
             delete_row_count: Some(0),
             embedded_index: None,
             file_source,
-            value_stats_cols: Some(vec![]),
+            value_stats_cols,
             external_path: None,
             first_row_id,
             write_cols,
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs 
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index 15f8803..6417b9c 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -131,6 +131,10 @@ impl AppendDedicatedFormatFileWriter {
         }
 
         let normal_schema = 
Arc::new(arrow_schema::Schema::new(normal_arrow_fields));
+        let normal_field_names = normal_table_fields
+            .iter()
+            .map(|field| field.name().to_string())
+            .collect();
         let vector_writer = if let Some(vector_file_format) = 
vector_file_format {
             if vector_table_fields.is_empty() {
                 None
@@ -178,7 +182,7 @@ impl AppendDedicatedFormatFileWriter {
             format_options.clone(),
             Some(0),
             None,
-            None,
+            Some(normal_field_names),
         );
 
         Self {
diff --git a/crates/paimon/src/table/kv_file_writer.rs 
b/crates/paimon/src/table/kv_file_writer.rs
index dd5ac7d..54ffa46 100644
--- a/crates/paimon/src/table/kv_file_writer.rs
+++ b/crates/paimon/src/table/kv_file_writer.rs
@@ -423,7 +423,7 @@ impl KeyValueFileWriter {
             writer.write(&chunk_batch).await?;
         }
 
-        let file_size = writer.close().await? as i64;
+        let file_size = writer.close().await?.file_size as i64;
 
         let key_columns: Vec<Arc<dyn Array>> = self
             .config
diff --git a/crates/paimon/src/table/postpone_file_writer.rs 
b/crates/paimon/src/table/postpone_file_writer.rs
index afe318e..8ee83cb 100644
--- a/crates/paimon/src/table/postpone_file_writer.rs
+++ b/crates/paimon/src/table/postpone_file_writer.rs
@@ -184,7 +184,7 @@ impl PostponeFileWriter {
         let creation_time = self.current_file_creation_time;
 
         self.in_flight_closes.spawn(async move {
-            let file_size = writer.close().await? as i64;
+            let file_size = writer.close().await?.file_size as i64;
             Ok(build_meta(
                 file_name,
                 file_size,
@@ -249,7 +249,7 @@ impl PostponeFileWriter {
         let file_name = self.current_file_name.take().unwrap();
         let row_count = self.current_row_count;
         self.current_row_count = 0;
-        let file_size = writer.close().await? as i64;
+        let file_size = writer.close().await?.file_size as i64;
 
         let min_seq = self.current_file_start_seq;
         let max_seq = self.next_sequence_number - 1;
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index 375dfa3..86d8085 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -881,15 +881,16 @@ mod tests {
     use crate::catalog::Identifier;
     use crate::io::{FileIO, FileIOBuilder};
     use crate::spec::{
-        bucket_dir_name, BigIntType, BinaryRowBuilder, BlobType, DataField, 
DataType, DecimalType,
-        FileKind, FloatType, IndexManifest, IntType, LocalZonedTimestampType, 
Manifest,
-        ManifestList, Schema, TableSchema, TimestampType, TinyIntType, 
VarCharType, VectorType,
+        bucket_dir_name, BigIntType, BinaryRow, BinaryRowBuilder, BinaryType, 
BlobType, DataField,
+        DataType, Datum, DecimalType, FileKind, FloatType, IndexManifest, 
IntType,
+        LocalZonedTimestampType, Manifest, ManifestList, PredicateBuilder, 
Schema, TableSchema,
+        TimeType, TimestampType, TinyIntType, VarBinaryType, VarCharType, 
VectorType,
         SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME, 
VALUE_KIND_FIELD_ID,
         VALUE_KIND_FIELD_NAME,
     };
     use crate::table::{SnapshotManager, TableCommit};
     use arrow_array::RecordBatchReader as _;
-    use arrow_array::{Int32Array, Int64Array, Int8Array, StringArray};
+    use arrow_array::{Int32Array, Int64Array, Int8Array, StringArray, 
Time32MillisecondArray};
     use arrow_schema::{
         DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema, 
TimeUnit,
     };
@@ -989,6 +990,21 @@ mod tests {
         .unwrap()
     }
 
+    fn make_nullable_batch(ids: Vec<Option<i32>>, values: Vec<Option<i32>>) -> 
RecordBatch {
+        let schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, true),
+            ArrowField::new("value", ArrowDataType::Int32, true),
+        ]));
+        RecordBatch::try_new(
+            schema,
+            vec![
+                Arc::new(Int32Array::from(ids)),
+                Arc::new(Int32Array::from(values)),
+            ],
+        )
+        .unwrap()
+    }
+
     fn make_vector_batch(ids: Vec<i32>, vectors: Vec<Vec<f32>>) -> RecordBatch 
{
         use arrow_array::builder::{FixedSizeListBuilder, Float32Builder};
 
@@ -1203,6 +1219,445 @@ mod tests {
         assert_eq!(snapshot.total_record_count(), Some(3));
     }
 
+    #[tokio::test]
+    async fn test_append_write_populates_value_stats() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_table_write_value_stats";
+        setup_dirs(&file_io, table_path).await;
+
+        let table = test_table(&file_io, table_path);
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&make_nullable_batch(
+                vec![Some(3), None],
+                vec![Some(30), Some(10)],
+            ))
+            .await
+            .unwrap();
+        table_write
+            .write_arrow_batch(&make_nullable_batch(vec![Some(1)], vec![None]))
+            .await
+            .unwrap();
+
+        let messages = table_write.prepare_commit().await.unwrap();
+        assert_eq!(messages.len(), 1);
+        assert_eq!(messages[0].new_files.len(), 1);
+        let file = &messages[0].new_files[0];
+        assert_eq!(file.row_count, 3);
+        assert_eq!(file.value_stats_cols, None);
+        assert_eq!(file.value_stats.null_counts(), &vec![Some(1), Some(1)]);
+
+        let min_values = 
BinaryRow::from_serialized_bytes(file.value_stats.min_values()).unwrap();
+        let max_values = 
BinaryRow::from_serialized_bytes(file.value_stats.max_values()).unwrap();
+        assert_eq!(min_values.get_int(0).unwrap(), 1);
+        assert_eq!(max_values.get_int(0).unwrap(), 3);
+        assert_eq!(min_values.get_int(1).unwrap(), 10);
+        assert_eq!(max_values.get_int(1).unwrap(), 30);
+    }
+
+    #[tokio::test]
+    async fn test_append_write_populates_microsecond_timestamp_value_stats() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_table_write_timestamp6_value_stats";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("ts", DataType::Timestamp(TimestampType::new(6).unwrap()))
+            .column(
+                "lzts",
+                
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(6).unwrap()),
+            )
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "test_timestamp9_table"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new(
+                "ts",
+                ArrowDataType::Timestamp(TimeUnit::Microsecond, None),
+                true,
+            ),
+            ArrowField::new(
+                "lzts",
+                ArrowDataType::Timestamp(TimeUnit::Microsecond, 
Some("UTC".into())),
+                true,
+            ),
+        ]));
+        let batch = RecordBatch::try_new(
+            arrow_schema,
+            vec![
+                Arc::new(arrow_array::TimestampMicrosecondArray::from(vec![
+                    Some(1_704_067_200_987_654_i64),
+                    Some(1_704_067_200_123_456_i64),
+                ])),
+                Arc::new(
+                    arrow_array::TimestampMicrosecondArray::from(vec![
+                        Some(1_704_067_201_999_999_i64),
+                        Some(1_704_067_201_000_001_i64),
+                    ])
+                    .with_timezone("UTC"),
+                ),
+            ],
+        )
+        .unwrap();
+
+        table_write.write_arrow_batch(&batch).await.unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        let file = &messages[0].new_files[0];
+        assert_eq!(file.value_stats_cols, None);
+
+        let min_values = 
BinaryRow::from_serialized_bytes(file.value_stats.min_values()).unwrap();
+        let max_values = 
BinaryRow::from_serialized_bytes(file.value_stats.max_values()).unwrap();
+        assert_eq!(
+            min_values.get_timestamp_raw(0, 6).unwrap(),
+            (1_704_067_200_123, 456_000)
+        );
+        assert_eq!(
+            max_values.get_timestamp_raw(0, 6).unwrap(),
+            (1_704_067_200_987, 654_000)
+        );
+        assert_eq!(
+            min_values.get_timestamp_raw(1, 6).unwrap(),
+            (1_704_067_201_000, 1_000)
+        );
+        assert_eq!(
+            max_values.get_timestamp_raw(1, 6).unwrap(),
+            (1_704_067_201_999, 999_000)
+        );
+    }
+
+    #[tokio::test]
+    async fn 
test_append_write_nanosecond_timestamp_value_stats_keeps_counts_only() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_table_write_timestamp9_value_stats";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("ts", DataType::Timestamp(TimestampType::new(9).unwrap()))
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "test_timestamp9_table"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
+            "ts",
+            ArrowDataType::Timestamp(TimeUnit::Nanosecond, None),
+            true,
+        )]));
+        let batch = RecordBatch::try_new(
+            arrow_schema,
+            vec![Arc::new(arrow_array::TimestampNanosecondArray::from(vec![
+                Some(1_704_067_200_987_654_321_i64),
+                None,
+            ]))],
+        )
+        .unwrap();
+
+        table_write.write_arrow_batch(&batch).await.unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        let file = &messages[0].new_files[0];
+        assert_eq!(file.value_stats_cols, None);
+        assert_eq!(file.value_stats.null_counts(), &vec![Some(1)]);
+
+        let min_values = 
BinaryRow::from_serialized_bytes(file.value_stats.min_values()).unwrap();
+        let max_values = 
BinaryRow::from_serialized_bytes(file.value_stats.max_values()).unwrap();
+        assert!(min_values.is_null_at(0));
+        assert!(max_values.is_null_at(0));
+    }
+
+    #[tokio::test]
+    async fn 
test_append_write_truncates_string_value_stats_and_keeps_binary_counts() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_table_write_skip_variable_length_stats";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("name", DataType::VarChar(VarCharType::string_type()))
+            .column(
+                "payload",
+                DataType::VarBinary(VarBinaryType::new(1024).unwrap()),
+            )
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "test_variable_length_stats_table"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, false),
+            ArrowField::new("name", ArrowDataType::Utf8, true),
+            ArrowField::new("payload", ArrowDataType::Binary, true),
+        ]));
+        let batch = RecordBatch::try_new(
+            arrow_schema,
+            vec![
+                Arc::new(Int32Array::from(vec![2, 1])),
+                Arc::new(StringArray::from(vec![
+                    Some("a long string value"),
+                    Some("another long string value"),
+                ])),
+                Arc::new(arrow_array::BinaryArray::from(vec![
+                    Some(b"large-binary-value" as &[u8]),
+                    Some(b"another-large-binary-value" as &[u8]),
+                ])),
+            ],
+        )
+        .unwrap();
+
+        table_write.write_arrow_batch(&batch).await.unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        let file = &messages[0].new_files[0];
+        assert_eq!(file.value_stats_cols, None);
+        assert_eq!(
+            file.value_stats.null_counts(),
+            &vec![Some(0), Some(0), Some(0)]
+        );
+
+        let min_values = 
BinaryRow::from_serialized_bytes(file.value_stats.min_values()).unwrap();
+        let max_values = 
BinaryRow::from_serialized_bytes(file.value_stats.max_values()).unwrap();
+        assert_eq!(min_values.get_int(0).unwrap(), 1);
+        assert_eq!(max_values.get_int(0).unwrap(), 2);
+        assert_eq!(min_values.get_string(1).unwrap(), "a long string va");
+        assert_eq!(max_values.get_string(1).unwrap(), "another long sts");
+        assert!(min_values.is_null_at(2));
+        assert!(max_values.is_null_at(2));
+    }
+
+    #[tokio::test]
+    async fn test_append_write_applies_configured_metadata_stats_modes() {
+        let file_io = test_file_io();
+        let table_path = 
"memory:/test_table_write_configured_metadata_stats_modes";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("name", DataType::VarChar(VarCharType::string_type()))
+            .column("payload", 
DataType::Binary(BinaryType::new(1024).unwrap()))
+            .column("value", DataType::Int(IntType::new()))
+            .option("metadata.stats-mode", "counts")
+            .option("metadata.stats-keep-first-n-columns", "2")
+            .option("fields.name.stats-mode", "full")
+            .option("fields.payload.stats-mode", "full")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "test_configured_stats_modes_table"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, true),
+            ArrowField::new("name", ArrowDataType::Utf8, true),
+            ArrowField::new("payload", ArrowDataType::Binary, true),
+            ArrowField::new("value", ArrowDataType::Int32, true),
+        ]));
+        let batch = RecordBatch::try_new(
+            arrow_schema,
+            vec![
+                Arc::new(Int32Array::from(vec![Some(2), None])),
+                Arc::new(StringArray::from(vec![
+                    Some("alpha-long-value-12345"),
+                    Some("zeta-long-value-99999"),
+                ])),
+                Arc::new(arrow_array::BinaryArray::from(vec![
+                    Some(b"first-binary-value" as &[u8]),
+                    None,
+                ])),
+                Arc::new(Int32Array::from(vec![Some(10), Some(20)])),
+            ],
+        )
+        .unwrap();
+
+        table_write.write_arrow_batch(&batch).await.unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        let file = &messages[0].new_files[0];
+        assert_eq!(
+            file.value_stats_cols,
+            Some(vec![
+                "id".to_string(),
+                "name".to_string(),
+                "payload".to_string()
+            ])
+        );
+        assert_eq!(
+            file.value_stats.null_counts(),
+            &vec![Some(1), Some(0), Some(1)]
+        );
+
+        let min_values = 
BinaryRow::from_serialized_bytes(file.value_stats.min_values()).unwrap();
+        let max_values = 
BinaryRow::from_serialized_bytes(file.value_stats.max_values()).unwrap();
+        assert!(min_values.is_null_at(0));
+        assert!(max_values.is_null_at(0));
+        assert_eq!(min_values.get_string(1).unwrap(), 
"alpha-long-value-12345");
+        assert_eq!(max_values.get_string(1).unwrap(), "zeta-long-value-99999");
+        assert!(min_values.is_null_at(2));
+        assert!(max_values.is_null_at(2));
+    }
+
+    #[tokio::test]
+    async fn test_append_write_respects_non_dense_metadata_stats_storage() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_table_write_non_dense_metadata_stats";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("ignored", DataType::Int(IntType::new()))
+            .option("metadata.stats-mode", "none")
+            .option("metadata.stats-dense-store", "false")
+            .option("fields.id.stats-mode", "counts")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "test_non_dense_stats_table"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![
+            ArrowField::new("id", ArrowDataType::Int32, true),
+            ArrowField::new("ignored", ArrowDataType::Int32, true),
+        ]));
+        let batch = RecordBatch::try_new(
+            arrow_schema,
+            vec![
+                Arc::new(Int32Array::from(vec![Some(1), None])),
+                Arc::new(Int32Array::from(vec![Some(10), Some(20)])),
+            ],
+        )
+        .unwrap();
+
+        table_write.write_arrow_batch(&batch).await.unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        let file = &messages[0].new_files[0];
+        assert_eq!(file.value_stats_cols, None);
+        assert_eq!(file.value_stats.null_counts(), &vec![Some(1), None]);
+
+        let min_values = 
BinaryRow::from_serialized_bytes(file.value_stats.min_values()).unwrap();
+        let max_values = 
BinaryRow::from_serialized_bytes(file.value_stats.max_values()).unwrap();
+        assert_eq!(min_values.arity(), 2);
+        assert_eq!(max_values.arity(), 2);
+        assert!(min_values.is_null_at(0));
+        assert!(max_values.is_null_at(0));
+        assert!(min_values.is_null_at(1));
+        assert!(max_values.is_null_at(1));
+    }
+
+    #[tokio::test]
+    async fn test_append_write_populates_time_value_stats() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_table_write_time_value_stats";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("tm", DataType::Time(TimeType::new(3).unwrap()))
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io.clone(),
+            Identifier::new("default", "test_time_value_stats_table"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+
+        let arrow_schema = Arc::new(ArrowSchema::new(vec![ArrowField::new(
+            "tm",
+            ArrowDataType::Time32(TimeUnit::Millisecond),
+            true,
+        )]));
+        let batch = RecordBatch::try_new(
+            arrow_schema,
+            vec![Arc::new(Time32MillisecondArray::from(vec![
+                Some(3_000),
+                None,
+                Some(1_000),
+            ]))],
+        )
+        .unwrap();
+
+        table_write.write_arrow_batch(&batch).await.unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        let file = &messages[0].new_files[0];
+        assert_eq!(file.value_stats_cols, None);
+        assert_eq!(file.value_stats.null_counts(), &vec![Some(1)]);
+
+        let min_values = 
BinaryRow::from_serialized_bytes(file.value_stats.min_values()).unwrap();
+        let max_values = 
BinaryRow::from_serialized_bytes(file.value_stats.max_values()).unwrap();
+        assert_eq!(min_values.get_int(0).unwrap(), 1_000);
+        assert_eq!(max_values.get_int(0).unwrap(), 3_000);
+    }
+
+    #[tokio::test]
+    async fn test_scan_prunes_real_append_files_using_written_value_stats() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_table_write_value_stats_scan";
+        setup_dirs(&file_io, table_path).await;
+
+        let table = test_table(&file_io, table_path);
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&make_batch(vec![1, 2], vec![10, 20]))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write
+            .write_arrow_batch(&make_batch(vec![100, 101], vec![1000, 1010]))
+            .await
+            .unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .greater_than("id", Datum::Int(10))
+            .unwrap();
+        let mut reader = table.new_read_builder();
+        reader.with_filter(predicate);
+        let (_plan, trace) = 
reader.new_scan().plan_with_trace().await.unwrap();
+
+        assert_eq!(trace.final_files, 1, "scan trace: {trace:?}");
+        assert!(
+            trace.manifest_entries_pruned_by_data_stats >= 1,
+            "scan trace: {trace:?}"
+        );
+    }
+
     #[tokio::test]
     async fn test_table_write_row_format_roundtrip() {
         let file_io = test_file_io();
@@ -1328,6 +1783,7 @@ mod tests {
 
         assert_eq!(parquet_files[0].row_count, 3);
         assert_eq!(blob_files[0].row_count, 3);
+        assert_eq!(parquet_files[0].write_cols, Some(vec!["id".to_string()]));
         assert_eq!(blob_files[0].write_cols, 
Some(vec!["payload".to_string()]));
 
         // Commit and verify snapshot
@@ -1339,6 +1795,71 @@ mod tests {
         assert_eq!(snapshot.id(), 1);
     }
 
+    #[tokio::test]
+    async fn test_dedicated_blob_stats_use_normal_write_columns() {
+        let file_io = test_file_io();
+        let table_path = "memory:/test_dedicated_blob_stats_mapping";
+        setup_dirs(&file_io, table_path).await;
+
+        let schema = Schema::builder()
+            .column("payload", DataType::Blob(BlobType::new()))
+            .column("a", DataType::Int(IntType::new()))
+            .column("b", DataType::Int(IntType::new()))
+            .option("data-evolution.enabled", "true")
+            .build()
+            .unwrap();
+        let table = Table::new(
+            file_io,
+            Identifier::new("default", "test_dedicated_blob_stats_mapping"),
+            table_path.to_string(),
+            TableSchema::new(0, &schema),
+            None,
+        );
+        let batch = RecordBatch::try_new(
+            Arc::new(ArrowSchema::new(vec![
+                ArrowField::new("payload", ArrowDataType::Binary, true),
+                ArrowField::new("a", ArrowDataType::Int32, false),
+                ArrowField::new("b", ArrowDataType::Int32, false),
+            ])),
+            vec![
+                Arc::new(arrow_array::BinaryArray::from(vec![Some(
+                    b"payload" as &[u8],
+                )])),
+                Arc::new(Int32Array::from(vec![100])),
+                Arc::new(Int32Array::from(vec![0])),
+            ],
+        )
+        .unwrap();
+
+        let mut table_write = TableWrite::new(&table, 
"test-user".to_string()).unwrap();
+        table_write.write_arrow_batch(&batch).await.unwrap();
+        let messages = table_write.prepare_commit().await.unwrap();
+        let normal_write_cols = messages[0]
+            .new_files
+            .iter()
+            .find(|file| file.file_name.ends_with(".parquet"))
+            .unwrap()
+            .write_cols
+            .clone();
+        TableCommit::new(table.clone(), "test-user".to_string())
+            .commit(messages)
+            .await
+            .unwrap();
+
+        let predicate = PredicateBuilder::new(table.schema().fields())
+            .greater_than("a", Datum::Int(50))
+            .unwrap();
+        let mut reader = table.new_read_builder();
+        reader.with_filter(predicate);
+        let (_plan, trace) = 
reader.new_scan().plan_with_trace().await.unwrap();
+
+        assert!(trace.final_files > 0, "scan trace: {trace:?}");
+        assert_eq!(
+            normal_write_cols,
+            Some(vec!["a".to_string(), "b".to_string()])
+        );
+    }
+
     async fn assert_vector_write_uses_dedicated_file(table_path: &str, 
vector_file_format: &str) {
         let file_io = test_file_io();
         setup_dirs(&file_io, table_path).await;
@@ -1379,6 +1900,7 @@ mod tests {
         assert_eq!(vector_files.len(), 1);
         assert_eq!(normal_files[0].row_count, 3);
         assert_eq!(vector_files[0].row_count, 3);
+        assert_eq!(normal_files[0].write_cols, Some(vec!["id".to_string()]));
         assert_eq!(
             vector_files[0].write_cols,
             Some(vec!["embedding".to_string()])

Reply via email to