jayzhan211 opened a new issue, #24980:
URL: https://github.com/apache/datafusion/issues/24980

   ### Describe the bug
   
   `LimitedDistinctAggregation` rewrites `SELECT DISTINCT ... LIMIT n` by 
pushing `n` into `AggregateExec` as a soft limit (`LimitOptions`, shown as 
`lim=[n]` in `EXPLAIN`). The intent is that the aggregate stops consuming input 
as soon as `n` distinct groups exist, and the downstream limit operator trims 
the exact row count.
   
   The legacy `GroupedHashAggregateStream` honours this in every mode via 
`hit_soft_group_limit()`. Among the new dedicated streams (#22710), only 
`PartialHashAggregateStream` and `FinalHashAggregateStream` implement it. 
`SingleHashAggregateStream` has no `group_values_soft_limit` at all: given a 
`Single` / `SinglePartitioned` aggregate with `lim=[n]`, it reads its whole 
input and materialises every group before emitting.
   
   **Current `main` (since #24961):** the soft limit is treated as a hint 
during stream selection, and the `limit_options.is_none()` conditions were 
dropped from the stream predicates (see the doc comment on 
`AggregateExec::limit_options`). Single-stage distinct aggregates therefore 
route to `SingleHashAggregateStream` and lose the early termination. The plan 
text is unchanged, so this is a performance regression with no visible signal.
   
   **Before #24961:** `should_use_single_hash_stream()` required 
`limit_options.is_none()`, so any single-stage aggregate carrying a soft limit 
fell back to the legacy `GroupedHashAggregateStream` and kept the early 
termination. The gap in `SingleHashAggregateStream` was masked by that routing, 
not fixed, and the legacy stream is now deprecated and slated for removal 
(#22710), so restoring the fallback is not a long-term option.
   
   Nothing in the test suite catches this, because the plan text is identical 
before and after #24961: the node prints `lim=[n]` whether or not the selected 
stream acts on it. For example, `aggregate.slt` has
   
   ```
   AggregateExec: mode=SinglePartitioned, gby=[c3@0 as c3, min(...)@1 as ...], 
aggr=[], lim=[5]
   ```
   
   which passes unchanged while scanning all of its input under the limit. The 
same shape appears for `SELECT DISTINCT ... LIMIT n` whenever the aggregate is 
planned as a single stage: `target_partitions = 1`, or hash-partitioned input 
on the group keys (`SinglePartitioned`).
   
   Streams affected:
   
   | Stream | Soft limit | Notes |
   |---|---|---|
   | `GroupedHashAggregateStream` (legacy) | yes | all modes |
   | `PartialHashAggregateStream` | yes | |
   | `FinalHashAggregateStream` | yes | skipped once the stream has spilled |
   | `SingleHashAggregateStream` | **no** | selected for single-stage 
distinct-with-limit since #24961; previously masked by the legacy fallback |
   | `PartialReduceHashAggregateStream` | no | only matters if a limit is ever 
pushed into a `PartialReduce` node |
   | `OrderedSingleAggregateStream` / `OrderedFinalAggregateStream` | no | low 
impact: they emit groups incrementally, so the downstream limit stops polling 
after ~one extra batch |
   
   ### To Reproduce
   
   ```sql
   set datafusion.execution.target_partitions = 1;
   create table t as select v % 1000 as k from generate_series(1, 10000000) as 
s(v);
   explain analyze select distinct k from t limit 5;
   ```
   
   The plan shows `AggregateExec: mode=Single, ..., aggr=[], lim=[5]`. On 
current `main` the scan's `output_rows` reports all 10M rows. Before #24961 
(legacy stream selected), and after the fix proposed below, it stays at a 
single batch.
   
   ### Expected behavior
   
   `SingleHashAggregateStream` (and ideally the other new streams that can 
accumulate a distinct group count) should stop reading input once `limit <= 
building_group_count()`, matching the legacy stream and 
`PartialHashAggregateStream`.
   
   ## Proposed solution
   
   1. **Implement the soft limit in `SingleHashAggregateStream`.** Mirror 
`PartialHashAggregateStream`:
      - Add `group_values_soft_limit: Option<usize>`, initialised from 
`agg.limit_options().map(|c| c.limit())`.
      - In the `ReadingInput` branch, after 
`hash_table.aggregate_batch(&batch)`, check `limit <= 
hash_table.building_group_count()`. When hit, `close_input()` and take the same 
transition as the `Poll::Ready(None)` (input exhausted) arm.
      - Reusing the input-exhausted transition keeps spilling correct for free: 
if the stream has already spilled, it goes through `PreparingMergeInput` / 
`MergingSpills` as usual. Stopping early is still valid there, because the 
in-memory table alone already holds at least `limit` distinct groups, and the 
soft limit only applies to `aggr=[]` (see 
`is_unordered_unfiltered_group_by_distinct`), so there is no accumulator state 
to lose.
      - `OrderedSingleAggregateStream` does not need it for the reason in the 
table above.
      - `PartialReduceHashAggregateStream` can get the same treatment if we 
ever push limits into `PartialReduce`; otherwise document that it is 
unsupported.
   
   2. **Add a test that fails without the fix.** The plan text cannot catch 
this, so the test has to observe consumption. A unit test next to the existing 
partial/final soft-limit test in `aggregates/mod.rs` that runs 
`AggregateMode::Single` with `LimitOptions::new(2)` over several small input 
batches, asserts `StreamType::SingleHash`, and checks either (a) the output row 
count equals the limit, or (b) via a counting input stream, that not every 
input batch was polled.
   
   3. **Keep the `AggregateExec::limit_options` doc comment in sync** so the 
list of streams that honour the hint stays accurate.
   
   Fallback if (1) is not wanted right away: restore `limit_options.is_none()` 
in `should_use_single_hash_stream()` so the single-stage distinct-with-limit 
case routes back to the legacy stream until it is removed. That reintroduces 
the legacy dependency #24961 dropped, so I'd prefer implementing the limit 
directly.
   
   ### Additional context
   
   - Part of #22710 (split aggregation into dedicated streams). Routing change 
that exposed this: #24961.
   - The soft limit is set by `LimitedDistinctAggregation` 
(`datafusion/physical-optimizer/src/limited_distinct_aggregation.rs`), gated by 
`datafusion.optimizer.enable_distinct_aggregation_soft_limit`.
   - Legacy check: `GroupedHashAggregateStream::hit_soft_group_limit`. 
New-stream check to mirror: `PartialHashAggregateStream::hit_soft_group_limit` 
("Step 2: Soft limit optimization" in `hash_stream.rs`).


-- 
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