Skip to content

Commit 82cecf1

Browse files
authored
fix(table): stop answering COUNT(*) from a placeholder row count (#624)
1 parent 7810abe commit 82cecf1

4 files changed

Lines changed: 292 additions & 4 deletions

File tree

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Licensed to the Apache Software Foundation (ASF) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The ASF licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
//! Statistics-driven aggregates over `type=format-table` tables.
19+
//!
20+
//! A format table has no manifest, so scan planning cannot fill in per-file
21+
//! row counts. Those counts must be reported as *unknown*; if the placeholder
22+
//! is reported as an exact statistic instead, DataFusion's
23+
//! `aggregate_statistics` rule answers `COUNT(*)` from it and never opens a
24+
//! single data file — a silent wrong answer.
25+
26+
mod common;
27+
28+
use std::path::Path;
29+
use std::sync::Arc;
30+
31+
use arrow_array::{Int64Array, RecordBatch};
32+
use arrow_schema::{DataType as ArrowDataType, Field, Schema as ArrowSchema};
33+
use paimon::catalog::{Catalog, Identifier};
34+
use paimon::spec::{BigIntType, DataType, Schema, SchemaBuilder, VarCharType};
35+
use paimon_datafusion::SQLContext;
36+
use parquet::arrow::ArrowWriter;
37+
use tempfile::TempDir;
38+
39+
const DATABASE: &str = "test_db";
40+
const TABLE: &str = "events";
41+
42+
/// Creates a `type=format-table` table in a filesystem catalog and returns the
43+
/// table directory, where callers drop raw data files.
44+
async fn setup_format_table() -> (TempDir, SQLContext, std::path::PathBuf) {
45+
setup_table(Schema::builder().column("id", DataType::BigInt(BigIntType::new()))).await
46+
}
47+
48+
/// Same, partitioned by a single `dt` column (Hive layout: the partition value
49+
/// lives in the directory name, not in the data file).
50+
async fn setup_partitioned_format_table() -> (TempDir, SQLContext, std::path::PathBuf) {
51+
setup_table(
52+
Schema::builder()
53+
.column("dt", DataType::VarChar(VarCharType::new(32).unwrap()))
54+
.column("id", DataType::BigInt(BigIntType::new()))
55+
.partition_keys(vec!["dt".to_string()]),
56+
)
57+
.await
58+
}
59+
60+
async fn setup_table(builder: SchemaBuilder) -> (TempDir, SQLContext, std::path::PathBuf) {
61+
let (tmp, catalog) = common::create_test_env();
62+
catalog
63+
.create_database(DATABASE, false, Default::default())
64+
.await
65+
.expect("CREATE DATABASE failed");
66+
let schema = builder
67+
.option("type", "format-table")
68+
.option("file.format", "parquet")
69+
.build()
70+
.unwrap();
71+
catalog
72+
.create_table(&Identifier::new(DATABASE, TABLE), schema, false)
73+
.await
74+
.expect("CREATE TABLE failed");
75+
let table_dir = tmp.path().join(format!("{DATABASE}.db")).join(TABLE);
76+
let context = common::create_sql_context(catalog).await;
77+
(tmp, context, table_dir)
78+
}
79+
80+
fn write_parquet(path: &Path, values: &[i64]) {
81+
let schema = Arc::new(ArrowSchema::new(vec![Field::new(
82+
"id",
83+
ArrowDataType::Int64,
84+
true,
85+
)]));
86+
let batch = RecordBatch::try_new(
87+
Arc::clone(&schema),
88+
vec![Arc::new(Int64Array::from(values.to_vec()))],
89+
)
90+
.unwrap();
91+
let file = std::fs::File::create(path).unwrap();
92+
let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
93+
writer.write(&batch).unwrap();
94+
writer.close().unwrap();
95+
}
96+
97+
async fn scalar_count(context: &SQLContext, sql: &str) -> i64 {
98+
let batches = context.sql(sql).await.unwrap().collect().await.unwrap();
99+
let batch = batches
100+
.iter()
101+
.find(|batch| batch.num_rows() > 0)
102+
.expect("aggregate must return one row");
103+
batch
104+
.column(0)
105+
.as_any()
106+
.downcast_ref::<Int64Array>()
107+
.expect("count column must be int64")
108+
.value(0)
109+
}
110+
111+
async fn scanned_rows(context: &SQLContext, sql: &str) -> i64 {
112+
let batches = context.sql(sql).await.unwrap().collect().await.unwrap();
113+
batches.iter().map(|batch| batch.num_rows() as i64).sum()
114+
}
115+
116+
/// `COUNT(*)` on a format table must agree with what an actual scan returns.
117+
/// Regression for the silent `COUNT(*) = 0`: the placeholder
118+
/// `DataFileMeta::row_count` was reported as an exact statistic.
119+
#[tokio::test]
120+
async fn test_format_table_count_star_matches_scanned_rows() {
121+
let (_tmp, context, table_dir) = setup_format_table().await;
122+
write_parquet(&table_dir.join("part-0.parquet"), &[1, 2, 3]);
123+
write_parquet(&table_dir.join("part-1.parquet"), &[4, 5]);
124+
125+
let scanned = scanned_rows(&context, "SELECT id FROM paimon.test_db.events").await;
126+
assert_eq!(scanned, 5, "the scan itself must see all rows");
127+
128+
let counted = scalar_count(&context, "SELECT COUNT(*) AS c FROM paimon.test_db.events").await;
129+
assert_eq!(
130+
counted, scanned,
131+
"COUNT(*) must not be answered from the placeholder row count"
132+
);
133+
134+
let counted_col =
135+
scalar_count(&context, "SELECT COUNT(id) AS c FROM paimon.test_db.events").await;
136+
assert_eq!(
137+
counted_col, scanned,
138+
"COUNT(col) must not be short-circuited"
139+
);
140+
}
141+
142+
/// A format table with no data files really has zero rows; reporting the row
143+
/// count as unknown must not turn that into a wrong answer either.
144+
#[tokio::test]
145+
async fn test_empty_format_table_counts_zero() {
146+
let (_tmp, context, _table_dir) = setup_format_table().await;
147+
148+
assert_eq!(
149+
scalar_count(&context, "SELECT COUNT(*) AS c FROM paimon.test_db.events").await,
150+
0
151+
);
152+
}
153+
154+
/// The partition-pruned plan reaches `partition_statistics()` too, so a pruned
155+
/// `COUNT(*)` must also come from the data and not from the placeholder.
156+
///
157+
/// Only predicated queries are asserted here. An unfiltered scan over a
158+
/// partitioned format table on a `file://` warehouse currently finds no splits
159+
/// at all — `table_path` keeps the `file:///` form while the listed status
160+
/// paths come back as `file:/`, so the `strip_prefix` in
161+
/// `partition_row_from_path` (paimon/src/table/format_table_scan.rs:487-493)
162+
/// drops every file. That is a separate defect from the one under test.
163+
#[tokio::test]
164+
async fn test_partitioned_format_table_count_with_partition_predicate() {
165+
let (_tmp, context, table_dir) = setup_partitioned_format_table().await;
166+
for (dt, values) in [
167+
("2026-07-21", vec![1i64, 2, 3]),
168+
("2026-07-22", vec![4i64, 5]),
169+
] {
170+
let partition_dir = table_dir.join(format!("dt={dt}"));
171+
std::fs::create_dir_all(&partition_dir).unwrap();
172+
write_parquet(&partition_dir.join("part-0.parquet"), &values);
173+
}
174+
175+
for (dt, expected) in [("2026-07-21", 3), ("2026-07-22", 2), ("2026-07-23", 0)] {
176+
let sql = format!("SELECT id FROM paimon.test_db.events WHERE dt = '{dt}'");
177+
let scanned = scanned_rows(&context, &sql).await;
178+
assert_eq!(scanned, expected, "scan of dt={dt}");
179+
180+
let sql = format!("SELECT COUNT(*) AS c FROM paimon.test_db.events WHERE dt = '{dt}'");
181+
assert_eq!(
182+
scalar_count(&context, &sql).await,
183+
expected,
184+
"count of dt={dt}"
185+
);
186+
}
187+
}
188+
189+
/// A data file that genuinely holds zero rows must also count 0.
190+
#[tokio::test]
191+
async fn test_format_table_with_empty_file_counts_zero() {
192+
let (_tmp, context, table_dir) = setup_format_table().await;
193+
write_parquet(&table_dir.join("part-0.parquet"), &[]);
194+
195+
assert_eq!(
196+
scalar_count(&context, "SELECT COUNT(*) AS c FROM paimon.test_db.events").await,
197+
0
198+
);
199+
}

crates/paimon/src/spec/data_file.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ pub struct DataFileMeta {
4747
#[serde(rename = "_FILE_SIZE")]
4848
pub file_size: i64,
4949
// row_count tells the total number of rows (including add & delete) in this file.
50+
// A negative value means the producer does not know the row count; see
51+
// [`DataFileMeta::ROW_COUNT_UNKNOWN`]. Never treat it as a real count.
5052
#[serde(rename = "_ROW_COUNT")]
5153
pub row_count: i64,
5254
#[serde(rename = "_MIN_KEY", with = "serde_bytes")]
@@ -188,6 +190,21 @@ fn read_compact_millis_as_utc(
188190
}
189191

190192
impl DataFileMeta {
193+
/// Placeholder for `row_count` when the producer cannot know how many rows
194+
/// a file holds.
195+
///
196+
/// Metadata that comes from a manifest always carries a real count. Sources
197+
/// that plan over bare directory listings (for example `type=format-table`,
198+
/// which has no manifest) have nothing to fill in and must use this value:
199+
/// a plain `0` is indistinguishable from a file that really is empty, and
200+
/// every consumer that trusts it silently returns a wrong answer.
201+
pub const ROW_COUNT_UNKNOWN: i64 = -1;
202+
203+
/// Whether `row_count` is a real count rather than [`Self::ROW_COUNT_UNKNOWN`].
204+
pub fn row_count_known(&self) -> bool {
205+
self.row_count >= 0
206+
}
207+
191208
/// Decode this file's manifest value statistics for a field in the current schema.
192209
///
193210
/// Returns `None` when statistics are missing, malformed, or belong to a different

crates/paimon/src/table/format_table_scan.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ fn data_file_meta(file_name: String, file_size: i64, schema_id: i64) -> DataFile
619619
DataFileMeta {
620620
file_name,
621621
file_size,
622-
row_count: 0,
622+
row_count: DataFileMeta::ROW_COUNT_UNKNOWN,
623623
min_key: Vec::new(),
624624
max_key: Vec::new(),
625625
key_stats: BinaryTableStats::empty(),

crates/paimon/src/table/source.rs

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -561,9 +561,25 @@ impl DataSplit {
561561
file.data_file_path(&self.bucket_path)
562562
}
563563

564-
/// Total row count of all data files in this split.
564+
/// Sum of the physical row counts this split knows about.
565+
///
566+
/// Files whose count is [`DataFileMeta::ROW_COUNT_UNKNOWN`] contribute
567+
/// nothing, so the result is a lower bound, not a total. Ask
568+
/// [`Self::row_counts_known`] before presenting it as one.
565569
pub fn row_count(&self) -> i64 {
566-
self.data_files.iter().map(|f| f.row_count).sum()
570+
self.data_files
571+
.iter()
572+
.filter(|f| f.row_count_known())
573+
.map(|f| f.row_count)
574+
.sum()
575+
}
576+
577+
/// Whether every data file in this split carries a real row count.
578+
///
579+
/// False for splits planned without a manifest (`type=format-table`), where
580+
/// the only honest answer about row counts is "unknown".
581+
pub fn row_counts_known(&self) -> bool {
582+
self.data_files.iter().all(DataFileMeta::row_count_known)
567583
}
568584

569585
/// Returns the merged row count if it can be computed.
@@ -577,10 +593,15 @@ impl DataSplit {
577593
/// 2. If all files have `first_row_id` (data evolution mode): merge
578594
/// overlapping row ID ranges and take max row count per group.
579595
///
580-
/// Returns `None` otherwise.
596+
/// Returns `None` otherwise, and always `None` when any file's row count is
597+
/// [`DataFileMeta::ROW_COUNT_UNKNOWN`] — no arithmetic over a placeholder
598+
/// produces a number a caller may trust.
581599
///
582600
/// 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)
583601
pub fn merged_row_count(&self) -> Option<i64> {
602+
if !self.row_counts_known() {
603+
return None;
604+
}
584605
if let Some(count) = self.raw_merged_row_count() {
585606
return Some(count);
586607
}
@@ -1367,6 +1388,57 @@ mod tests {
13671388
assert_eq!(s.merged_row_count(), Some(15));
13681389
}
13691390

1391+
/// A placeholder row count must surface as "unknown", not as a number.
1392+
/// Reporting `Some(0)` here lets an engine answer `COUNT(*)` with 0 for a
1393+
/// split that holds data.
1394+
#[test]
1395+
fn test_merged_row_count_unknown_when_a_file_has_no_row_count() {
1396+
let s = split(
1397+
vec![
1398+
file("a", DataFileMeta::ROW_COUNT_UNKNOWN, None),
1399+
file("b", DataFileMeta::ROW_COUNT_UNKNOWN, None),
1400+
],
1401+
true,
1402+
);
1403+
assert!(!s.row_counts_known());
1404+
assert_eq!(s.merged_row_count(), None);
1405+
assert_eq!(s.row_count(), 0, "unknown files contribute nothing");
1406+
}
1407+
1408+
/// One unknown file poisons the whole split: the rest are still a lower
1409+
/// bound, never a total.
1410+
#[test]
1411+
fn test_merged_row_count_unknown_when_mixed_with_known_files() {
1412+
let s = split(
1413+
vec![
1414+
file("a", 10, None),
1415+
file("b", DataFileMeta::ROW_COUNT_UNKNOWN, None),
1416+
],
1417+
true,
1418+
);
1419+
assert_eq!(s.merged_row_count(), None);
1420+
assert_eq!(s.row_count(), 10);
1421+
}
1422+
1423+
/// Unknown row counts must not be rescued by the data-evolution branch.
1424+
#[test]
1425+
fn test_merged_row_count_unknown_beats_data_evolution_branch() {
1426+
let s = split(
1427+
vec![file("a", DataFileMeta::ROW_COUNT_UNKNOWN, Some(0))],
1428+
false,
1429+
);
1430+
assert_eq!(s.merged_row_count(), None);
1431+
}
1432+
1433+
/// A file that really holds zero rows stays an exact 0 — "unknown" is a
1434+
/// distinct value, not a synonym for empty.
1435+
#[test]
1436+
fn test_merged_row_count_zero_rows_stays_exact() {
1437+
let s = split(vec![file("a", 0, None)], true);
1438+
assert!(s.row_counts_known());
1439+
assert_eq!(s.merged_row_count(), Some(0));
1440+
}
1441+
13701442
#[test]
13711443
fn test_fully_materialized_pk_dv_requires_compacted_files() {
13721444
let mut level_zero = file("a", 10, None);

0 commit comments

Comments
 (0)