Skip to content

Fix/11748 agg emit flag#23871

Open
HairstonE wants to merge 2 commits into
apache:mainfrom
HairstonE:fix/11748-agg-emit-flag
Open

Fix/11748 agg emit flag#23871
HairstonE wants to merge 2 commits into
apache:mainfrom
HairstonE:fix/11748-agg-emit-flag

Conversation

@HairstonE

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

EliminateGroupByConstant strips constant expressions from a GROUP BY. When it strips all of them, it turns a grouping aggregate into a global aggregate, which returns 1 row on empty input instead of 0.

Original attempt: #11897. My previous PR #22132 had the correct output but at a per-row cost, whereas this solution does not have that cost.

What changes are included in this PR?

EliminateGroupByConstant now leaves an all-constant GROUP BY alone. When every group key is a literal and there's at least one aggregate, the physical planner plans a global AggregateExec carrying a new emit_no_rows_on_empty_input flag, with a projection re-attaching the constant columns. Now a flagged aggregate that saw no input rows emits 0 rows instead of the one default row a global aggregate returns.
The flag survives plan rewrites and round-trips through the proto.
A GROUP BY that isn't all-constant takes exactly the path it did before.

Are these changes tested?

Yes. A sqllogictest reproducer for the original issue (GROUP BY false HAVING false returning 0 rows), plus the non-HAVING case (GROUP BY false on empty input) and the non-empty case. Updated the optimizer SLT expectations and unit tests.

Are there any user-facing changes?

Queries with an all-constant GROUP BY over empty input now correctly return 0 rows instead of 1 row of NULL.

… GROUP BY so we can emit no rows when the input is empty
@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) proto Related to proto crate physical-plan Changes to the physical-plan crate labels Jul 24, 2026
@github-actions

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v54.1.0 (current)
       Built [ 111.053s] (current)
     Parsing datafusion v54.1.0 (current)
      Parsed [   0.037s] (current)
    Building datafusion v54.1.0 (baseline)
       Built [ 106.747s] (baseline)
     Parsing datafusion v54.1.0 (baseline)
      Parsed [   0.037s] (baseline)
    Checking datafusion v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.921s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 220.597s] datafusion
    Building datafusion-optimizer v54.1.0 (current)
       Built [  26.222s] (current)
     Parsing datafusion-optimizer v54.1.0 (current)
      Parsed [   0.033s] (current)
    Building datafusion-optimizer v54.1.0 (baseline)
       Built [  26.276s] (baseline)
     Parsing datafusion-optimizer v54.1.0 (baseline)
      Parsed [   0.033s] (baseline)
    Checking datafusion-optimizer v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.223s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  53.916s] datafusion-optimizer
    Building datafusion-physical-optimizer v54.1.0 (current)
       Built [  38.528s] (current)
     Parsing datafusion-physical-optimizer v54.1.0 (current)
      Parsed [   0.023s] (current)
    Building datafusion-physical-optimizer v54.1.0 (baseline)
       Built [  39.689s] (baseline)
     Parsing datafusion-physical-optimizer v54.1.0 (baseline)
      Parsed [   0.024s] (baseline)
    Checking datafusion-physical-optimizer v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.166s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  79.859s] datafusion-physical-optimizer
    Building datafusion-physical-plan v54.1.0 (current)
       Built [  36.522s] (current)
     Parsing datafusion-physical-plan v54.1.0 (current)
      Parsed [   0.153s] (current)
    Building datafusion-physical-plan v54.1.0 (baseline)
       Built [  36.582s] (baseline)
     Parsing datafusion-physical-plan v54.1.0 (baseline)
      Parsed [   0.151s] (baseline)
    Checking datafusion-physical-plan v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.951s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  75.839s] datafusion-physical-plan
    Building datafusion-proto v54.1.0 (current)
       Built [  61.047s] (current)
     Parsing datafusion-proto v54.1.0 (current)
      Parsed [   0.020s] (current)
    Building datafusion-proto v54.1.0 (baseline)
       Built [  60.857s] (baseline)
     Parsing datafusion-proto v54.1.0 (baseline)
      Parsed [   0.021s] (baseline)
    Checking datafusion-proto v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.327s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 124.071s] datafusion-proto
    Building datafusion-proto-models v54.1.0 (current)
       Built [  24.477s] (current)
     Parsing datafusion-proto-models v54.1.0 (current)
      Parsed [   0.138s] (current)
    Building datafusion-proto-models v54.1.0 (baseline)
       Built [  24.445s] (baseline)
     Parsing datafusion-proto-models v54.1.0 (baseline)
      Parsed [   0.164s] (baseline)
    Checking datafusion-proto-models v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   2.442s] 223 checks: 222 pass, 1 fail, 0 warn, 30 skip

--- failure constructible_struct_adds_field: externally-constructible struct adds field ---

Description:
A pub struct constructible with a struct literal has a new pub field. Existing struct literals must be updated to include the new field.
        ref: https://doc.rust-lang.org/reference/expressions/struct-expr.html
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.49.0/src/lints/constructible_struct_adds_field.ron

Failed in:
  field AggregateExecNode.emit_no_rows_on_empty_input in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2178
  field AggregateExecNode.emit_no_rows_on_empty_input in /home/runner/work/datafusion/datafusion/datafusion/proto-models/src/generated/prost.rs:2178

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  53.671s] datafusion-proto-models
    Building datafusion-sqllogictest v54.1.0 (current)
       Built [ 190.918s] (current)
     Parsing datafusion-sqllogictest v54.1.0 (current)
      Parsed [   0.023s] (current)
    Building datafusion-sqllogictest v54.1.0 (baseline)
       Built [ 192.214s] (baseline)
     Parsing datafusion-sqllogictest v54.1.0 (baseline)
      Parsed [   0.022s] (baseline)
    Checking datafusion-sqllogictest v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.116s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 387.348s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Jul 24, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.86643% with 53 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.71%. Comparing base (050046a) to head (8ae1f1d).
⚠️ Report is 58 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/physical-plan/src/aggregates/mod.rs 85.06% 4 Missing and 19 partials ⚠️
datafusion/core/src/physical_planner.rs 84.26% 8 Missing and 6 partials ⚠️
datafusion/proto-models/src/generated/pbjson.rs 0.00% 13 Missing ⚠️
datafusion/proto/src/physical_plan/mod.rs 0.00% 1 Missing and 1 partial ⚠️
...n/physical-plan/src/aggregates/aggregate_stream.rs 87.50% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             main   #23871     +/-   ##
=========================================
  Coverage   80.71%   80.71%             
=========================================
  Files        1089     1090      +1     
  Lines      368750   370635   +1885     
  Branches   368750   370635   +1885     
=========================================
+ Hits       297631   299166   +1535     
- Misses      53375    53618    +243     
- Partials    17744    17851    +107     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@neilconway

Copy link
Copy Markdown
Contributor

I'm curious -- why do we want to follow the DuckDB behavior here? Integer literal GROUP BY expressions are interpreted as column ordinals, so to me it seems more consistent to reject non-integer literal GROUP BY expressions, as Postgres does.

@HairstonE

Copy link
Copy Markdown
Contributor Author

Hi Neil!

I'm curious -- why do we want to follow the DuckDB behavior here? Integer literal GROUP BY expressions are interpreted as column ordinals, so to me it seems more consistent to reject non-integer literal GROUP BY expressions, as Postgres does.

DuckDB has the correct output so I used it for reference. I believe Postgres only blocks bare literals. When it accepts this type of query Postgres has the same behavior as DuckDB. This bug would still persist in constant expression group bys even if we reject the same way Postgres does. Adding this rejection now could also cause query breaking changes.

@alamb Do you have any thoughts on this?

@alamb

alamb commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

I am sorry for all the back and forth @HairstonE

Rereading #11748 it uses this query:

SELECT AVG(v1) FROM t1 GROUP BY false having false;

I am really struggling to figure out why the solutions seem so complicated and I don't understand why emitting an output row is a function of all the groups being constant.

Specifically since there are no input rows in this example, it shouldn't matter what the group columns are, no output should be emitted (except for COUNT which is a specia case)

It seems to me the core problem is that SELECT AVG(v1) FROM t1 GROUP BY false; is returning an output row (note there is no HAVING):

> create table t1(v1 int);
0 row(s) fetched.
Elapsed 0.021 seconds.

> SELECT AVG(v1) FROM t1 GROUP BY false;
+------------+
| avg(t1.v1) |
+------------+
| NULL       |
+------------+
1 row(s) fetched.
Elapsed 0.040 seconds.

explain SELECT AVG(v1) FROM t1 GROUP BY false;
+---------------+-------------------------------+
| plan_type | plan |
+---------------+-------------------------------+
| physical_plan | ┌───────────────────────────┐ |
| | │ AggregateExec │ |
| | │ -------------------- │ |
| | │ aggr: avg(t1.v1) │ |
| | │ mode: Single │ |
| | └─────────────┬─────────────┘ |
| | ┌─────────────┴─────────────┐ |
| | │ DataSourceExec │ |
| | │ -------------------- │ |
| | │ bytes: 0 │ |
| | │ format: memory │ |
| | │ rows: 0 │ |
| | └───────────────────────────┘ |
| | |
+---------------+-------------------------------+
1 row(s) fetched.
Elapsed 0.006 seconds.


But it works fine when there is a row

```sql
> insert into t1 values (1);
+-------+
| count |
+-------+
| 1     |
+-------+
1 row(s) fetched.
Elapsed 0.016 seconds.

> SELECT AVG(v1) FROM t1 GROUP BY false;
+------------+
| avg(t1.v1) |
+------------+
| 1.0        |
+------------+
1 row(s) fetched.
Elapsed 0.004 seconds.

logical_plan
01)Projection: Date32("2023-05-04") AS dt, Boolean(true) AS today_filter, count(Int64(1))
02)--Aggregate: groupBy=[[]], aggr=[[count(Int64(1))]]
01)Projection: to_date(Utf8("2023-05-04")) AS dt, date_part(Utf8("DAY"),now()) < Int64(1000) AS today_filter, count(Int64(1))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems like a regression (the expression is no longer folded)

@neilconway

neilconway commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

@alamb

I am really struggling to figure out why the solutions seem so complicated and I don't understand why emitting an output row is a function of all the groups being constant.

  1. When all grouping keys are constants, the optimizer (EliminateGroupByConstant) removes the grouping entirely, replacing a grouped agg with an ungrouped agg
  2. This changes the semantics of the query. count() is not a special-case per se; count() produces a single row with 0, most other aggs will produce a single row with NULL. This is different than a grouped agg over an empty input, which should produce zero rows.

To me, the simplest fix here is just to decline to optimize away the grouping if all of the grouping expressions are constants. This is a corner case anyway; the perf hit is not catastrophic, and I'd be surprised to see a real-world workload where this pattern occurs.

And yes, I realize that we did something similar in a prior PR 🙃

@alamb

alamb commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

And yes, I realize that we did something similar in a prior PR 🙃

if you are happy with the PR #22132 then let's reopen it and merge

I am sorry for all the runaround @HairstonE

@HairstonE

Copy link
Copy Markdown
Contributor Author

I can reopen that PR. Right now it is not what Neil is suggesting, I would need to revert
a commit back for the "decline to optimize away the grouping if all of the grouping
expressions are constants" guard. If we go with the version currently on that branch I still need
to address previous review comments before I closed it (unneeded cloning and possibly the
registry lookup).

@neilconway

Copy link
Copy Markdown
Contributor

I can reopen that PR. Right now it is not what Neil is suggesting, I would need to revert
a commit back for the "decline to optimize away the grouping if all of the grouping
expressions are constants" guard.

Personally I'd prefer that simple approach, but if anyone (e.g., you or @alamb) see a reason to do something more complicated, would love to discuss.

@alamb

alamb commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Personally I'd prefer that simple approach, but if anyone (e.g., you or @alamb) see a reason to do something more complicated, would love to discus

My goal is: 1) simple implementation and 2) no performance regressions (for legitmate useful queries)

I may have misunderstood the original PR and its impact on performance

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto detected api change Auto detected API change core Core DataFusion crate optimizer Optimizer rules physical-plan Changes to the physical-plan crate proto Related to proto crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorrect result returned by a group by query (SQLancer-TLP)

4 participants