Skip to content

fix: omit ordering fields from the state of order-insensitive aggregates - #25402

Merged
neilconway merged 4 commits into
apache:mainfrom
hassaanch23:fix/insensitive-aggregate-ordering-state
Sep 17, 2026
Merged

neilconway merged 4 commits into
apache:mainfrom
hassaanch23:fix/insensitive-aggregate-ordering-state

Conversation

@hassaanch23

@hassaanch23 hassaanch23 commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

An order-insensitive aggregate called with an ORDER BY fails whenever the aggregation runs in two phases (PartialFinal/FinalPartitioned), grouped or not:

CREATE TABLE d (g INT, k INT, v INT) AS VALUES (1, 2, 20), (1, 1, 10), (2, 4, 40), (2, 3, 30);
SELECT g, min(v ORDER BY k) FROM d GROUP BY g;
-- Arrow error: Invalid argument error: number of columns(2) must match number of fields(3) in schema

AggregateExprBuilder::build() derived the aggregate's ordering_fields from its ORDER BY even when the function is order-insensitive. AggregateFunctionExpr::order_bys() hides those expressions from the accumulator, but state_fields() still passed ordering_fields. Min and Max use the default AggregateUDFImpl::state_fields, which appends them, while their accumulators only emit the value. The partial state schema therefore declared fields that no column filled. sum, count, bool_and and bool_or override state_fields without ordering fields, so they already worked.

What changes are included in this PR?

AggregateExprBuilder::build() now drops order_bys when the function's order_sensitivity() is insensitive. ordering_fields is then empty from the start, so the aggregate carries no ordering state that later code has to ignore. Order-sensitive aggregates are unchanged.

None of the built-in order-insensitive aggregates reads order_bys from AccumulatorArgs, and plans display aggregates by name(), so EXPLAIN output does not change.

What is the testing strategy for this PR?

Two queries in group_by.slt, in the section that sets target_partitions = 8, each selecting MIN, MAX and SUM with ORDER BY ts DESC over sales_global, with SUM as a control:

  • Grouped by country. The plan is PartialFinalPartitioned. On main it fails with number of columns(4) must match number of fields(6).
  • Ungrouped, with WHERE amount > 0 so the input is multi-partition and the plan is PartialFinal. On main it fails with number of columns(3) must match number of fields(5). Without the filter, the single-partition input runs in Single mode and never builds a partial state.

Both queries pass with this change. The other .slt files covering aggregates still pass (group_by, aggregate, array_agg, first_last_*, distinct_on, agg_func_substitute, window, order).

Are there any user-facing changes?

Queries that failed now return results. No API changes.

Notes for reviewers

The state field names for ordering fields are being reworked in #25196. This PR only changes whether order-insensitive aggregates have ordering fields at all, so the two shouldn't interact beyond a possible textual rebase.

`min(v ORDER BY k)` and `max(v ORDER BY k)` in a grouped query failed with
"number of columns(2) must match number of fields(3) in schema" whenever
the aggregation ran in two phases, as it does with the default
target_partitions.

AggregateFunctionExpr::order_bys() already returns no expressions for an
order-insensitive aggregate, so its ORDER BY columns are never fed to the
accumulator. state_fields() still passed ordering_fields, though, and the
default AggregateUDFImpl::state_fields, which Min and Max use, appends them.
The accumulators only emit the value, so the partial state schema had
fields with no columns behind them.

Pass no ordering fields for order-insensitive aggregates, mirroring
order_bys().

Part of apache#25401
@github-actions github-actions Bot added physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt) labels Sep 17, 2026
@github-actions github-actions Bot added the auto detected api change Auto detected API change label Sep 17, 2026

@neilconway neilconway left a comment

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.

Thanks @hassaanch23 ! This is a good analysis of the root cause of the bug. The approach we're taking in this PR is okay, but it leaves the state of the agg a little inconsistent (ordering_fields is defined but most code should probably check if the agg is order-insensitive before using it).

I wonder if it would be cleaner to clear order_bys in AggregateExprBuilder::build() for order-insensitive aggregates. That way ordering_fields is empty, so we avoid the inconsistent state in the first place. What do you think?

Comment thread datafusion/sqllogictest/test_files/group_by.slt
@codecov-commenter

codecov-commenter commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.30769% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.32%. Comparing base (a2b8093) to head (2a95a74).

Files with missing lines Patch % Lines
datafusion/physical-expr/src/aggregate.rs 92.30% 0 Missing and 5 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #25402   +/-   ##
=======================================
  Coverage   82.32%   82.32%           
=======================================
  Files        1137     1137           
  Lines      431824   431883   +59     
  Branches   431824   431883   +59     
=======================================
+ Hits       355500   355563   +63     
+ Misses      54838    54824   -14     
- Partials    21486    21496   +10     

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

Instead of passing empty ordering fields from state_fields(), drop the
ORDER BY in AggregateExprBuilder::build() when the function is
order-insensitive. The ordering fields are then empty from the start, so
the aggregate carries no ordering state that later code has to ignore.

Also cover the ungrouped case. It fails the same way once the aggregation
runs in Partial and Final modes; the filter makes the single-partition
`sales_global` input multi-partition so the test reaches that path.
@hassaanch23

Copy link
Copy Markdown
Contributor Author

@neilconway Thanks, that's cleaner. I pushed cc17378. AggregateExprBuilder::build() now drops order_bys when the function is order-insensitive, so ordering_fields is empty from the start, and the state_fields() change is gone.

Before switching, I checked two things. None of the built-in order-insensitive aggregates (sum, count, min, max, any_value, bool_and, bool_or) reads order_bys from AccumulatorArgs. And plans display aggregates by name(), so EXPLAIN output doesn't change.

I also merged main. The semver-checks note was comparing against create_accumulator_with_metrics and create_groups_accumulator_with_metrics, which landed in #25051 after this branch was created. This PR doesn't touch either.

@github-actions github-actions Bot removed the auto detected api change Auto detected API change label Sep 17, 2026

@neilconway neilconway left a comment

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.

Awesome, @hassaanch23 ! Nice work, I like this approach.

This is optional, but the SLTs are a bit fragile because they depend on the planner choosing a two-phase plan. We could also add a unit test that builds an order-insensitive UDAF with .order_by(), and then assert that its order_bys are empty. I got Claude to whip up something quickly: https://gist.github.com/neilconway/551457d97cea924b879be0dbc256deb7

Comment thread datafusion/physical-expr/src/aggregate.rs
Comment thread datafusion/physical-expr/src/aggregate.rs Outdated
AggregateExprBuilder::build() now guarantees that an order-insensitive
aggregate has no order_bys, so order_bys() can return them directly and
with_new_expressions no longer needs to special-case order-insensitive
aggregates.

Add unit tests that build an aggregate using the default state_fields with
.order_by() and check its order_bys, its ORDER BY expressions, its state
fields and what reaches the accumulator, for an order-insensitive and an
order-sensitive function. Unlike the sqllogictests, they don't depend on
the planner choosing a two-phase plan.

Co-authored-by: Neil Conway <neil.conway@gmail.com>
@hassaanch23

Copy link
Copy Markdown
Contributor Author

@neilconway Thanks! Pushed 2a95a74. It has your unit tests unchanged, with you credited as co-author, and both simplifications.

With the builder now clearing order_bys, order_insensitive_aggregate_discards_order_by exercises the builder itself rather than the accessor. With the clearing disabled, it fails at expr.order_bys().is_empty(), while order_sensitive_aggregate_keeps_order_by still passes. The full sqllogictest suite and clippy on all targets pass.

@neilconway

Copy link
Copy Markdown
Contributor

@hassaanch23 Excellent! Thank you for the great PR and the quick turnaround time.

@neilconway
neilconway added this pull request to the merge queue Sep 17, 2026
Merged via the queue into apache:main with commit a7cac9a Sep 17, 2026
41 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-expr Changes to the physical-expr crates sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants