Skip to content

Commit c5bb62e

Browse files
committed
fix: preserve NULL groups in TopK aggregation
Grouped TopK aggregation previously dropped groups whose MIN/MAX inputs were all NULL. Track bounded all-NULL candidates alongside valued groups and emit them for the final sort to rank and truncate. Handle NULL-to-value transitions, fully reuse freed hash-table slots, and keep NULL bookkeeping off the common no-NULL hot path. Nullable MIN/MAX with NULLS FIRST is not monotonic for bounded aggregation: a group can move from NULL to a worse non-NULL rank. Skip the TopK pushdown in that case to preserve exact results while retaining it for NULLS LAST and non-nullable inputs. Closes #23440, closes #22190.
1 parent 2f25454 commit c5bb62e

6 files changed

Lines changed: 823 additions & 63 deletions

File tree

datafusion/physical-optimizer/src/topk_aggregation.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ impl TopKAggregation {
4646
aggr: &AggregateExec,
4747
order_by: &str,
4848
order_desc: bool,
49+
nulls_first: bool,
4950
limit: usize,
5051
) -> Option<Arc<dyn ExecutionPlan>> {
5152
// Current only support single group key
@@ -66,6 +67,26 @@ impl TopKAggregation {
6667

6768
// Check if this is ordering by an aggregate function (MIN/MAX)
6869
if let Some((field, desc)) = aggr.get_minmax_desc() {
70+
// A nullable MIN/MAX starts as NULL and becomes non-NULL when the
71+
// group sees its first value. With NULLS FIRST that transition
72+
// worsens the group's rank, so a bounded aggregation cannot safely
73+
// discard other NULL groups. Use regular aggregation for exact
74+
// results. Non-nullable inputs never take this transition and can
75+
// still use TopK.
76+
let input_nullable = aggr
77+
.aggr_expr()
78+
.iter()
79+
.exactly_one()
80+
.ok()?
81+
.expressions()
82+
.into_iter()
83+
.exactly_one()
84+
.ok()?
85+
.nullable(aggr.input_schema.as_ref())
86+
.ok()?;
87+
if nulls_first && input_nullable {
88+
return None;
89+
}
6990
// ensure the sort direction matches aggregate function
7091
if desc != order_desc {
7192
return None;
@@ -100,6 +121,7 @@ impl TopKAggregation {
100121
let order = sort.properties().output_ordering()?;
101122
let order = order.iter().exactly_one().ok()?;
102123
let order_desc = order.options.descending;
124+
let nulls_first = order.options.nulls_first;
103125
let order = order.expr.downcast_ref::<Column>()?;
104126
let mut cur_col_name = order.name().to_string();
105127
let limit = sort.fetch()?;
@@ -111,7 +133,13 @@ impl TopKAggregation {
111133
}
112134
if let Some(aggr) = plan.downcast_ref::<AggregateExec>() {
113135
// either we run into an Aggregate and transform it
114-
match Self::transform_agg(aggr, &cur_col_name, order_desc, limit) {
136+
match Self::transform_agg(
137+
aggr,
138+
&cur_col_name,
139+
order_desc,
140+
nulls_first,
141+
limit,
142+
) {
115143
None => cardinality_preserved = false,
116144
Some(plan) => return Ok(Transformed::yes(plan)),
117145
}

datafusion/physical-plan/src/aggregates/grouped_topk_stream.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,26 @@ impl GroupedTopKAggregateStream {
149149
if has_nulls && self.is_group_by_only() {
150150
self.null_group_seen = true;
151151
}
152+
// Keep the common no-NULL path free of NULL bookkeeping. Once a NULL
153+
// group exists, use the NULL-aware path until it has been resolved.
154+
let track_null_groups = !self.is_group_by_only()
155+
&& (has_nulls || self.priority_map.has_null_groups());
152156
for row_idx in 0..len {
153157
if has_nulls && vals.is_null(row_idx) {
158+
// MIN/MAX ignore NULL inputs, but a group whose values are all
159+
// NULL must still be emitted with a NULL aggregate value, so
160+
// track it. (GROUP BY-only aggregations handle NULL group keys
161+
// via `null_group_seen` instead.)
162+
if !self.is_group_by_only() {
163+
self.priority_map.insert_null(row_idx);
164+
}
154165
continue;
155166
}
156-
self.priority_map.insert(row_idx)?;
167+
if track_null_groups {
168+
self.priority_map.insert_with_null_groups(row_idx)?;
169+
} else {
170+
self.priority_map.insert(row_idx)?;
171+
}
157172
}
158173
Ok(())
159174
}

0 commit comments

Comments
 (0)