Repository navigation
fix: emit completed groups when fully sorted final and single aggrega… #26637
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -261,7 +261,8 @@ impl ClusteredFinalAggregateStream { | |
| /// 1. Start. | ||
| /// 2. Merge one input batch: | ||
| /// - If memory fits and no groups are complete, continue reading input. | ||
| /// - If OOM, spill. | ||
| /// - If OOM, spill. Without spilling, emit completed groups through [3], | ||
| /// and fail only when no group is complete. | ||
| /// 3. Prepare output: | ||
| /// - Before any spill, a prefix of groups is complete: materialize the | ||
| /// entire prefix once, retaining the input and active groups to resume | ||
|
|
@@ -340,7 +341,15 @@ impl Aggregating { | |
| Ok(()) => {} | ||
| Err(oom @ DataFusionError::ResourcesExhausted(_)) => { | ||
| let Some(spill_context) = self.spill_context.as_mut() else { | ||
| return Err(oom); | ||
| // Without spilling, emitting completed groups is the only | ||
| // way to release memory. Fail only when none is complete. | ||
| let batch = | ||
| self.table.take_completed_result_batch()?.ok_or(oom)?; | ||
| timer.done(); | ||
| return Ok(Some(ExecutionStage::Outputting(Outputting { | ||
| batch, | ||
| resume: Some(self), | ||
| }))); | ||
| }; | ||
| if self.table.is_empty() { | ||
| return Err(oom); | ||
|
|
@@ -464,8 +473,11 @@ mod tests { | |
| use crate::test::TestMemoryExec; | ||
| use arrow::array::{Int64Array, StringViewArray}; | ||
| use arrow::datatypes::{DataType, Field, Schema}; | ||
| use datafusion_common::test_util::batches_to_string; | ||
| use datafusion_execution::config::SessionConfig; | ||
| use datafusion_execution::memory_pool::{GreedyMemoryPool, MemoryPool}; | ||
| use datafusion_execution::memory_pool::{ | ||
| GreedyMemoryPool, MemoryPool, UnboundedMemoryPool, | ||
| }; | ||
| use datafusion_execution::runtime_env::RuntimeEnvBuilder; | ||
| use datafusion_functions_aggregate::{min_max::min_udaf, sum::sum_udaf}; | ||
| use datafusion_physical_expr::PhysicalSortExpr; | ||
|
|
@@ -732,4 +744,87 @@ mod tests { | |
| assert_eq!(pool.reserved(), 0); | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Runs a final `SUM(k) GROUP BY k` over two partial-state batches sorted on | ||
| /// `k` that share a boundary key. Returns the output and the most memory the | ||
| /// stream held while handing off output. | ||
| async fn run_fully_sorted( | ||
| pool: Arc<dyn MemoryPool>, | ||
| ) -> Result<(Vec<RecordBatch>, usize)> { | ||
| let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Int64, false)])); | ||
| let groups = PhysicalGroupBy::new_single(vec![(col("k", &schema)?, "k".into())]); | ||
| let expressions = vec![Arc::new( | ||
| AggregateExprBuilder::new(sum_udaf(), vec![col("k", &schema)?]) | ||
| .schema(Arc::clone(&schema)) | ||
| .alias("sum") | ||
| .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], | ||
| empty, | ||
| Arc::clone(&schema), | ||
| )?; | ||
| let partial_schema = partial.schema(); | ||
| let batches = [0..=256, 256..=512] | ||
| .into_iter() | ||
| .map(|keys| { | ||
| let k = Int64Array::from_iter_values(keys); | ||
| RecordBatch::try_new( | ||
| Arc::clone(&partial_schema), | ||
| vec![Arc::new(k.clone()), Arc::new(k)], | ||
| ) | ||
| }) | ||
| .collect::<Result<Vec<_>, _>>()?; | ||
| let ordering = | ||
| LexOrdering::new([PhysicalSortExpr::new_default(col("k", &partial_schema)?)]) | ||
| .unwrap(); | ||
| let input = | ||
| TestMemoryExec::try_new(&[batches], Arc::clone(&partial_schema), None)? | ||
| .try_with_sort_information(vec![ordering])?; | ||
| let aggregate = AggregateExec::try_new( | ||
| AggregateMode::Final, | ||
| groups.as_final(), | ||
| expressions, | ||
| vec![None], | ||
| Arc::new(TestMemoryExec::update_cache(&Arc::new(input))), | ||
| schema, | ||
| )?; | ||
| assert!(matches!( | ||
| aggregate.group_clustering_mode(), | ||
| GroupClusteringMode::Full | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both new tests assert |
||
| )); | ||
| let context = Arc::new( | ||
| TaskContext::default().with_runtime( | ||
| RuntimeEnvBuilder::new() | ||
| .with_memory_pool(Arc::clone(&pool)) | ||
| .build_arc()?, | ||
| ), | ||
| ); | ||
| let mut stream = | ||
| ClusteredFinalAggregateStream::new(&aggregate, &context, 0)?.into_stream(); | ||
| // Each output batch is a single slice, so the stream releases it before | ||
| // handing it off and holds only the table. | ||
| let mut held = pool.reserved(); | ||
| let mut output = vec![]; | ||
| while let Some(batch) = stream.next().await.transpose()? { | ||
| held = held.max(pool.reserved()); | ||
| output.push(batch); | ||
| } | ||
| Ok((output, held)) | ||
| } | ||
|
|
||
| /// The limit fits the table with only the active group, but not the table | ||
| /// before the completed groups are emitted. The result must not change. | ||
| #[tokio::test] | ||
| async fn completed_groups_under_memory_pressure() -> Result<()> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test only checks that a tight limit now succeeds. Nothing checks the other half of the contract in the docs: the error is still returned when no group can be released. A rerun with a limit just below |
||
| let (expected, held) = | ||
| run_fully_sorted(Arc::new(UnboundedMemoryPool::default())).await?; | ||
| let (actual, _) = run_fully_sorted(Arc::new(GreedyMemoryPool::new(held))).await?; | ||
| assert_eq!(batches_to_string(&actual), batches_to_string(&expected)); | ||
| Ok(()) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,8 +82,9 @@ use crate::{RecordBatchStream, SendableRecordBatchStream}; | |
| /// only the current group remains active after completed groups are emitted, and | ||
| /// memory usage does not grow with the total number of groups. | ||
| /// | ||
| /// If a memory reservation nevertheless fails, the stream returns the error | ||
| /// directly, indicating an unexpected behavior. | ||
| /// If the reservation after an input batch fails, `ClusteredSingleAggregateStream` | ||
| /// still emits the completed groups, and returns the error only when no group is | ||
| /// complete. | ||
| /// | ||
| /// ## Partial group clustering | ||
| /// | ||
|
|
@@ -335,7 +336,27 @@ impl ClusteredSingleAggregateStream { | |
| let Some(spill_context) = spill_context else { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new With |
||
| // `None` means spilling is not supported, see comments | ||
| // at `ClusteredSingleAggregateState` for details. | ||
| return Self::break_with_err(e); | ||
| // Emitting completed groups is then the only way to | ||
| // release memory. Fail only when none is complete. | ||
| let timer = elapsed_compute.timer(); | ||
| let result = table.take_completed_result_batch(); | ||
| timer.done(); | ||
| return match result { | ||
| Ok(Some(batch)) => { | ||
| let table_memory = | ||
| Self::reservation_size_for_table(&table, None); | ||
| self.start_outputting( | ||
| batch, | ||
| table_memory, | ||
| ClusteredSingleAggregateState::ReadingInput { | ||
| table, | ||
| spill_context: None, | ||
| }, | ||
| ) | ||
| } | ||
| Ok(None) => Self::break_with_err(e), | ||
| Err(e) => Self::break_with_err(e), | ||
| }; | ||
| }; | ||
| if table.is_empty() { | ||
| return Self::break_with_internal_err( | ||
|
|
@@ -766,10 +787,10 @@ mod tests { | |
| use crate::test::TestMemoryExec; | ||
| use arrow::array::Int64Array; | ||
| use arrow::datatypes::{DataType, Field, Schema}; | ||
| use datafusion_common::test_util::batches_to_sort_string; | ||
| use datafusion_common::test_util::{batches_to_sort_string, batches_to_string}; | ||
| use datafusion_execution::config::SessionConfig; | ||
| use datafusion_execution::memory_pool::{ | ||
| GreedyMemoryPool, MemoryPool, PeakRecordingPool, | ||
| GreedyMemoryPool, MemoryPool, PeakRecordingPool, UnboundedMemoryPool, | ||
| }; | ||
| use datafusion_execution::runtime_env::RuntimeEnvBuilder; | ||
| use datafusion_functions_aggregate::sum::sum_udaf; | ||
|
|
@@ -851,4 +872,70 @@ mod tests { | |
| ); | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Runs a single `SUM(k) GROUP BY k` over two batches sorted on `k` that | ||
| /// share a boundary key. Returns the output and the most memory the stream | ||
| /// held while handing off output. | ||
| async fn run_fully_sorted( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This driver (context with The unit-test form makes sense here since a SQL memory limit cannot target one stream's table size. If a SQL form is feasible, |
||
| pool: Arc<dyn MemoryPool>, | ||
| ) -> Result<(Vec<RecordBatch>, usize)> { | ||
| let schema = Arc::new(Schema::new(vec![Field::new("k", DataType::Int64, false)])); | ||
| let batches = [0..=256, 256..=512] | ||
| .into_iter() | ||
| .map(|keys| { | ||
| let k = Int64Array::from_iter_values(keys); | ||
| RecordBatch::try_new(Arc::clone(&schema), vec![Arc::new(k)]) | ||
| }) | ||
| .collect::<Result<Vec<_>, _>>()?; | ||
| let input = TestMemoryExec::try_new(&[batches], Arc::clone(&schema), None)? | ||
| .try_with_sort_information(vec![ | ||
| LexOrdering::new([PhysicalSortExpr::new_default(col("k", &schema)?)]) | ||
| .unwrap(), | ||
| ])?; | ||
| let aggregate = AggregateExec::try_new( | ||
| AggregateMode::Single, | ||
| PhysicalGroupBy::new_single(vec![(col("k", &schema)?, "k".into())]), | ||
| vec![Arc::new( | ||
| AggregateExprBuilder::new(sum_udaf(), vec![col("k", &schema)?]) | ||
| .schema(Arc::clone(&schema)) | ||
| .alias("sum") | ||
| .build()?, | ||
| )], | ||
| vec![None], | ||
| Arc::new(TestMemoryExec::update_cache(&Arc::new(input))), | ||
| Arc::clone(&schema), | ||
| )?; | ||
| assert!(matches!( | ||
| aggregate.group_clustering_mode, | ||
| GroupClusteringMode::Full | ||
| )); | ||
| let context = Arc::new( | ||
| TaskContext::default().with_runtime( | ||
| RuntimeEnvBuilder::new() | ||
| .with_memory_pool(Arc::clone(&pool)) | ||
| .build_arc()?, | ||
| ), | ||
| ); | ||
| let mut stream = ClusteredSingleAggregateStream::new(&aggregate, &context, 0)?; | ||
| // Each output batch is a single slice, so the stream releases it before | ||
| // handing it off and holds only the table. | ||
| let mut held = pool.reserved(); | ||
| let mut output = vec![]; | ||
| while let Some(batch) = stream.next().await.transpose()? { | ||
| held = held.max(pool.reserved()); | ||
| output.push(batch); | ||
| } | ||
| Ok((output, held)) | ||
| } | ||
|
|
||
| /// The limit fits the table with only the active group, but not the table | ||
| /// before the completed groups are emitted. The result must not change. | ||
| #[tokio::test] | ||
| async fn fully_sorted_completed_groups_under_memory_pressure() -> Result<()> { | ||
| let (expected, held) = | ||
| run_fully_sorted(Arc::new(UnboundedMemoryPool::default())).await?; | ||
| let (actual, _) = run_fully_sorted(Arc::new(GreedyMemoryPool::new(held))).await?; | ||
| assert_eq!(batches_to_string(&actual), batches_to_string(&expected)); | ||
| Ok(()) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Design question for both streams. The fix uses a refused
try_resizeas the trigger and then emits. The issue points at the legacyGroupedHashAggregateStream, which emits completed groups before it updates the reservation. For the non-spilling case, taking the completed groups first and then sizing the reservation once would not need an error path, and a failure would then mean the active group itself does not fit. Is there a reason to prefer the error-driven fallback, for example keeping the success path untouched? If so, a short comment saying why would help. Note that neither variant lowers the real peak, sinceaggregate_batchhas already allocated the new groups. It only makes the accounting consistent.