Repository navigation
fix: Set Substrait aggregation phase to INITIAL_TO_RESULT - #25146
namanjain24-sudo wants to merge 4 commits into
Conversation
26b02c3 to
d50b923
Compare
Thank you, @Xuanwo |
ae437fa to
334f6f7
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #25146 +/- ##
==========================================
- Coverage 82.48% 82.47% -0.01%
==========================================
Files 1140 1140
Lines 437341 437342 +1
Branches 437341 437342 +1
==========================================
- Hits 360721 360710 -11
- Misses 54835 54843 +8
- Partials 21785 21789 +4 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
22cf937 to
caf289f
Compare
|
@Xuanwo just rebased onto main to clear the conflict, no code changes — but that reset the approval. Could you take another quick look when you get a chance? |
## Which issue does this PR close? - Closes apache#24967. ## Rationale for this change The Substrait consumer treats explicit intermediate aggregate phases as complete calls. This can silently return final values when a plan requests intermediate state, or report an unrelated root-schema naming error. ## What changes are included in this PR? - Validate phases in aggregate and window expressions before translating their arguments. - Accept `INITIAL_TO_RESULT` and retain `UNSPECIFIED` for compatibility with existing DataFusion-produced plans. - Reject explicit intermediate phases and unknown protobuf enum values with clear errors. - Document the compatibility limitation: Substrait defines `UNSPECIFIED` as `INTERMEDIATE_TO_RESULT`, but the consumer cannot distinguish legacy DataFusion complete calls from unspecified intermediate-state calls produced elsewhere. Those calls remain accepted. The producer change in apache#25146 is complementary and is not duplicated here. ## What is the testing strategy for this PR? - Reproduced the original behavior: an `INITIAL_TO_INTERMEDIATE` average over values 1 and 2 returned 1.5 instead of intermediate state. - Tests cover supported phases, every explicit unsupported phase, rooted and unrooted aggregates, unknown binary-protobuf enum values, and actual window output. - The full Substrait integration target passes: 213 tests passed, with six existing tests ignored. - The extended workspace suite passes 11,267 Rust tests, with eight existing tests ignored, and all 511 SQL logic-test files using an explicit four-thread limit. An initial default-concurrency run failed one ordered-aggregate spill test with a test-memory-pool exhaustion; the complete limited-concurrency rerun passed without source changes. - `cargo clippy --all-targets --all-features -- -D warnings` and the complete `./dev/rust_lint.sh` pass, including strict workspace documentation checks. ## Are there any user-facing changes? Plans with explicit unsupported aggregate or window phases now fail instead of being interpreted as complete calls. Existing unspecified-phase plans remain accepted, including the ambiguity described above. No public Rust API changes are included. Intermediate-state execution and the separate AVG output-type mismatch are not addressed here.
## Which issue does this PR close? - Closes apache#24967. ## Rationale for this change The Substrait consumer treats explicit intermediate aggregate phases as complete calls. This can silently return final values when a plan requests intermediate state, or report an unrelated root-schema naming error. ## What changes are included in this PR? - Validate phases in aggregate and window expressions before translating their arguments. - Accept `INITIAL_TO_RESULT` and retain `UNSPECIFIED` for compatibility with existing DataFusion-produced plans. - Reject explicit intermediate phases and unknown protobuf enum values with clear errors. - Document the compatibility limitation: Substrait defines `UNSPECIFIED` as `INTERMEDIATE_TO_RESULT`, but the consumer cannot distinguish legacy DataFusion complete calls from unspecified intermediate-state calls produced elsewhere. Those calls remain accepted. The producer change in apache#25146 is complementary and is not duplicated here. ## What is the testing strategy for this PR? - Reproduced the original behavior: an `INITIAL_TO_INTERMEDIATE` average over values 1 and 2 returned 1.5 instead of intermediate state. - Tests cover supported phases, every explicit unsupported phase, rooted and unrooted aggregates, unknown binary-protobuf enum values, and actual window output. - The full Substrait integration target passes: 213 tests passed, with six existing tests ignored. - The extended workspace suite passes 11,267 Rust tests, with eight existing tests ignored, and all 511 SQL logic-test files using an explicit four-thread limit. An initial default-concurrency run failed one ordered-aggregate spill test with a test-memory-pool exhaustion; the complete limited-concurrency rerun passed without source changes. - `cargo clippy --all-targets --all-features -- -D warnings` and the complete `./dev/rust_lint.sh` pass, including strict workspace documentation checks. ## Are there any user-facing changes? Plans with explicit unsupported aggregate or window phases now fail instead of being interpreted as complete calls. Existing unspecified-phase plans remain accepted, including the ambiguity described above. No public Rust API changes are included. Intermediate-state execution and the separate AVG output-type mismatch are not addressed here.
|
@Xuanwo gentle bump on this whenever you get a chance — still just the rebase, no code changes since your approval. |
caf289f to
6577d30
Compare
|
@Xuanwo rebased onto main again to clear the conflict (main independently added Substrait output_type for window functions, which this PR's window-phase change also touches) — no further code changes, just the rebase. Sorry for the repeat ping. |
## Which issue does this PR close? - Closes apache#24967. ## Rationale for this change The Substrait consumer treats explicit intermediate aggregate phases as complete calls. This can silently return final values when a plan requests intermediate state, or report an unrelated root-schema naming error. ## What changes are included in this PR? - Validate phases in aggregate and window expressions before translating their arguments. - Accept `INITIAL_TO_RESULT` and retain `UNSPECIFIED` for compatibility with existing DataFusion-produced plans. - Reject explicit intermediate phases and unknown protobuf enum values with clear errors. - Document the compatibility limitation: Substrait defines `UNSPECIFIED` as `INTERMEDIATE_TO_RESULT`, but the consumer cannot distinguish legacy DataFusion complete calls from unspecified intermediate-state calls produced elsewhere. Those calls remain accepted. The producer change in apache#25146 is complementary and is not duplicated here. ## What is the testing strategy for this PR? - Reproduced the original behavior: an `INITIAL_TO_INTERMEDIATE` average over values 1 and 2 returned 1.5 instead of intermediate state. - Tests cover supported phases, every explicit unsupported phase, rooted and unrooted aggregates, unknown binary-protobuf enum values, and actual window output. - The full Substrait integration target passes: 213 tests passed, with six existing tests ignored. - The extended workspace suite passes 11,267 Rust tests, with eight existing tests ignored, and all 511 SQL logic-test files using an explicit four-thread limit. An initial default-concurrency run failed one ordered-aggregate spill test with a test-memory-pool exhaustion; the complete limited-concurrency rerun passed without source changes. - `cargo clippy --all-targets --all-features -- -D warnings` and the complete `./dev/rust_lint.sh` pass, including strict workspace documentation checks. ## Are there any user-facing changes? Plans with explicit unsupported aggregate or window phases now fail instead of being interpreted as complete calls. Existing unspecified-phase plans remain accepted, including the ambiguity described above. No public Rust API changes are included. Intermediate-state execution and the separate AVG output-type mismatch are not addressed here.
The producer left `phase` at its default on every aggregate and window function call it emits, so each one carried AGGREGATION_PHASE_UNSPECIFIED. That is not the same as omitting the field: the spec gives the value the meaning INTERMEDIATE_TO_RESULT, i.e. that the arguments are already intermediate aggregation state to be combined. A LogicalPlan::Aggregate is always a complete aggregation over its input rows. The partial/final split is a physical planning concern that the logical producer has no notion of. Both `AggregateFunction.phase` and `Expression.WindowFunction.phase` are documented as required, and as needing INITIAL_TO_RESULT for a complete invocation, so set that at both call sites. This stays invisible to a DataFusion-to-DataFusion round trip because the consumer never reads `phase`, but a consumer that honours the declaration reads a complete aggregation as one whose arguments are partial state.
A DataFusion round trip cannot see a field the consumer never reads, so nothing here catches a wrong AggregationPhase: the consumer rebuilds a complete aggregation whatever the plan says. substrait-spark does read it, and maps the default UNSPECIFIED to Spark's Final, which Spark then rejects because the inputs are raw rows rather than partial buffers. An ignored Rust test writes the plan for count/sum/avg, and a small Maven project converts it with substrait-java and runs it in Spark over t(i) = 1, 2, 3, checking both the aggregation mode and the rows. Two workarounds are applied on the Java side, each named after the issue it stands in for: apache#11545 for the extension URN, and apache#25049 for the unset output_type, which can go once that fix lands. The job runs only when the Substrait crate changes, because it pulls a Spark sized dependency set.
The job runs on a plain runner rather than the amd64/rust container, so setup-builder's apt-get calls fail there. Install the protobuf compiler and add rustfmt, which is what the Substrait build needs from it.
The `Rust` workflow has not started on this PR since the last rebase (`startup_failure`, zero jobs). It was the only such run in the repository that day, and the same job content started fine on the two runs before the rebase, so the failure comes from this PR's `rust.yml` delta rather than from the job itself or from any action it uses. Rather than guess at that interaction, drop the delta: the job gets its own workflow, scoped with `on.*.paths` the way `audit.yml` is, and `rust.yml` is left byte-identical to main. `dorny/paths-filter` is then unnecessary, and protoc comes from `taiki-e/install-action` as it does in `breaking_changes_detector.yml`. Triggers follow `rust.yml` so the fork policy apache#25591 established still holds: a push to any branch but the merge queue's, and the same fork clause on the job, so a PR from a fork runs this on the fork rather than on upstream runners. There is no `merge_group` trigger, since the job is not a required status check and would gate nothing there.
6577d30 to
2c50c03
Compare
|
Correction to my three earlier bumps: I said there were no code changes since your approval, and that was wrong. A Java/Spark interop test was added after it, at @alamb's and @alexandrefimov's request on #25100 — the diff went from 3 files to 8. It also left the upstream Sorry for the misleading pings. Could you take another look when you have time? |
|
Same question here: what is the usecase we are trying to solve here (not what defects might or might not exist in the code) Are you trying to run DataFusion generated substrait downstream with some other system? |
|
I am not likely going to have time to review this type of PR unless there is some user actually hitting errors / limitations See this for rationale: https://andrew.nerdnetworks.org/blog/2026/10/19/my-plan-for-reviewing-prs-in-open-source-projects-in-the-age-of-ai/ |
|
Fair enough — closing this to keep my queue small rather than leave it sitting. Happy to reopen if a user turns up who needs the INITIAL_TO_RESULT phase set on Substrait aggregations. |
Which issue does this PR close?
Rationale for this change
The Substrait producer never sets
phaseon the aggregate and window functioncalls it emits, so every call carries
AGGREGATION_PHASE_UNSPECIFIED.That is not the same as leaving the field out. The spec gives the value a
meaning, and it is not the one these plans need:
Both
AggregateFunction.phaseandExpression.WindowFunction.phasearedocumented as
Required. Must be set to INITIAL_TO_RESULT for ... that are not decomposable.A
LogicalPlan::Aggregateis always a complete aggregation over its inputrows. The partial/final split is a physical planning concern, and the logical
producer has no notion of it, so
INITIAL_TO_RESULTis the phase these plansshould declare. What they declare instead carries the spec meaning
INTERMEDIATE_TO_RESULT: that the arguments are already intermediate state tobe combined.
This stays invisible to a DataFusion-to-DataFusion round trip. #25045 has
since closed #24967, so the consumer does read the field now, but it accepts
UNSPECIFIEDandINITIAL_TO_RESULTalike — the first only for compatibilitywith plans DataFusion has already produced — so either value survives the trip
unchanged. A consumer that does honour the
declaration reads a complete aggregation as one whose arguments are already
partial state.
What changes are included in this PR?
Set
phasetoAGGREGATION_PHASE_INITIAL_TO_RESULTat the two producer callsites that emit it:
from_aggregate_functioninproducer/expr/aggregate_function.rsfrom_window_functioninproducer/expr/window_function.rsNo other producer site emits the field, and the consumer accepts both the old
and the new value, so nothing else changes.
What is the testing strategy for this PR?
New test
aggregate_and_window_functions_declare_initial_to_resultindatafusion/substrait/tests/cases/serialize.rs. It produces plans for a bareaggregate, a grouped aggregate, and a window function, then walks the produced
protobuf directly and asserts the phase on every aggregate and window function
call, rather than round-tripping through the consumer, which accepts both the
old and the new value, so a round trip could not catch this.
Verified the test fails without the producer change:
The java interop test
mainand this branch both pass every test in the repository, because theDataFusion consumer accepts either value and rebuilds a complete aggregation
either way. #25100 asked for that gap to be covered against another
implementation, so this PR also adds one:
tests/cases/java_interop.rswrites the plan forSELECT count(i), sum(i), avg(i) FROM t. It is#[ignore]d, socargo testis unchanged for anyone without a JVM.
java-interop/converts that plan with substrait-java 0.103.0 and runs it inSpark 3.5.4 over
t(i) = 1, 2, 3, checking the aggregation mode and the rows.Run both halves with:
Against a plan from this branch it passes, with
Completeandcount 3, sum 6, avg 2.0. Against a plan frommainit fails withexpected: <[Complete, Complete, Complete]> but was: <[Final, Final, Final]>,which is the failure this change prevents.
Two workarounds are applied on the Java side, each named after the issue it
stands in for: #11545 for the extension URN, and #25049 for the unset
output_type. #25090 has since closed #25049, so that second workaround isnow a no-op — it only fills the field when the plan leaves it unset, and the
plan no longer does. Taking it out is a follow-up rather than part of this
change.
The CI job lives in its own workflow,
.github/workflows/substrait_java_interop.yml,scoped with
on.*.pathstodatafusion/substrait/**and the workflow file,because it pulls a Spark sized dependency set. It carries the same fork clause
as the jobs in
rust.yml, so a pull request from a fork runs it on the fork.It is not a required status check.
One consequence worth naming:
fork_ci_status.ymlmirrors onlyrust.yml, sothis job's result is not surfaced as a check on the pull request the way the
Rust (fork)check is. It shows asskippinghere and runs on the forkinstead. On this branch it passed in 2m49s, with
Tests run: 1, Failures: 0, Errors: 0, Skipped: 0andBUILD SUCCESS:https://github.com/namanjain24-sudo/datafusion/actions/runs/40411467914
Are there any user-facing changes?
Plans produced by
to_substrait_plannow declareAGGREGATION_PHASE_INITIAL_TO_RESULTinstead ofAGGREGATION_PHASE_UNSPECIFIEDon aggregate and window function calls. Thisis a fix to the emitted protobuf rather than a Rust API change. Consumers that
read
phasewill now see the correct declaration; consumers that ignore it,including DataFusion's own, are unaffected.