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 82cecf1c fix(table): stop answering COUNT(*) from a placeholder row 
count (#624)
82cecf1c is described below

commit 82cecf1c617f7bbd394bfe163fb55acd76df320c
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Tue Jul 28 23:12:46 2026 +0800

    fix(table): stop answering COUNT(*) from a placeholder row count (#624)
---
 .../datafusion/tests/format_table_statistics.rs    | 199 +++++++++++++++++++++
 crates/paimon/src/spec/data_file.rs                |  17 ++
 crates/paimon/src/table/format_table_scan.rs       |   2 +-
 crates/paimon/src/table/source.rs                  |  78 +++++++-
 4 files changed, 292 insertions(+), 4 deletions(-)

diff --git a/crates/integrations/datafusion/tests/format_table_statistics.rs 
b/crates/integrations/datafusion/tests/format_table_statistics.rs
new file mode 100644
index 00000000..c5505fb1
--- /dev/null
+++ b/crates/integrations/datafusion/tests/format_table_statistics.rs
@@ -0,0 +1,199 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Statistics-driven aggregates over `type=format-table` tables.
+//!
+//! A format table has no manifest, so scan planning cannot fill in per-file
+//! row counts. Those counts must be reported as *unknown*; if the placeholder
+//! is reported as an exact statistic instead, DataFusion's
+//! `aggregate_statistics` rule answers `COUNT(*)` from it and never opens a
+//! single data file — a silent wrong answer.
+
+mod common;
+
+use std::path::Path;
+use std::sync::Arc;
+
+use arrow_array::{Int64Array, RecordBatch};
+use arrow_schema::{DataType as ArrowDataType, Field, Schema as ArrowSchema};
+use paimon::catalog::{Catalog, Identifier};
+use paimon::spec::{BigIntType, DataType, Schema, SchemaBuilder, VarCharType};
+use paimon_datafusion::SQLContext;
+use parquet::arrow::ArrowWriter;
+use tempfile::TempDir;
+
+const DATABASE: &str = "test_db";
+const TABLE: &str = "events";
+
+/// Creates a `type=format-table` table in a filesystem catalog and returns the
+/// table directory, where callers drop raw data files.
+async fn setup_format_table() -> (TempDir, SQLContext, std::path::PathBuf) {
+    setup_table(Schema::builder().column("id", 
DataType::BigInt(BigIntType::new()))).await
+}
+
+/// Same, partitioned by a single `dt` column (Hive layout: the partition value
+/// lives in the directory name, not in the data file).
+async fn setup_partitioned_format_table() -> (TempDir, SQLContext, 
std::path::PathBuf) {
+    setup_table(
+        Schema::builder()
+            .column("dt", DataType::VarChar(VarCharType::new(32).unwrap()))
+            .column("id", DataType::BigInt(BigIntType::new()))
+            .partition_keys(vec!["dt".to_string()]),
+    )
+    .await
+}
+
+async fn setup_table(builder: SchemaBuilder) -> (TempDir, SQLContext, 
std::path::PathBuf) {
+    let (tmp, catalog) = common::create_test_env();
+    catalog
+        .create_database(DATABASE, false, Default::default())
+        .await
+        .expect("CREATE DATABASE failed");
+    let schema = builder
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .build()
+        .unwrap();
+    catalog
+        .create_table(&Identifier::new(DATABASE, TABLE), schema, false)
+        .await
+        .expect("CREATE TABLE failed");
+    let table_dir = tmp.path().join(format!("{DATABASE}.db")).join(TABLE);
+    let context = common::create_sql_context(catalog).await;
+    (tmp, context, table_dir)
+}
+
+fn write_parquet(path: &Path, values: &[i64]) {
+    let schema = Arc::new(ArrowSchema::new(vec![Field::new(
+        "id",
+        ArrowDataType::Int64,
+        true,
+    )]));
+    let batch = RecordBatch::try_new(
+        Arc::clone(&schema),
+        vec![Arc::new(Int64Array::from(values.to_vec()))],
+    )
+    .unwrap();
+    let file = std::fs::File::create(path).unwrap();
+    let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
+    writer.write(&batch).unwrap();
+    writer.close().unwrap();
+}
+
+async fn scalar_count(context: &SQLContext, sql: &str) -> i64 {
+    let batches = context.sql(sql).await.unwrap().collect().await.unwrap();
+    let batch = batches
+        .iter()
+        .find(|batch| batch.num_rows() > 0)
+        .expect("aggregate must return one row");
+    batch
+        .column(0)
+        .as_any()
+        .downcast_ref::<Int64Array>()
+        .expect("count column must be int64")
+        .value(0)
+}
+
+async fn scanned_rows(context: &SQLContext, sql: &str) -> i64 {
+    let batches = context.sql(sql).await.unwrap().collect().await.unwrap();
+    batches.iter().map(|batch| batch.num_rows() as i64).sum()
+}
+
+/// `COUNT(*)` on a format table must agree with what an actual scan returns.
+/// Regression for the silent `COUNT(*) = 0`: the placeholder
+/// `DataFileMeta::row_count` was reported as an exact statistic.
+#[tokio::test]
+async fn test_format_table_count_star_matches_scanned_rows() {
+    let (_tmp, context, table_dir) = setup_format_table().await;
+    write_parquet(&table_dir.join("part-0.parquet"), &[1, 2, 3]);
+    write_parquet(&table_dir.join("part-1.parquet"), &[4, 5]);
+
+    let scanned = scanned_rows(&context, "SELECT id FROM 
paimon.test_db.events").await;
+    assert_eq!(scanned, 5, "the scan itself must see all rows");
+
+    let counted = scalar_count(&context, "SELECT COUNT(*) AS c FROM 
paimon.test_db.events").await;
+    assert_eq!(
+        counted, scanned,
+        "COUNT(*) must not be answered from the placeholder row count"
+    );
+
+    let counted_col =
+        scalar_count(&context, "SELECT COUNT(id) AS c FROM 
paimon.test_db.events").await;
+    assert_eq!(
+        counted_col, scanned,
+        "COUNT(col) must not be short-circuited"
+    );
+}
+
+/// A format table with no data files really has zero rows; reporting the row
+/// count as unknown must not turn that into a wrong answer either.
+#[tokio::test]
+async fn test_empty_format_table_counts_zero() {
+    let (_tmp, context, _table_dir) = setup_format_table().await;
+
+    assert_eq!(
+        scalar_count(&context, "SELECT COUNT(*) AS c FROM 
paimon.test_db.events").await,
+        0
+    );
+}
+
+/// The partition-pruned plan reaches `partition_statistics()` too, so a pruned
+/// `COUNT(*)` must also come from the data and not from the placeholder.
+///
+/// Only predicated queries are asserted here. An unfiltered scan over a
+/// partitioned format table on a `file://` warehouse currently finds no splits
+/// at all — `table_path` keeps the `file:///` form while the listed status
+/// paths come back as `file:/`, so the `strip_prefix` in
+/// `partition_row_from_path` (paimon/src/table/format_table_scan.rs:487-493)
+/// drops every file. That is a separate defect from the one under test.
+#[tokio::test]
+async fn test_partitioned_format_table_count_with_partition_predicate() {
+    let (_tmp, context, table_dir) = setup_partitioned_format_table().await;
+    for (dt, values) in [
+        ("2026-07-21", vec![1i64, 2, 3]),
+        ("2026-07-22", vec![4i64, 5]),
+    ] {
+        let partition_dir = table_dir.join(format!("dt={dt}"));
+        std::fs::create_dir_all(&partition_dir).unwrap();
+        write_parquet(&partition_dir.join("part-0.parquet"), &values);
+    }
+
+    for (dt, expected) in [("2026-07-21", 3), ("2026-07-22", 2), 
("2026-07-23", 0)] {
+        let sql = format!("SELECT id FROM paimon.test_db.events WHERE dt = 
'{dt}'");
+        let scanned = scanned_rows(&context, &sql).await;
+        assert_eq!(scanned, expected, "scan of dt={dt}");
+
+        let sql = format!("SELECT COUNT(*) AS c FROM paimon.test_db.events 
WHERE dt = '{dt}'");
+        assert_eq!(
+            scalar_count(&context, &sql).await,
+            expected,
+            "count of dt={dt}"
+        );
+    }
+}
+
+/// A data file that genuinely holds zero rows must also count 0.
+#[tokio::test]
+async fn test_format_table_with_empty_file_counts_zero() {
+    let (_tmp, context, table_dir) = setup_format_table().await;
+    write_parquet(&table_dir.join("part-0.parquet"), &[]);
+
+    assert_eq!(
+        scalar_count(&context, "SELECT COUNT(*) AS c FROM 
paimon.test_db.events").await,
+        0
+    );
+}
diff --git a/crates/paimon/src/spec/data_file.rs 
b/crates/paimon/src/spec/data_file.rs
index d2b901bd..314c6d15 100644
--- a/crates/paimon/src/spec/data_file.rs
+++ b/crates/paimon/src/spec/data_file.rs
@@ -47,6 +47,8 @@ pub struct DataFileMeta {
     #[serde(rename = "_FILE_SIZE")]
     pub file_size: i64,
     // row_count tells the total number of rows (including add & delete) in 
this file.
+    // A negative value means the producer does not know the row count; see
+    // [`DataFileMeta::ROW_COUNT_UNKNOWN`]. Never treat it as a real count.
     #[serde(rename = "_ROW_COUNT")]
     pub row_count: i64,
     #[serde(rename = "_MIN_KEY", with = "serde_bytes")]
@@ -188,6 +190,21 @@ fn read_compact_millis_as_utc(
 }
 
 impl DataFileMeta {
+    /// Placeholder for `row_count` when the producer cannot know how many rows
+    /// a file holds.
+    ///
+    /// Metadata that comes from a manifest always carries a real count. 
Sources
+    /// that plan over bare directory listings (for example 
`type=format-table`,
+    /// which has no manifest) have nothing to fill in and must use this value:
+    /// a plain `0` is indistinguishable from a file that really is empty, and
+    /// every consumer that trusts it silently returns a wrong answer.
+    pub const ROW_COUNT_UNKNOWN: i64 = -1;
+
+    /// Whether `row_count` is a real count rather than 
[`Self::ROW_COUNT_UNKNOWN`].
+    pub fn row_count_known(&self) -> bool {
+        self.row_count >= 0
+    }
+
     /// Decode this file's manifest value statistics for a field in the 
current schema.
     ///
     /// Returns `None` when statistics are missing, malformed, or belong to a 
different
diff --git a/crates/paimon/src/table/format_table_scan.rs 
b/crates/paimon/src/table/format_table_scan.rs
index 28907664..df2a48e0 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -619,7 +619,7 @@ fn data_file_meta(file_name: String, file_size: i64, 
schema_id: i64) -> DataFile
     DataFileMeta {
         file_name,
         file_size,
-        row_count: 0,
+        row_count: DataFileMeta::ROW_COUNT_UNKNOWN,
         min_key: Vec::new(),
         max_key: Vec::new(),
         key_stats: BinaryTableStats::empty(),
diff --git a/crates/paimon/src/table/source.rs 
b/crates/paimon/src/table/source.rs
index 41836580..933cb441 100644
--- a/crates/paimon/src/table/source.rs
+++ b/crates/paimon/src/table/source.rs
@@ -561,9 +561,25 @@ impl DataSplit {
         file.data_file_path(&self.bucket_path)
     }
 
-    /// Total row count of all data files in this split.
+    /// Sum of the physical row counts this split knows about.
+    ///
+    /// Files whose count is [`DataFileMeta::ROW_COUNT_UNKNOWN`] contribute
+    /// nothing, so the result is a lower bound, not a total. Ask
+    /// [`Self::row_counts_known`] before presenting it as one.
     pub fn row_count(&self) -> i64 {
-        self.data_files.iter().map(|f| f.row_count).sum()
+        self.data_files
+            .iter()
+            .filter(|f| f.row_count_known())
+            .map(|f| f.row_count)
+            .sum()
+    }
+
+    /// Whether every data file in this split carries a real row count.
+    ///
+    /// False for splits planned without a manifest (`type=format-table`), 
where
+    /// the only honest answer about row counts is "unknown".
+    pub fn row_counts_known(&self) -> bool {
+        self.data_files.iter().all(DataFileMeta::row_count_known)
     }
 
     /// Returns the merged row count if it can be computed.
@@ -577,10 +593,15 @@ impl DataSplit {
     /// 2. If all files have `first_row_id` (data evolution mode): merge
     ///    overlapping row ID ranges and take max row count per group.
     ///
-    /// Returns `None` otherwise.
+    /// Returns `None` otherwise, and always `None` when any file's row count 
is
+    /// [`DataFileMeta::ROW_COUNT_UNKNOWN`] — no arithmetic over a placeholder
+    /// produces a number a caller may trust.
     ///
     /// Reference: 
[DataSplit.mergedRowCount()](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/table/source/DataSplit.java#L133)
     pub fn merged_row_count(&self) -> Option<i64> {
+        if !self.row_counts_known() {
+            return None;
+        }
         if let Some(count) = self.raw_merged_row_count() {
             return Some(count);
         }
@@ -1367,6 +1388,57 @@ mod tests {
         assert_eq!(s.merged_row_count(), Some(15));
     }
 
+    /// A placeholder row count must surface as "unknown", not as a number.
+    /// Reporting `Some(0)` here lets an engine answer `COUNT(*)` with 0 for a
+    /// split that holds data.
+    #[test]
+    fn test_merged_row_count_unknown_when_a_file_has_no_row_count() {
+        let s = split(
+            vec![
+                file("a", DataFileMeta::ROW_COUNT_UNKNOWN, None),
+                file("b", DataFileMeta::ROW_COUNT_UNKNOWN, None),
+            ],
+            true,
+        );
+        assert!(!s.row_counts_known());
+        assert_eq!(s.merged_row_count(), None);
+        assert_eq!(s.row_count(), 0, "unknown files contribute nothing");
+    }
+
+    /// One unknown file poisons the whole split: the rest are still a lower
+    /// bound, never a total.
+    #[test]
+    fn test_merged_row_count_unknown_when_mixed_with_known_files() {
+        let s = split(
+            vec![
+                file("a", 10, None),
+                file("b", DataFileMeta::ROW_COUNT_UNKNOWN, None),
+            ],
+            true,
+        );
+        assert_eq!(s.merged_row_count(), None);
+        assert_eq!(s.row_count(), 10);
+    }
+
+    /// Unknown row counts must not be rescued by the data-evolution branch.
+    #[test]
+    fn test_merged_row_count_unknown_beats_data_evolution_branch() {
+        let s = split(
+            vec![file("a", DataFileMeta::ROW_COUNT_UNKNOWN, Some(0))],
+            false,
+        );
+        assert_eq!(s.merged_row_count(), None);
+    }
+
+    /// A file that really holds zero rows stays an exact 0 — "unknown" is a
+    /// distinct value, not a synonym for empty.
+    #[test]
+    fn test_merged_row_count_zero_rows_stays_exact() {
+        let s = split(vec![file("a", 0, None)], true);
+        assert!(s.row_counts_known());
+        assert_eq!(s.merged_row_count(), Some(0));
+    }
+
     #[test]
     fn test_fully_materialized_pk_dv_requires_compacted_files() {
         let mut level_zero = file("a", 10, None);

Reply via email to