Repository navigation
Substrait producer emits AGGREGATION_PHASE_UNSPECIFIED for every aggregate and window function #25100
Description
Activity
namanjain24-sudo commented
on Sep 9, 2026 ContributorAuthorMore actionstake
Same question: #25190 (comment)
I don't understand what you are doing, or why this is a bug. Can you please fix clarify on this PR what the actual problem is you are solving?
Yes, this is also a compatibility problem.
DataFusion exports a complete logical aggregate over raw input rows, but sets
phasetoUNSPECIFIED. Current substrait-java mapsUNSPECIFIEDto SparkFinal, because in Substrait it meansINTERMEDIATE_TO_RESULT.INITIAL_TO_RESULTmaps to SparkComplete. As a result, substrait-java treats the aggregate arguments as intermediate state even though the plan contains expressions over the original input rows.DataFusion-to-DataFusion round trips do not catch this because the DataFusion consumer ignores
phase. #25049 is a separate issue: substrait-java currently fails earlier becauseoutput_typeis missing. Once that is fixed, the phase is still incorrect.namanjain24-sudo commented
on Sep 11, 2026 ContributorAuthorMore actions@alexandrefimov's reading is what the code does — I checked it rather than take it on trust, and the
exact lines are worth pinning because this one never throws.substrait-java
v0.103.0,spark/src/main/scala/io/substrait/spark/expression/ToAggregateFunction.scala:76:def toSpark(phase: SExpression.AggregationPhase): AggregateMode = phase match { case SExpression.AggregationPhase.UNSPECIFIED => Final // UNSPECIFIED implies INTERMEDIATE_TO_RESULT case SExpression.AggregationPhase.INITIAL_TO_INTERMEDIATE => Partial case SExpression.AggregationPhase.INTERMEDIATE_TO_INTERMEDIATE => PartialMerge case SExpression.AggregationPhase.INTERMEDIATE_TO_RESULT => Final case SExpression.AggregationPhase.INITIAL_TO_RESULT => Complete }
So what we export as
AGGREGATION_PHASE_UNSPECIFIEDarrives as SparkFinal, meaning "the inputs
are partial aggregation buffers", when what we actually exported is a complete aggregation over raw
input rows, which is SparkComplete. Nothing is rejected. It is read as a different plan.algebra.protoagrees at both ends:AGGREGATION_PHASE_UNSPECIFIED = 0is documented "Implies
INTERMEDIATE_TO_RESULT", andINITIAL_TO_RESULTis "A complete invocation: the function should
aggregate the given set of inputs to yield a single return value". The field's own comment says
Required.Why no test here catches it: our consumer never reads
phase. There are zero occurrences of it
anywhere underlogical_plan/consumer/, so producer and consumer agree on the omission and every
DataFusion-to-DataFusion round trip passes.On the fix in #25146 — setting both sites to
INITIAL_TO_RESULTunconditionally is correct for us
and does not need a case analysis, because the Substrait producer converts logical plans and
LogicalPlan::Aggregatehas no mode at all (input,group_expr,aggr_expr,schema).
AggregateMode, with itsPartialandFinalvariants, lives inphysical-plan. A logical
aggregate is therefore always a complete aggregation, which is exactlyINITIAL_TO_RESULT. I
confirmed the diff does set both sites, the aggregate and the window function.Correcting myself on one point: I wrote on #25090 that this issue was a spec-conformance argument
rather than a reproduction. That was wrong, and the mapping above is why. A silent misread is the
worse of the two failures, not the milder one.I feel like there is a larger point here -- basically if we want to have better interop between java and datafusion, we should probably be testing that interop directly rather than reasoning about the code / adding incremental unit tests. That way we can ensure the systems interoperate and that we aren't introducing regressions
Maybe we could look into some sort of automated testing -- or see if the substrait project already has one -- that actually creates plans and then verifies if DataFusion can read them successfully, and verifies the plan is as expected in DataFusion (e.g. check the explain plan)
Part of this exists already, for plans DataFusion reads.
consumer_integration.rscompares the logical plans DataFusion builds from 22 TPC-H plans taken from substrait-io/consumer-testing with snapshots, andlogical_plans.rsdoes the same for five plans generated by substrait-java's Isthmus. Both read JSON files committed here, the TPC-H ones last refreshed in 2024, so a change in substrait-java never reaches them.#25049 (
output_type), #25190 (CASE) and this issue (phase) are about plans DataFusion writes and substrait-java reads, and neither suite covers that. DataFusion's Substrait tests do not run substrait-java, and consumer-testing runs DataFusion as a producer, but its consumers are Acero, DataFusion and DuckDB.Until #11545 is fixed, substrait-java rejects any DataFusion plan that calls a function before it reads
output_typeorphase, so a test in that direction has to work around #11545 first, as the table in #25090 does.namanjain24-sudo commented
on Sep 14, 2026 ContributorAuthorMore actionsI ran this directly instead of reasoning about it further. Setup:
mainat c2cf289, substrait-java / Isthmus 0.103.0 (the latest release), the 22 TPC-H queries, SF 0.01 data. @alexandrefimov's comment above already covers what the existing suites test, so this is only what a fresh run shows.Java → DataFusion. I used plans generated by Isthmus from its own TPC-H queries.
- As generated, 0 of 22 load. The plans name tables in upper case and DataFusion registers them in lower case, so every one fails with
No table named '…'('LINEITEM','PART', and so on; substrait generated by Apache Calcite does not run in DataFusion #14831). - With identifier normalization turned off so the names match, 7 of 22 run. All 7 return the same rows as DuckDB running the same queries (compared to 4 decimal places).
- Of the other 15:
- 11 fail on
VarCharliterals. The consumer acceptsFixedCharbut has noVarCharcase. - 3 are correlated subqueries. Isthmus now writes
OuterReference.rel_reference, a field thesubstrait0.63.0 crate we pin doesn't have, so the consumer seessteps_out = 0. - 1 has an enum function argument ([substrait] Add support for enum arguments #12493).
- 11 fail on
The checked-in TPC-H plans from 2024 contain no
VarCharliterals and norel_reference, so those tests can't see either gap.DataFusion → Java. DataFusion produces all 22 plans, and substrait-java 0.103.0 fails on all 22 at the extension URN (#11545).
A correction to my earlier comment on this issue. I wrote "Nothing is rejected." That is true of substrait-java's plan converter, but not end to end. I took an Isthmus plan for
SELECT COUNT(I), SUM(I), AVG(I)over the values 1, 2, 3 and changed onlyAggregateFunction.phase. WithINITIAL_TO_RESULT, substrait-spark 0.103.0 on Spark 3.5.4 returns[3, 6, 2.0]. WithUNSPECIFIED, it maps the aggregates toFinal, and Spark rejects the plan during analysis withMISSING_ATTRIBUTES. So the phase bug is real, but in Spark it shows up as a failure, not as wrong results.The harness is small: one Java class for Isthmus and substrait-java, a Rust example for DataFusion, and DuckDB for the reference rows. If it's useful, I can turn it into a job that runs on a schedule rather than on every PR, either here or in consumer-testing, whichever you prefer. The
VarCharliteral andrel_referencegaps don't seem to have issues yet, and I can file them.- As generated, 0 of 22 load. The plans name tables in upper case and DataFusion registers them in lower case, so every one fails with
I guess what I am saying is that if the goal is to ensure that datafusion produced plans can be consumed by substrait-java, we should be testing that explicitly somehow (and not one offs in an issue comment). Otherwise I predict it will be hard to find people who can review / look at these PRs and both 1) Have confidence they match the java behavior, 2) will continue to work over time
For example, can we make a test that makes DataFusion plans, and then tries to consume it using substrait-java? We could perhaps only run this test on changes to datafusion-substrait.
I agree. Could we start with one aggregate test that generates a plan in DataFusion, runs it through substrait-java and Spark, and checks the result? Conversion alone would miss the phase failure described above.
@namanjain24-sudo could you add this test to #25146 using your harness, with an explicit workaround for #11545?
namanjain24-sudo commented
on Sep 15, 2026 ContributorAuthorMore actions@alexandrefimov @alamb yes, I'll add it to #25146. Before writing it, I checked that such a test really tells the fix apart from
main, using a plan DataFusion itself generates. The query isSELECT count(i), sum(i), avg(i) FROM t, run through substrait-java 0.103.0 and substrait-spark on Spark 3.5.4 overt(i) = 1, 2, 3:producer workarounds applied result #25146 none substrait-java: Function 'avg' references URN anchor -1(#11545)#25146 #11545 substrait-java: Type is not set(#25049)#25146 #11545 + output_typeSpark Complete,[3, 6, 2.0]main(f8cc678, the base of #25146)#11545 + output_typeSpark Final, analysis fails withMISSING_ATTRIBUTES#25146 with #25090 applied #11545 Spark Complete,[3, 6, 2.0]So on #25146 alone the test needs a second workaround: substrait-java stops at the missing
output_typebefore it readsphase. I'll include both and mark theoutput_typeone for removal once #25090 lands. If #25090 is merged first, only the #11545 workaround is needed.What I plan to push to #25146:
- a Rust test in
datafusion/substraitthat writes the plan, - a small Java program (substrait-java and substrait-spark 0.103.0, Spark 3.5.4 in local mode) that applies the workarounds, runs the plan and checks the rows,
- a CI job with JDK 17 that runs both only when
datafusion/substrait/**changes. The Spark classpath is about 260 MB, so the job would cache it.
This would be the first Java code and JVM job in the repo, so please say if you'd rather it lived in a separate workflow or somewhere else.
- a Rust test in
namanjain24-sudo commented
on Sep 16, 2026 ContributorAuthorMore actions@alexandrefimov @alamb the test is in #25146 now, as f0f8429.
It is in two halves, because producing the plan needs no JVM:
datafusion/substrait/tests/cases/java_interop.rswrites the plan forSELECT count(i), sum(i), avg(i) FROM t. It is#[ignore]d, so a normalcargo testis unchanged for anyone without Java.datafusion/substrait/java-interop/is a small Maven project that converts that plan with substrait-java 0.103.0, runs it in Spark 3.5.4 overt(i) = 1, 2, 3, and checks both the aggregation mode and the rows.
The two workarounds are applied on the Java side only, each named after the issue it stands in for, so the test fails for the reason it is about rather than an unrelated one:
- Provide valid extensionUris and extensionUriReferences in Substrait #11545, the extension URN, which otherwise stops the plan first;
- Substrait producer omits the required output_type on aggregate functions #25049, the unset
output_type. That one can go once fix: Set Substrait output_type on aggregate functions #25090 lands, and the test then needs only the URN workaround.
What it catches, run both ways:
plan produced by result this branch passes: Complete, andcount 3, sum 6, avg 2.0mainfails: expected: <[Complete, Complete, Complete]> but was: <[Final, Final, Final]>The second row is the point. A round trip cannot see this, because the consumer never reads
phaseand rebuilds a complete aggregation either way.On cost, since it pulls a Spark sized dependency set: the job runs only when
datafusion/substrait/**orrust.ymlchanges, usingdorny/paths-filter, and caches~/.m2throughactions/setup-java. Locally the Maven half takes about 9 seconds with a warm cache, and the plan is written by a test that already exists in the crate.This is the first JVM job in the repo, so if you would rather it lived in its own workflow, or ran on a schedule instead of on Substrait changes, say so and I will move it.
Describe the bug
The Substrait producer never sets
phaseon the aggregate and window function calls it emits. Every call carriesAGGREGATION_PHASE_UNSPECIFIED, which is not the same as leaving a field out: the spec gives that enum value a meaning, and it is not the one these plans need.Reproduced on
mainat35c56b020.AggregateFunction.phaseandExpression.WindowFunction.phaseare both documented as:and the enum documents the default as:
A
LogicalPlan::Aggregateis always a complete aggregation over its input rows — the partial/final split is a physical planning concern, and the logical producer has no notion of it. So the phase these plans should declare isINITIAL_TO_RESULT. What they declare instead carries the spec meaningINTERMEDIATE_TO_RESULT: that the arguments are already intermediate state to be combined.Both call sites hardcode the value:
from_aggregate_function—phase: AggregationPhase::Unspecified as i32(datafusion/substrait/src/logical_plan/producer/expr/aggregate_function.rs:68)phase: 0, // default to AGGREGATION_PHASE_UNSPECIFIED(datafusion/substrait/src/logical_plan/producer/expr/window_function.rs:111)To reproduce
Add this as an example under
datafusion/substrait/examples/and runcargo run --locked -p datafusion-substrait --example phase_probe. It inspects the produced protobuf directly, without converting it back through a consumer.Output:
Expected behavior
AggregateFunction.phaseandExpression.WindowFunction.phaseshould be set toAGGREGATION_PHASE_INITIAL_TO_RESULT, since the producer only ever emits complete aggregations.This stays invisible to a DataFusion-to-DataFusion round trip because the consumer never reads the field — the string
phasedoes not appear anywhere underdatafusion/substrait/src/logical_plan/consumer/, which is #24967. A consumer that does honour the declaration reads a complete aggregation as one whose arguments are already intermediate state.Additional context
Related, but distinct:
phase. fix: reject unsupported Substrait aggregation phases #25045 acceptsINITIAL_TO_RESULTand deliberately keeps acceptingUNSPECIFIED"for compatibility with existing DataFusion-produced plans" — setting the phase on the producer side is what would let that allowance go away later.output_typeon the sameAggregateFunctionmessage; this report is about the neighbouringphasefield.