Skip to content

Commit c149764

Browse files
authored
fix: preserve missing Parquet null counts (#25242)
## Which issue does this PR close? Closes #25239. Follows up on #21907 and [Arrow #6485](apache/arrow-rs#6485). ## Rationale for this change An absent Parquet `null_count` is unknown. Treating it as zero can discard NULL rows during `NULLS FIRST` TopK and `IS NULL` pruning, and let metadata-only `COUNT(column)` return an incorrect result. ## What changes are included in this PR? Preserve missing null counts in the shared row-group statistics adapter and file statistics. Remove the per-caller compatibility flag so static pruning, runtime pruning, and fully matched proofs use the same interpretation. Add an upgrade guide covering affected writers, plan changes, and migration. ## What is the testing strategy for this PR? Regressions cover missing and explicit-zero counts, static pruning, file-statistics precision, `COUNT`, and TopK with ASC/DESC, NULLS FIRST/LAST, and dynamic pruning enabled/disabled. The affected cases fail without the fix. Extended workspace tests, strict all-target/all-feature Clippy, and the full lint suite pass. ## Are there any user-facing changes? Correct results for Parquet files with missing null counts. Affected files can lose `IS NULL` pruning, metadata-only `COUNT(column)`, and `NULLS FIRST` TopK pruning. Sort pushdown can also retain a full `SortExec` for `ORDER BY` on nullable columns, even when files are sorted and non-overlapping. This includes older files written with parquet-rs before 53.1.0, used by DataFusion releases before 42.1.0. The DataFusion 56 upgrade guide explains how to identify and rewrite affected files. Explicit counts retain their existing behavior. No public API changes. --------- Signed-off-by: peterxcli <peterxcli@gmail.com>
1 parent afaee3d commit c149764

6 files changed

Lines changed: 298 additions & 19 deletions

File tree

‎datafusion/core/tests/parquet/dynamic_row_group_pruning.rs‎

Lines changed: 175 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,190 @@
3030
//! because batch-arrival timing affects how soon the TopK heap fills,
3131
//! and we don't want this test to become flaky.
3232
33+
use std::io::Write;
3334
use std::sync::Arc;
3435

3536
use arrow::array::{ArrayRef, Int64Array, RecordBatch, StringArray};
3637
use arrow_schema::{DataType, Field, Schema};
3738

38-
use datafusion::prelude::SessionConfig;
39+
use datafusion::physical_plan::collect;
40+
use datafusion::prelude::{ParquetReadOptions, SessionConfig, SessionContext};
41+
use datafusion_common::ScalarValue;
42+
use parquet::arrow::ArrowWriter;
43+
use parquet::file::metadata::{ParquetMetaData, ParquetMetaDataWriter};
44+
use parquet::file::properties::{EnabledStatistics, WriterProperties};
45+
use parquet::file::statistics::Statistics;
46+
use parquet::file::writer::TrackedWrite;
47+
use tempfile::NamedTempFile;
3948

4049
use crate::parquet::Unit::RowGroup;
50+
use crate::parquet::utils::MetricsFinder;
4151
use crate::parquet::{ContextWithParquet, Scenario};
4252

53+
/// Keep min/max but omit the middle row group's null count. Negate the
54+
/// values for DESC so the first group always establishes the winning bound.
55+
fn file_with_missing_null_count(descending: bool, has_null: bool) -> NamedTempFile {
56+
let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int64, true)]));
57+
let props = WriterProperties::builder()
58+
.set_statistics_enabled(EnabledStatistics::Chunk)
59+
.build();
60+
let mut bytes = Vec::new();
61+
let mut writer =
62+
ArrowWriter::try_new(&mut bytes, schema.clone(), Some(props)).unwrap();
63+
for values in [
64+
vec![Some(0), Some(1), Some(2)],
65+
vec![
66+
if has_null { None } else { Some(100) },
67+
Some(100),
68+
Some(101),
69+
],
70+
vec![Some(200), Some(201), Some(202)],
71+
] {
72+
let values: Int64Array = values
73+
.into_iter()
74+
.map(|v| v.map(|v| if descending { -v } else { v }))
75+
.collect();
76+
let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(values)]).unwrap();
77+
writer.write(&batch).unwrap();
78+
writer.flush().unwrap();
79+
}
80+
let metadata = writer.close().unwrap();
81+
let mut groups = metadata.row_groups().to_vec();
82+
if has_null {
83+
let column = groups[1].column(0).clone();
84+
let Statistics::Int64(stats) = column.statistics().unwrap() else {
85+
panic!("expected Int64 statistics");
86+
};
87+
let stats = Statistics::int64(
88+
stats.min_opt().copied(),
89+
stats.max_opt().copied(),
90+
None,
91+
None,
92+
false,
93+
);
94+
let column = column.into_builder().set_statistics(stats).build().unwrap();
95+
groups[1] = groups[1]
96+
.clone()
97+
.into_builder()
98+
.set_column_metadata(vec![column])
99+
.build()
100+
.unwrap();
101+
}
102+
let metadata = ParquetMetaData::new(metadata.file_metadata().clone(), groups);
103+
104+
// Replace the footer, leaving the encoded rows and their offsets intact.
105+
let footer_len =
106+
u32::from_le_bytes(bytes[bytes.len() - 8..bytes.len() - 4].try_into().unwrap());
107+
bytes.truncate(bytes.len() - 8 - footer_len as usize);
108+
let mut file = tempfile::Builder::new()
109+
.suffix(".parquet")
110+
.tempfile()
111+
.unwrap();
112+
let mut output = TrackedWrite::new(file.as_file_mut());
113+
output.write_all(&bytes).unwrap();
114+
ParquetMetaDataWriter::new_with_tracked(output, &metadata)
115+
.finish()
116+
.unwrap();
117+
file
118+
}
119+
120+
#[tokio::test]
121+
async fn missing_null_count_preserves_null_filter_and_count() {
122+
let file = file_with_missing_null_count(false, true);
123+
let ctx = SessionContext::new();
124+
ctx.register_parquet(
125+
"t",
126+
file.path().to_str().unwrap(),
127+
ParquetReadOptions::default(),
128+
)
129+
.await
130+
.unwrap();
131+
for (sql, expected) in [
132+
("SELECT COUNT(v) FROM t", 8),
133+
("SELECT COUNT(*) FROM t WHERE v IS NULL", 1),
134+
] {
135+
let batches = ctx.sql(sql).await.unwrap().collect().await.unwrap();
136+
assert_eq!(
137+
ScalarValue::try_from_array(batches[0].column(0), 0).unwrap(),
138+
ScalarValue::Int64(Some(expected)),
139+
"{sql}",
140+
);
141+
}
142+
}
143+
144+
#[tokio::test]
145+
async fn dynamic_rg_pruning_preserves_missing_null_count() {
146+
for descending in [false, true] {
147+
for has_null in [false, true] {
148+
let file = file_with_missing_null_count(descending, has_null);
149+
for nulls_first in [false, true] {
150+
for pushdown in [false, true] {
151+
let mut config = SessionConfig::new()
152+
.with_target_partitions(1)
153+
.with_batch_size(1)
154+
.with_parquet_page_index_pruning(false);
155+
// Keep the live filter on the row-group path: file-level
156+
// statistics can otherwise prune the entire remaining file.
157+
config.options_mut().execution.collect_statistics = false;
158+
config.options_mut().optimizer.enable_sort_pushdown = false;
159+
config
160+
.options_mut()
161+
.optimizer
162+
.enable_topk_dynamic_filter_pushdown = pushdown;
163+
config.options_mut().execution.parquet.pushdown_filters = false;
164+
let ctx = SessionContext::new_with_config(config);
165+
ctx.register_parquet(
166+
"t",
167+
file.path().to_str().unwrap(),
168+
ParquetReadOptions::default(),
169+
)
170+
.await
171+
.unwrap();
172+
let order = if descending { "DESC" } else { "ASC" };
173+
let null_order = if nulls_first { "FIRST" } else { "LAST" };
174+
let sql = format!(
175+
"SELECT v FROM t ORDER BY v {order} NULLS {null_order} LIMIT 1"
176+
);
177+
let plan = ctx
178+
.sql(&sql)
179+
.await
180+
.unwrap()
181+
.create_physical_plan()
182+
.await
183+
.unwrap();
184+
let batches = collect(plan.clone(), ctx.task_ctx()).await.unwrap();
185+
assert_eq!(batches.iter().map(|b| b.num_rows()).sum::<usize>(), 1);
186+
let expected = if has_null && nulls_first {
187+
None
188+
} else {
189+
Some(0)
190+
};
191+
assert_eq!(
192+
ScalarValue::try_from_array(batches[0].column(0), 0).unwrap(),
193+
ScalarValue::Int64(expected),
194+
"{sql}, has_null={has_null}, pushdown={pushdown}",
195+
);
196+
let metrics = MetricsFinder::find_metrics(plan.as_ref()).unwrap();
197+
let pruned = metrics
198+
.sum_by_name("row_groups_pruned_dynamic_filter")
199+
.unwrap()
200+
.as_usize();
201+
if pushdown {
202+
assert!(
203+
pruned > 0,
204+
"runtime pruning must run: {sql}, has_null={has_null}\n{metrics}\n{}",
205+
datafusion::physical_plan::displayable(plan.as_ref())
206+
.indent(true),
207+
);
208+
} else {
209+
assert_eq!(pruned, 0);
210+
}
211+
}
212+
}
213+
}
214+
}
215+
}
216+
43217
/// Build five `RecordBatch`es whose `v` column ranges are disjoint:
44218
/// batch `i` carries `v` values `[i*100, (i+1)*100)`. When written with
45219
/// `max_row_group_row_count = 100` each batch lands in its own row group.

‎datafusion/datasource-parquet/src/metadata.rs‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,10 @@ impl<'a> DFParquetMetadata<'a> {
551551
file_metadata.schema_descr(),
552552
) {
553553
Ok(stats_converter) => {
554+
// An omitted count must not become an exact zero in
555+
// file statistics used for pruning and aggregates.
556+
let stats_converter =
557+
stats_converter.with_missing_null_counts_as_zero(false);
554558
let parquet_index = stats_converter.parquet_column_index();
555559
if parquet_index.is_some_and(|index| {
556560
has_untrusted_min_max_order(
@@ -1394,6 +1398,43 @@ mod tests {
13941398
ParquetMetaData::new(file_meta, row_groups)
13951399
}
13961400

1401+
#[test]
1402+
fn test_statistics_preserve_missing_null_counts() {
1403+
let schema_descr = create_schema_descr(1);
1404+
let arrow_schema = create_arrow_schema(1);
1405+
for (null_counts, expected) in [
1406+
(vec![None], Precision::Absent),
1407+
(vec![Some(0), None], Precision::Inexact(0)),
1408+
(vec![Some(2), None], Precision::Inexact(2)),
1409+
(vec![Some(0), Some(0)], Precision::Exact(0)),
1410+
] {
1411+
let row_groups = null_counts
1412+
.into_iter()
1413+
.map(|null_count| {
1414+
create_row_group_with_stats(
1415+
&schema_descr,
1416+
vec![Some(ParquetStatistics::int32(
1417+
Some(1),
1418+
Some(10),
1419+
None,
1420+
null_count,
1421+
false,
1422+
))],
1423+
10,
1424+
)
1425+
})
1426+
.collect();
1427+
let metadata =
1428+
create_parquet_metadata(Arc::clone(&schema_descr), row_groups);
1429+
let statistics = DFParquetMetadata::statistics_from_parquet_metadata(
1430+
&metadata,
1431+
&arrow_schema,
1432+
)
1433+
.unwrap();
1434+
assert_eq!(statistics.column_statistics[0].null_count, expected);
1435+
}
1436+
}
1437+
13971438
#[test]
13981439
fn test_summarize_null_counts() {
13991440
let schema_descr = create_schema_descr(1);

‎datafusion/datasource-parquet/src/push_decoder.rs‎

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -253,11 +253,6 @@ impl RowGroupPruner {
253253
.map(Vec::as_slice),
254254
row_group_metadatas,
255255
arrow_schema: self.arrow_schema.as_ref(),
256-
// Match the existing static row-group pruning behavior: when a
257-
// statistic's null count is missing, treat it as zero. This is
258-
// sound for runtime pruning because the predicate only needs to
259-
// prove a row group *cannot* contain matching rows.
260-
missing_null_counts_as_zero: true,
261256
};
262257

263258
match pp.prune(&stats) {

‎datafusion/datasource-parquet/src/row_group_filter.rs‎

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -335,11 +335,6 @@ impl RowGroupAccessPlanFilter {
335335
column_orders,
336336
row_group_metadatas,
337337
arrow_schema,
338-
// Preserve the existing row-group pruning behavior. This path only
339-
// proves whether matching rows may exist, so it uses the
340-
// StatisticsConverter default for older parquet-rs files where a
341-
// missing null count can mean there are zero nulls.
342-
missing_null_counts_as_zero: true,
343338
};
344339

345340
// try to prune the row groups in a single call
@@ -407,11 +402,6 @@ impl RowGroupAccessPlanFilter {
407402
.map(|&i| &groups[i])
408403
.collect::<Vec<_>>(),
409404
arrow_schema,
410-
// Fully matched row groups require a stronger proof: every row
411-
// must pass the predicate. Missing null counts are unknown here;
412-
// treating them as zero can incorrectly mark nullable row groups as
413-
// fully matched and make limit pruning unsound.
414-
missing_null_counts_as_zero: false,
415405
};
416406

417407
let Ok(inverted_values) = inverted_predicate.prune(&inverted_pruning_stats)
@@ -495,7 +485,6 @@ pub(crate) struct RowGroupPruningStatistics<'a> {
495485
pub(crate) column_orders: Option<&'a [ColumnOrder]>,
496486
pub(crate) row_group_metadatas: Vec<&'a RowGroupMetaData>,
497487
pub(crate) arrow_schema: &'a Schema,
498-
pub(crate) missing_null_counts_as_zero: bool,
499488
}
500489

501490
impl<'a> RowGroupPruningStatistics<'a> {
@@ -510,7 +499,9 @@ impl<'a> RowGroupPruningStatistics<'a> {
510499
self.arrow_schema,
511500
self.parquet_schema,
512501
)?
513-
.with_missing_null_counts_as_zero(self.missing_null_counts_as_zero))
502+
// Missing counts cannot rule out nulls, either when pruning groups or
503+
// when proving that every row matches the predicate.
504+
.with_missing_null_counts_as_zero(false))
514505
}
515506

516507
fn min_max_statistics_converter(
@@ -783,6 +774,48 @@ mod tests {
783774
assert_eq!(row_groups.is_fully_matched(), &vec![false, true, false]);
784775
}
785776

777+
#[test]
778+
fn row_group_pruning_predicate_missing_null_count() {
779+
let schema = Arc::new(Schema::new(vec![Field::new("c1", DataType::Int32, true)]));
780+
let schema_descr = get_test_schema_descr(vec![PrimitiveTypeField::new(
781+
"c1",
782+
PhysicalType::INT32,
783+
)]);
784+
let groups = [None, Some(0), Some(1)].map(|null_count| {
785+
get_row_group_meta_data(
786+
&schema_descr,
787+
vec![ParquetStatistics::int32(
788+
Some(100),
789+
Some(101),
790+
None,
791+
null_count,
792+
false,
793+
)],
794+
)
795+
});
796+
797+
for (expr, expected) in [
798+
(col("c1").is_null(), vec![0, 2]),
799+
(col("c1").is_null().or(col("c1").lt(lit(0))), vec![0, 2]),
800+
(col("c1").lt(lit(0)), vec![]),
801+
] {
802+
let predicate = build_test_pruning_predicate(
803+
logical2physical(&expr, &schema),
804+
Arc::clone(&schema),
805+
);
806+
let mut filter =
807+
RowGroupAccessPlanFilter::new(ParquetAccessPlan::new_all(groups.len()));
808+
filter.prune_by_statistics(
809+
&schema,
810+
&schema_descr,
811+
&groups,
812+
&predicate,
813+
&parquet_file_metrics(),
814+
);
815+
assert_eq!(filter.build().row_group_indexes(), expected, "{expr}");
816+
}
817+
}
818+
786819
#[test]
787820
fn prune_by_limit_preserves_row_selection() {
788821
let field = PrimitiveTypeField::new("c1", PhysicalType::INT32);

‎datafusion/datasource-parquet/src/statistics_order_tests.rs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,6 @@ fn byte_array_order_guard_follows_parquet_type_not_arrow_representation() {
520520
column_orders: metadata.column_orders().map(Vec::as_slice),
521521
row_group_metadatas: file.metadata.row_groups().iter().collect(),
522522
arrow_schema: &schema,
523-
missing_null_counts_as_zero: true,
524523
};
525524
for values in [stats.min_values(&column), stats.max_values(&column)] {
526525
let values = values.unwrap();

0 commit comments

Comments
 (0)