This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion.git
The following commit(s) were added to refs/heads/main by this push:
new 6700d02160 test: bound merge fan-in in the final hash aggregate spill
test (#25424)
6700d02160 is described below
commit 6700d02160b1d2aee228455a715813e4a586cfc3
Author: Kumar Ujjawal <[email protected]>
AuthorDate: Sun Sep 20 14:08:09 2026 +0000
test: bound merge fan-in in the final hash aggregate spill test (#25424)
## Which issue does this PR close?
- Closes #25423.
## Rationale for this change
Case G of `aggregate_memory_spill.slt` runs four final partitions in a 1
MiB greedy pool. A partition that replays its spills cannot spill again.
Its replay table fails to grow when its own merge buffers and the state
of the other partitions fill the pool. CI hit this on two merge-queue
runs (see the issue).
## What changes are included in this PR?
- `aggregate_memory_spill.slt`: set
`datafusion.runtime.max_spill_merge_fan_in = 2` for Case G and reset it
at the end, as #25252 did for `ordered_aggregate_spill.slt`. The memory
limit stays at 1 MiB. Cases A to F are unchanged.
- `FinalHashAggregateStream::new_with_input` builds the stream over a
given input, like `OrderedFinalAggregateStream::new_with_input`. `new`
calls it.
- Tests in `hash_stream.rs` spill and replay partition 0 while
partitions 1 to 3 keep their state in a shared 1 MiB greedy pool.
Channel inputs return `Pending` after each batch, so the interleaving
does not depend on task scheduling. Input sizes follow the observed
reservations: the held partitions read batches until they reserve 512
KiB, and partition 0 reads batches until it has spilled the requested
number of times. So the cases do not depend on the memory accounting of
a platform or feature set, such as `force_hash_collisions`. The tests
check the grouped results against a reference, the spill metrics, and
that the pool returns to the held size after completion, after a drop
during replay, and after an input error, and to zero after all streams
drop. These tests cover the spill and replay lifecycle; the SLT case
covers the fan-in regression.
## What is the testing strategy for this PR?
Local runs on this branch:
- The SLT file 480 times as 16 concurrent copies, each in its own
working directory. Before the change: 12 failures, all in Case G with
the `ResourcesExhausted` error from the issue. After the change: 0
failures.
- The new tests 50 times with default features and 20 times with
`force_hash_collisions` enabled in `datafusion-common`, once with
`--profile ci` and that feature as the extended CI job runs them, and
all `datafusion-physical-plan` unit tests.
- `cargo test -p datafusion-sqllogictest --test sqllogictests` for all
files.
- `cargo fmt --all -- --check` and `cargo clippy --all-targets
--all-features -- -D warnings`.
## Are there any user-facing changes?
No.
---
.../physical-plan/src/aggregates/hash_stream.rs | 299 ++++++++++++++++++++-
.../test_files/aggregate_memory_spill.slt | 10 +
2 files changed, 306 insertions(+), 3 deletions(-)
diff --git a/datafusion/physical-plan/src/aggregates/hash_stream.rs
b/datafusion/physical-plan/src/aggregates/hash_stream.rs
index 8e74e7bec7..d340bf5d43 100644
--- a/datafusion/physical-plan/src/aggregates/hash_stream.rs
+++ b/datafusion/physical-plan/src/aggregates/hash_stream.rs
@@ -736,8 +736,18 @@ impl FinalHashAggregateStream {
));
debug_assert_eq!(agg.input_order_mode, InputOrderMode::Linear);
- let schema = Arc::clone(&agg.schema);
let input = agg.input.execute(partition, Arc::clone(context))?;
+ Self::new_with_input(agg, context, partition, input)
+ }
+
+ /// Builds the stream over `input` instead of executing the plan's input.
+ pub(in crate::aggregates) fn new_with_input(
+ agg: &AggregateExec,
+ context: &Arc<TaskContext>,
+ partition: usize,
+ input: SendableRecordBatchStream,
+ ) -> Result<Self> {
+ let schema = Arc::clone(&agg.schema);
let input_schema = input.schema();
let batch_size = context.session_config().batch_size();
let baseline_metrics = BaselineMetrics::new(&agg.metrics, partition);
@@ -1022,18 +1032,24 @@ mod tests {
use super::*;
use crate::aggregates::{AggregateMode, PhysicalGroupBy};
+ use crate::common::collect;
use crate::execution_plan::ExecutionPlan;
use crate::test::TestMemoryExec;
use crate::test::exec::BarrierExec;
- use arrow::array::{AsArray, Int32Array, Int64Array};
+ use arrow::array::{AsArray, Int32Array, Int64Array, StringViewArray};
use arrow::datatypes::{DataType, Field, Int32Type, Schema};
use datafusion_common::Result;
+ use datafusion_execution::config::SessionConfig;
+ use datafusion_execution::memory_pool::{GreedyMemoryPool, MemoryPool};
use datafusion_execution::runtime_env::RuntimeEnvBuilder;
use datafusion_functions_aggregate::count::count_udaf;
+ use datafusion_functions_aggregate::{min_max::min_udaf, sum::sum_udaf};
use datafusion_physical_expr::aggregate::AggregateExprBuilder;
use datafusion_physical_expr::expressions::col;
- use futures::StreamExt;
+ use futures::channel::mpsc;
+ use futures::{FutureExt, StreamExt};
+ use std::collections::BTreeMap;
#[tokio::test]
async fn test_partial_hash_stream_double_emission_race_condition_bug() ->
Result<()> {
@@ -1607,4 +1623,281 @@ mod tests {
Ok(())
}
+
+ #[derive(Clone, Copy)]
+ enum Finish {
+ Collect,
+ DropDuringReplay,
+ InputError,
+ }
+
+ #[tokio::test]
+ async fn final_hash_spill_replay_with_other_partitions_holding_state() ->
Result<()> {
+ for spills in [1, 2, 3] {
+ run_shared_pool_case(spills, 1024 * 1024, Finish::Collect).await?;
+ }
+ // An unlimited pool produces the reference results without spilling.
+ run_shared_pool_case(0, 10 * 1024 * 1024, Finish::Collect).await
+ }
+
+ #[tokio::test]
+ async fn final_hash_spill_replay_releases_memory_on_drop() -> Result<()> {
+ run_shared_pool_case(1, 1024 * 1024, Finish::DropDuringReplay).await
+ }
+
+ #[tokio::test]
+ async fn final_hash_spill_releases_memory_on_input_error() -> Result<()> {
+ run_shared_pool_case(1, 1024 * 1024, Finish::InputError).await
+ }
+
+ /// Partition 0 spills `spills` times and replays while partitions 1..3
+ /// keep their aggregate state in the same greedy pool. With `spills` at 0,
+ /// partition 0 reads a fixed input that fits in memory.
+ ///
+ /// Input sizes follow the observed reservations, so the cases do not
+ /// depend on the memory accounting of a platform or feature set.
+ ///
+ /// These cases cover the spill and replay lifecycle in a shared pool:
+ /// results, spill metrics, and memory release. Case G in
+ /// `aggregate_memory_spill.slt` covers the merge fan-in regression for
+ /// issue #25423.
+ async fn run_shared_pool_case(
+ spills: usize,
+ limit: usize,
+ finish: Finish,
+ ) -> Result<()> {
+ const PARTITIONS: usize = 4;
+ /// Partitions 1..3 hold at least this much state.
+ const HELD_BYTES: usize = 512 * 1024;
+ /// A case that never reaches its target fails instead of looping.
+ const MAX_BATCHES: i64 = 1000;
+
+ let schema = Arc::new(Schema::new(vec![
+ Field::new("a", DataType::Int64, false),
+ Field::new("b", DataType::Int64, false),
+ Field::new("v", DataType::Int64, false),
+ Field::new("s", DataType::Utf8View, false),
+ ]));
+ let groups = PhysicalGroupBy::new_single(vec![
+ (col("a", &schema)?, "a".into()),
+ (col("b", &schema)?, "b".into()),
+ ]);
+ let expressions = vec![
+ Arc::new(
+ AggregateExprBuilder::new(sum_udaf(), vec![col("v", &schema)?])
+ .schema(Arc::clone(&schema))
+ .alias("sum")
+ .build()?,
+ ),
+ Arc::new(
+ AggregateExprBuilder::new(min_udaf(), vec![col("s", &schema)?])
+ .schema(Arc::clone(&schema))
+ .alias("min")
+ .build()?,
+ ),
+ ];
+ let empty = TestMemoryExec::try_new_exec(&[vec![]],
Arc::clone(&schema), None)?;
+ let partial = AggregateExec::try_new(
+ AggregateMode::Partial,
+ groups.clone(),
+ expressions.clone(),
+ vec![None; 2],
+ empty,
+ Arc::clone(&schema),
+ )?;
+ let partial_schema = partial.schema();
+ let input = TestMemoryExec::try_new_exec(
+ &vec![vec![]; PARTITIONS],
+ Arc::clone(&partial_schema),
+ None,
+ )?;
+ let aggregate = AggregateExec::try_new(
+ AggregateMode::FinalPartitioned,
+ groups.as_final(),
+ expressions,
+ vec![None; 2],
+ input,
+ schema,
+ )?;
+ assert_eq!(aggregate.input_order_mode(), &InputOrderMode::Linear);
+
+ let pool: Arc<dyn MemoryPool> = Arc::new(GreedyMemoryPool::new(limit));
+ let context = Arc::new(
+ TaskContext::default()
+ .with_session_config(SessionConfig::new().with_batch_size(128))
+ .with_runtime(
+ RuntimeEnvBuilder::new()
+ .with_memory_pool(Arc::clone(&pool))
+ .build_arc()?,
+ ),
+ );
+ let mut streams = vec![];
+ let mut senders = vec![];
+ for partition in 0..PARTITIONS {
+ let (sender, receiver) = mpsc::unbounded();
+ let input = Box::pin(RecordBatchStreamAdapter::new(
+ Arc::clone(&partial_schema),
+ receiver,
+ ));
+ let stream = FinalHashAggregateStream::new_with_input(
+ &aggregate, &context, partition, input,
+ )?
+ .into_stream();
+ senders.push(sender);
+ streams.push(stream);
+ }
+ let mut expected = BTreeMap::new();
+ let mut make_batch = |partition: i64, start: i64| {
+ for value in start..start + 128 {
+ let entry = expected
+ .entry((
+ partition,
+ if partition == 0 && value % 128 == 0 {
+ 0
+ } else {
+ value
+ },
+ ))
+ .or_insert_with(|| (0, (value % 2).to_string()));
+ entry.0 += value * 2;
+ }
+ RecordBatch::try_new(
+ Arc::clone(&partial_schema),
+ vec![
+ Arc::new(Int64Array::from(vec![partition; 128])),
+ Arc::new(Int64Array::from_iter_values((start..start +
128).map(
+ |value| {
+ if partition == 0 && value % 128 == 0 {
+ 0
+ } else {
+ value
+ }
+ },
+ ))),
+ Arc::new(Int64Array::from_iter_values(
+ (start..start + 128).map(|v| v * 2),
+ )),
+ Arc::new(StringViewArray::from_iter_values(
+ (start..start + 128).map(|v| if v % 2 == 0 { "0" }
else { "1" }),
+ )),
+ ],
+ )
+ .unwrap()
+ };
+ // Channel inputs return Pending after each supplied batch, so the
+ // interleaving below does not depend on task scheduling.
+ let mut feed = |partition: usize, batch: i64| {
+ senders[partition]
+ .unbounded_send(Ok(make_batch(partition as i64, batch * 128)))
+ .unwrap();
+ assert!(streams[partition].next().now_or_never().is_none());
+ };
+ // Keep the state of partitions 1..3 live while partition 0 spills and
+ // replays.
+ let mut held_batches = 0;
+ while pool.reserved() < HELD_BYTES {
+ assert!(held_batches < MAX_BATCHES, "held state stays small");
+ for partition in 1..PARTITIONS {
+ feed(partition, held_batches);
+ }
+ held_batches += 1;
+ }
+ let held = pool.reserved();
+ let spill_count = ||
aggregate.metrics().unwrap().spill_count().unwrap();
+ assert_eq!(spill_count(), 0);
+ // Feed partition 0 until it has spilled `spills` times. Key (0, 0)
+ // repeats in every batch, so replay must merge its sum across runs.
+ let mut batches = 0;
+ loop {
+ let done = if spills == 0 {
+ batches == 70
+ } else {
+ spill_count() >= spills
+ };
+ if done {
+ break;
+ }
+ assert!(batches < MAX_BATCHES, "partition 0 did not spill");
+ feed(0, batches);
+ batches += 1;
+ }
+ // Add groups after the last spill so replay also merges the final
+ // in-memory run.
+ for _ in 0..8 {
+ feed(0, batches);
+ batches += 1;
+ }
+ assert!(spill_count() >= spills);
+ assert_eq!(spill_count() == 0, spills == 0);
+ let mut first = streams.remove(0);
+ match finish {
+ Finish::Collect => {
+ senders[0].close_channel();
+ let mut output = collect(first).await?;
+ assert_eq!(pool.reserved(), held);
+ for sender in &senders[1..] {
+ sender.close_channel();
+ }
+ for stream in streams.drain(..) {
+ output.extend(collect(stream).await?);
+ }
+ let mut actual = BTreeMap::new();
+ for batch in output {
+ let a = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ let b = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ let sum = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap();
+ let min = batch
+ .column(3)
+ .as_any()
+ .downcast_ref::<StringViewArray>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ assert!(
+ actual
+ .insert(
+ (a.value(row), b.value(row)),
+ (sum.value(row),
min.value(row).to_string())
+ )
+ .is_none()
+ );
+ }
+ }
+ assert_eq!(actual, expected);
+ assert_eq!(spill_count() == 0, spills == 0);
+ }
+ Finish::DropDuringReplay => {
+ senders[0].close_channel();
+ first.next().await.unwrap()?;
+ assert!(pool.reserved() > held);
+ drop(first);
+ assert_eq!(pool.reserved(), held);
+ }
+ Finish::InputError => {
+ senders[0]
+ .unbounded_send(datafusion_common::exec_err!(
+ "injected input failure"
+ ))
+ .unwrap();
+ let error = first.next().await.unwrap().unwrap_err();
+ assert!(error.to_string().contains("injected input failure"));
+ assert_eq!(pool.reserved(), held);
+ drop(first);
+ }
+ }
+ drop(streams);
+ assert_eq!(pool.reserved(), 0);
+ Ok(())
+ }
}
diff --git a/datafusion/sqllogictest/test_files/aggregate_memory_spill.slt
b/datafusion/sqllogictest/test_files/aggregate_memory_spill.slt
index 53d515bf7f..48622127bc 100644
--- a/datafusion/sqllogictest/test_files/aggregate_memory_spill.slt
+++ b/datafusion/sqllogictest/test_files/aggregate_memory_spill.slt
@@ -198,6 +198,13 @@ FROM (
statement ok
SET datafusion.execution.target_partitions = 4
+# Four final partitions share the greedy pool. Limit merge buffers so a
+# partition that replays its spills can allocate while the other partitions
+# retain their aggregate state.
+# See https://github.com/apache/datafusion/issues/25423
+statement ok
+SET datafusion.runtime.max_spill_merge_fan_in = 2
+
query II
SELECT count(*), sum(total)
FROM (
@@ -230,6 +237,9 @@ FROM (
statement ok
RESET datafusion.runtime.memory_limit
+statement ok
+RESET datafusion.runtime.max_spill_merge_fan_in
+
statement ok
RESET datafusion.execution.batch_size
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]