comphead commented on code in PR #26637:
URL: https://github.com/apache/datafusion/pull/26637#discussion_r4361499205


##########
datafusion/physical-plan/src/aggregates/clustered_single_stream.rs:
##########
@@ -335,7 +336,27 @@ impl ClusteredSingleAggregateStream {
                         let Some(spill_context) = spill_context else {

Review Comment:
   The new `None` branch repeats the tail of the success path below: 
`take_completed_result_batch`, `reservation_size_for_table`, 
`start_outputting(.., ReadingInput { table, spill_context })`. It also adds two 
match arms that both call `break_with_err`, with the second `e` shadowing the 
OOM error.
   
   With `spill_context == None` the `has_spills` check below is always false, 
so the success path already does the right thing. Keeping the refused error in 
an `Option` and falling through would remove the duplicate block. The only 
extra logic is in the `Ok(None)` arm, which returns the saved error instead of 
continuing. The same shape applies to 
`ClusteredFinalAggregateStream::Aggregating::handle_stage`, where it saves a 
third copy of the `Outputting { batch, resume: Some(self) }` construction.



##########
datafusion/physical-plan/src/aggregates/clustered_final_stream.rs:
##########
@@ -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
+        ));
+        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<()> {

Review Comment:
   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 `held` (or a single-key 
input) that expects `ResourcesExhausted` would show the fix does not swallow 
genuine exhaustion and exercises the `ok_or(oom)` and `Ok(None)` arms.



##########
datafusion/physical-plan/src/aggregates/clustered_single_stream.rs:
##########
@@ -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(

Review Comment:
   This driver (context with `RuntimeEnvBuilder`, the `held` sampling loop, 
unlimited run then `GreedyMemoryPool::new(held)` run, `batches_to_string` 
comparison) is now the third copy, after `clustered_partial_stream.rs` on 
`main` and `run_fully_sorted` in `clustered_final_stream.rs`. Only the plan 
construction differs, and the final one needs partial-state input. 
`AggregateExec::execute` already dispatches to the clustered stream by mode, 
since `enable_migration_aggregate` defaults to `true`. One shared helper that 
takes `Arc<dyn ExecutionPlan>`, runs it on both pools and returns the output 
and `held` would leave each module with only its plan builder. The helper could 
live next to the existing aggregate test utilities in `aggregates/mod.rs`.
   
   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, 
`aggregate_memory_spill.slt` already sets a 1M limit and notes that sorted 
`generate_series` takes the streaming path. I have not tried it, so it may be 
too fragile.



##########
datafusion/physical-plan/src/aggregates/clustered_final_stream.rs:
##########
@@ -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

Review Comment:
   Both new tests assert `GroupClusteringMode::Full`. The changed branch is 
`spill_context == None`, which is also true for `Partial` clustering with 
temporary files disabled (`DiskManager` without tmp files). That case reaches 
the new code with an active key range defined by the sorted prefix, and it is 
not covered. A case with a two-column key sorted on the first column and tmp 
files disabled would cover it, for the final and the single stream.



##########
datafusion/physical-plan/src/aggregates/clustered_final_stream.rs:
##########
@@ -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

Review Comment:
   Design question for both streams. The fix uses a refused `try_resize` as the 
trigger and then emits. The issue points at the legacy 
`GroupedHashAggregateStream`, 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, since `aggregate_batch` has already allocated the 
new groups. It only makes the accounting consistent.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to