You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@datafusion.apache.org by gi...@apache.org on 2026/09/16 02:15:07 UTC

(datafusion) 01/01: fix: do not use the top-k aggregate stream for a DISTINCT limit with no ordering direction (#25349)

This is an automated email from the ASF dual-hosted git repository.

github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-25349-be5a96e76df9e0111cd4f21998b60a8db7212ba3
in repository https://gitbox.apache.org/repos/asf/datafusion.git

commit 18cbb877ba9a5cae7fc82313e4569a982d202b83
Author: Adrian Garcia Badaracco <17...@users.noreply.github.com>
AuthorDate: Wed Sep 16 02:14:44 2026 +0000

    fix: do not use the top-k aggregate stream for a DISTINCT limit with no ordering direction (#25349)
    
    ## Which issue does this PR close?
    
    - Closes https://github.com/apache/datafusion/issues/25350.
    
    ## Rationale for this change
    
    `SELECT DISTINCT ... LIMIT n` with no `ORDER BY` fails at execution with
    `Internal error: Ordering direction required for DISTINCT with limit`
    when one of the projected columns is a window function:
    
    ```sql
    CREATE TABLE t (env VARCHAR, region VARCHAR, deployment VARCHAR);
    INSERT INTO t VALUES ('prod','a','x'), ('prod','a','y'), ('staging','b','x');
    SELECT DISTINCT env, region, count(*) OVER (PARTITION BY env, deployment) AS n FROM t LIMIT 30;
    ```
    
    The mechanism:
    
    1. `LimitedDistinctAggregation` pushes `LimitOptions::new(limit)` (no
    direction, `descending: None`) into the `AggregateExec` while
    `is_unordered_unfiltered_group_by_distinct()` is true.
    2. `EnsureRequirements` then inserts the sort that the window function
    needs below the aggregation. `replace_children_if_necessary` rebuilds
    the aggregation through `replace_children` with
    `ChildrenPropertiesMode::Recompute`. This derives the properties again
    from the sorted child but keeps `limit_options`. The rebuilt node has
    `ordering_mode=PartiallySorted(..)` and an output ordering, so the
    predicate is now false.
    3. `execute_typed` selects `GroupedTopKAggregateStream`. Its constructor
    needs an ordering direction, and no rule supplied one, so it returns the
    error.
    
    The top-k stream also reads only the first group key
    (`aggr.group_expr().expr()[0]`). Thus it would give wrong results for a
    multi-column `DISTINCT` even with a direction. The correct behavior is
    to not select that stream in this case.
    
    ## What changes are included in this PR?
    
    `AggregateExec::execute_typed` selects the top-k path only when an
    ordering direction is available, which is what
    `GroupedTopKAggregateStream::new` requires:
    
    - `limit_options().descending` is set. `TopKAggregation` sets it, and
    that rule accepts only a single group key.
    - Or `get_minmax_desc()` supplies a direction.
    
    Otherwise, execution uses the regular grouped streams. This is correct
    because they treat the pushed limit as a soft limit, and the `LIMIT`
    above the aggregation still truncates the result.
    
    The limit pushdown and the plans do not change. Only the stream
    selection at execution changes.
    
    ## What is the testing strategy for this PR?
    
    A new case in `datafusion/sqllogictest/test_files/aggregate.slt` runs
    the multi-column and single-column forms of the query and checks the
    rows (the same rows as PostgreSQL 16.15 and DuckDB 1.5.2). Without the
    `mod.rs` change, both queries fail with the internal error. With the
    change, they pass.
    
    Also run locally, on this branch rebased onto current `main`:
    
    - `cargo test -p datafusion-physical-plan -p
    datafusion-physical-optimizer`
    - `cargo test --profile ci -p datafusion-sqllogictest --test
    sqllogictests` (all files)
    
    ## Are there any user-facing changes?
    
    Queries that failed with `Internal error: Ordering direction required
    for DISTINCT with limit` now return the correct rows. There are no API
    changes and no plan changes.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude <no...@anthropic.com>
---
 datafusion/physical-plan/src/aggregates/mod.rs   | 19 +++++++++++++++-
 datafusion/sqllogictest/test_files/aggregate.slt | 29 ++++++++++++++++++++++++
 2 files changed, 47 insertions(+), 1 deletion(-)

diff --git a/datafusion/physical-plan/src/aggregates/mod.rs b/datafusion/physical-plan/src/aggregates/mod.rs
index 5b0d2c2d2e..49a726f9be 100644
--- a/datafusion/physical-plan/src/aggregates/mod.rs
+++ b/datafusion/physical-plan/src/aggregates/mod.rs
@@ -1219,9 +1219,26 @@ impl AggregateExec {
             )?));
         }
 
-        // grouping by an expression that has a sort/limit upstream
+        // Grouping by an expression that has a sort/limit upstream.
+        //
+        // `GroupedTopKAggregateStream` keeps a priority queue, so it only works
+        // when an ordering direction is available: either this is a MIN/MAX
+        // aggregate, whose direction is implied by the accumulator, or the
+        // optimizer pushed a direction down next to the limit.
+        //
+        // A direction-less limit is a soft hint pushed by
+        // `LimitedDistinctAggregation`, which only pushes it while
+        // `is_unordered_unfiltered_group_by_distinct()` holds. That predicate is
+        // not stable: a later rule such as `EnsureRequirements` can insert the
+        // sort a window function requires below this aggregate, which gives the
+        // rebuilt aggregate an output ordering and makes the predicate false
+        // without ever supplying a direction. Falling back to the regular
+        // grouped streams is correct in that case, because they treat the limit
+        // as a soft limit and the `LIMIT` above this aggregate still truncates
+        // the result.
         if let Some(config) = self.limit_options
             && !self.is_unordered_unfiltered_group_by_distinct()
+            && (config.descending.is_some() || self.get_minmax_desc().is_some())
         {
             return Ok(StreamType::GroupedPriorityQueue(
                 GroupedTopKAggregateStream::new(self, context, partition, config.limit)?,
diff --git a/datafusion/sqllogictest/test_files/aggregate.slt b/datafusion/sqllogictest/test_files/aggregate.slt
index 3eabd5c4ff..a4a54cdee2 100644
--- a/datafusion/sqllogictest/test_files/aggregate.slt
+++ b/datafusion/sqllogictest/test_files/aggregate.slt
@@ -7522,6 +7522,35 @@ set datafusion.optimizer.enable_distinct_aggregation_soft_limit = true;
 statement ok
 set datafusion.optimizer.repartition_aggregations = true;
 
+# A window function in the projection makes `EnsureRequirements` add a sort below
+# the distinct aggregation, so the aggregation ends up with an output ordering after
+# `LimitedDistinctAggregation` pushed a direction-less limit into it. The query
+# must still execute and return the distinct rows.
+statement ok
+CREATE TABLE distinct_limit_window_table(env VARCHAR, region VARCHAR, deployment VARCHAR) AS VALUES
+  ('prod', 'a', 'x'),
+  ('prod', 'a', 'y'),
+  ('staging', 'b', 'x');
+
+query TTI rowsort
+SELECT DISTINCT env, region, count(*) OVER (PARTITION BY env, deployment) AS n
+FROM distinct_limit_window_table
+LIMIT 30;
+----
+prod a 1
+staging b 1
+
+query TI rowsort
+SELECT DISTINCT env, count(*) OVER (PARTITION BY env) AS n
+FROM distinct_limit_window_table
+LIMIT 30;
+----
+prod 2
+staging 1
+
+statement ok
+DROP TABLE distinct_limit_window_table;
+
 #
 # regr_*() tests
 #


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@datafusion.apache.org
For additional commands, e-mail: commits-help@datafusion.apache.org