Skip to content

Commit 977bdd1

Browse files
committed
fix: duplicate groups after spilling in legacy hash aggregation with a nested group key
When GroupedHashAggregateStream spills and switches to merging the sorted spill files it relies on GroupOrderingFull, which requires group ids to be assigned in first-seen order. It recreated its group values collector to guarantee that only when there was more than one group column, assuming a single column always uses a sequential single-column collector. A single nested column (Struct, Map) has no specialized single-column collector and is handled by GroupValuesColumn through a row-backed column, whose vectorized interning assigns ids out of input order. The ordering then emitted groups that were still in progress and the next batch reopened them as new groups, so the same key came out more than once with its aggregates split between the rows. Always recreate the collector for the merge phase.
1 parent 4a93ade commit 977bdd1

2 files changed

Lines changed: 124 additions & 8 deletions

File tree

‎datafusion/core/tests/memory_limit/mod.rs‎

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,13 @@ mod nlj_spill_unmatched;
2626
mod repartition_mem_limit;
2727
mod union_nullable_spill;
2828
mod view_spill_compaction;
29-
use arrow::array::{ArrayRef, DictionaryArray, Int32Array, RecordBatch, StringViewArray};
29+
use arrow::array::{
30+
ArrayRef, DictionaryArray, Int32Array, Int64Array, Int64Builder, ListBuilder,
31+
RecordBatch, StringViewArray, StructArray,
32+
};
33+
use arrow::buffer::NullBuffer;
3034
use arrow::compute::SortOptions;
31-
use arrow::datatypes::{Int32Type, SchemaRef};
35+
use arrow::datatypes::{Fields, Int32Type, SchemaRef};
3236
use arrow_schema::{DataType, Field, Schema};
3337
use datafusion::assert_batches_eq;
3438
use datafusion::config::SpillCompression;
@@ -43,6 +47,7 @@ use datafusion::physical_plan::{ExecutionPlan, SendableRecordBatchStream};
4347
use datafusion::prelude::{SessionConfig, SessionContext};
4448
use datafusion_catalog::Session;
4549
use datafusion_catalog::streaming::StreamingTable;
50+
use datafusion_common::test_util::batches_to_sort_string;
4651
use datafusion_common::{Result, assert_contains};
4752
use datafusion_execution::TaskContext;
4853
use datafusion_execution::disk_manager::{DiskManagerBuilder, DiskManagerMode};
@@ -133,7 +138,6 @@ async fn group_by_hash() {
133138
#[cfg(not(feature = "force_hash_collisions"))]
134139
mod count_distinct_spill {
135140
use super::*;
136-
use arrow::array::Int64Array;
137141
use datafusion::assert_batches_sorted_eq;
138142

139143
/// `count(distinct)` over integers under a memory limit.
@@ -220,6 +224,117 @@ mod count_distinct_spill {
220224
}
221225
}
222226

227+
/// `GROUP BY` on a single nested key in the legacy `GroupedHashAggregateStream`
228+
/// under a memory limit.
229+
///
230+
/// After spilling, the legacy stream re-aggregates the merged spill files with
231+
/// `GroupOrderingFull`, which requires group ids in first-seen order. A single
232+
/// nested key has no specialized single-column group values implementation and
233+
/// is handled by the multi-column one, whose vectorized interning does not
234+
/// guarantee that order. If the stream keeps that implementation for the merge
235+
/// phase, groups are emitted while still in progress and come out duplicated.
236+
const NESTED_KEY_ROWS: usize = 200_000;
237+
const NESTED_KEY_GROUPS: i64 = 16;
238+
const NESTED_KEY_BATCH_ROWS: usize = 8_192;
239+
240+
/// Small enough that the final stages must spill their `count(distinct)`
241+
/// state, large enough for the merge of the spilled runs. A `FairSpillPool`
242+
/// caps every stage at its share, so the partial stages emit early and the
243+
/// final stages spill repeatedly, which produces the many small merged batches
244+
/// this bug needs.
245+
const NESTED_KEY_MEMORY_LIMIT: usize = 4 * 1024 * 1024;
246+
247+
fn nested_key_struct_fields() -> Fields {
248+
Fields::from(vec![
249+
Field::new("list", DataType::new_list(DataType::Int64, true), true),
250+
Field::new("num", DataType::Int64, true),
251+
])
252+
}
253+
254+
/// `st` is mostly `{list: [g, g + 1], num: g}` for group `g`, with a sprinkle
255+
/// of null lists, empty lists, null nums and null structs so that keys of
256+
/// different shapes meet in the same batches. `v` is unique.
257+
fn nested_key_table() -> MemTable {
258+
let schema = Arc::new(Schema::new(vec![
259+
Field::new_struct("st", nested_key_struct_fields(), true),
260+
Field::new("v", DataType::Int64, false),
261+
]));
262+
let batches = (0..NESTED_KEY_ROWS)
263+
.step_by(NESTED_KEY_BATCH_ROWS)
264+
.map(|start| {
265+
let rows = start..(start + NESTED_KEY_BATCH_ROWS).min(NESTED_KEY_ROWS);
266+
let mut list = ListBuilder::new(Int64Builder::new());
267+
let mut num = Vec::with_capacity(rows.len());
268+
let mut valid = Vec::with_capacity(rows.len());
269+
for row in rows.clone() {
270+
let group = row as i64 % NESTED_KEY_GROUPS;
271+
match row % 37 {
272+
0 => list.append_null(),
273+
1 => list.append(true),
274+
_ => {
275+
list.values().append_value(group);
276+
list.values().append_value(group + 1);
277+
list.append(true);
278+
}
279+
}
280+
num.push((row % 41 != 0).then_some(group));
281+
valid.push(row % 43 != 0);
282+
}
283+
let st = StructArray::new(
284+
nested_key_struct_fields(),
285+
vec![Arc::new(list.finish()), Arc::new(Int64Array::from(num))],
286+
Some(NullBuffer::from(valid)),
287+
);
288+
RecordBatch::try_new(
289+
Arc::clone(&schema),
290+
vec![
291+
Arc::new(st),
292+
Arc::new(Int64Array::from_iter_values(rows.map(|row| row as i64))),
293+
],
294+
)
295+
.unwrap()
296+
})
297+
.collect();
298+
MemTable::try_new(schema, vec![batches]).unwrap()
299+
}
300+
301+
const NESTED_KEY_QUERY: &str = "select st, count(v), count(distinct v), sum(v), avg(v), min(v), max(v) \
302+
from t group by st";
303+
304+
/// Runs the query on the legacy stream, with or without a memory limit.
305+
async fn run_nested_key_query(memory_limit: Option<usize>) -> String {
306+
let mut runtime =
307+
RuntimeEnvBuilder::new().with_disk_manager_builder(DiskManagerBuilder::default());
308+
if let Some(limit) = memory_limit {
309+
runtime = runtime.with_memory_pool(Arc::new(FairSpillPool::new(limit)));
310+
}
311+
let config = SessionConfig::new()
312+
.with_target_partitions(4)
313+
// small batches: the merged spill stream arrives in many batches and
314+
// groups span batch boundaries
315+
.with_batch_size(64)
316+
.set_bool("datafusion.execution.enable_migration_aggregate", false);
317+
let ctx = SessionContext::new_with_config_rt(config, runtime.build_arc().unwrap());
318+
ctx.register_table("t", Arc::new(nested_key_table()))
319+
.unwrap();
320+
let batches = ctx
321+
.sql(NESTED_KEY_QUERY)
322+
.await
323+
.unwrap()
324+
.collect()
325+
.await
326+
.unwrap();
327+
batches_to_sort_string(&batches)
328+
}
329+
330+
#[tokio::test]
331+
async fn legacy_stream_nested_key_spill_keeps_groups_unique() {
332+
let expected = run_nested_key_query(None).await;
333+
let actual = run_nested_key_query(Some(NESTED_KEY_MEMORY_LIMIT)).await;
334+
// A duplicated group shows up as extra rows with the counts split
335+
assert_eq!(actual, expected);
336+
}
337+
223338
#[tokio::test]
224339
async fn join_by_key_multiple_partitions() {
225340
let config = SessionConfig::new().with_target_partitions(2);

‎datafusion/physical-plan/src/aggregates/grouped_hash_stream.rs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,15 +1372,16 @@ impl GroupedHashAggregateStream {
13721372

13731373
// Recreate `group_values` for streaming merge so group ids are assigned
13741374
// in first-seen order, as required by `GroupOrderingFull`.
1375-
// The pre-spill multi-column collector may use `vectorized_intern`, which
1376-
// can assign new group ids out of input order under hash collisions.
1375+
// The pre-spill collector may use `vectorized_intern`, which can assign
1376+
// new group ids out of input order under hash collisions. That is the
1377+
// multi-column collector, which also serves a single group column
1378+
// whose type has no specialized single-column collector (for example
1379+
// `Struct` or `Map`), so recreate unconditionally.
13771380
let group_schema = self
13781381
.spill_state
13791382
.merging_group_by
13801383
.group_schema(&self.spill_state.spill_schema)?;
1381-
if group_schema.fields().len() > 1 {
1382-
self.group_values = new_group_values(group_schema, &self.group_ordering)?;
1383-
}
1384+
self.group_values = new_group_values(group_schema, &self.group_ordering)?;
13841385

13851386
// Use `OutOfMemoryMode::ReportError` from this point on
13861387
// to ensure we don't spill the spilled data to disk again.

0 commit comments

Comments
 (0)