Uh oh!
There was an error while loading. Please reload this page.
[SPARK-59121][SQL] Fix wrong results when a storage-partitioned join reduces the partition keys of both sides - #58447
[SPARK-59121][SQL] Fix wrong results when a storage-partitioned join reduces the partition keys of both sides#58447peter-toth wants to merge 2 commits into
Conversation
…reduces the partition keys of both sides ### What changes were proposed in this pull request? This builds on apache#58335, which makes a reduced key-grouped partitioning report a transform that describes its keys for the two reducer shapes where one exists. When both sides of the join reduce there is none. The keys become `r1(f1(x))` = `r2(f2(x))`, a third key space that neither side's transform names. `bucket(12, id)` joined to `bucket(8, id)` is the flagship example. `BucketFunction.reducer` hands both sides `BucketReducer(4)`, the keys become `id % 4`, and both sides keep reporting the transforms they were built from. That is the gap apache#58335 names and leaves open, and this PR closes it by saying so rather than by inventing an expression. `TransformExpression` gains a fourth field, `reducedWith: Option[TransformFunctionId]`, which names the transform this one's keys were reduced together with. `KeyedShuffleSpec.reducersBothWays` is the only producer. In its both-sides-reduce branch it now reports `e1.reducedTogetherWith(e2)` instead of the bare `e1`. The marker holds no `Expression`, only a canonical name and a bucket count, so it is safe in a field canonicalization does not descend into. Putting it on the expression rather than on `KeyedPartitioning` is what keeps the change small. Every site that derives a partitioning already carries the expressions along. `AliasAwareOutputExpression` projects them, `GroupPartitionsExec.outputPartitioning` re-reports them, and `TransformExpression.withReference` re-targets them, so the marker is inherited, and dropped with the position it belongs to, without a line of new plumbing. `GroupPartitionsExec` needed no change at all. Four sites then refuse to reason about such keys, and a fifth refusal falls out of expression equality: - `KeyedShuffleSpec.isExpressionCompatible` does not compare marked keys by transform. Two of them are compatible when the same pair was reduced together, which is the pair the join produced. Anything else has to shuffle. - `KeyedShuffleSpec.canCreatePartitioning` does not shuffle another child onto marked keys, because that evaluates the reported expressions per row. - `KeyedShuffleSpec.reducersBothWays` does not reduce marked keys a second time. - `keysSatisfy` does not let marked keys satisfy an `OrderedDistribution`. An ordering is a claim about the key *values*, and nothing makes a reducer order-preserving. This one is a local guard. A marked position always carries a transform, and a SQL `ORDER BY` cannot name one. - `UnionExec.comparePartitioning` compares the children's expressions with `semanticEquals`, so a marked child no longer merges with an unmarked sibling reporting the same transform. No change was needed there. Clustering is the one thing a marked partitioning still satisfies, and that is sound. The keys remain a function of the same attributes, which is all `ClusteredDistribution` asks. Two things promised in the review of SPARK-59120 (apache#58420) are now delivered, since the marker answers the question they approximated: `KeyedPartitioning.expressionsDescribeKeyShape` and its use in `canCreatePartitioning` are replaced by `expressionsDescribeKeys`, and the three-reader list in the `keyDataTypes` scaladoc is gone. Keeping the type check as a second conjunct was considered and dropped. It is not sufficient, since `bucket(12)` and `bucket(8)` reducing onto `bucket(4)` keep their `IntegerType` and pass it. It is not necessary either. After a one-side reduce the reported expression is the target transform, and `EnsureRequirements` already refuses a reducer whose `resultType()` disagrees with the other side's key types, which are that transform's, with `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES`. So the keys and the expressions agree at every reachable one-side reduce, and a both-sides reduce is marked. The unit test that pinned the type proxy is replaced by one that pins the marker, and it keeps the proxy's struct case. A partitioning whose key row was built at `struct<f>` while it declares `struct<g>`, which is what `createPartitioning` produces, is still accepted. Re-adding any type comparison to the gate fails that assertion. Two smaller things came with it. `TransformExpression.resolveFunctionCall()` refuses a marked expression, so `eval` throws on one instead of computing the un-reduced transform and misrouting the row. That is a local gate rather than a live check, because every consumer of a reduced partitioning refuses it first and the write path never sees one. It also replaces the copy of the rule that prepends the bucket count as a literal argument in `DistributionAndOrderingUtils`, and both copies had to be touched here anyway, since the extractor grew a field. It builds a fresh expression per call, because the result can be stateful. ### Why are the changes needed? Four failures, each one a test here, all measured on this PR's base. All of them need `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, which is off by default, since that is what admits a reducer at all. 1. Two reduces onto different key spaces look co-partitioned. Four tables holding ids 0 until 24, bucketed by 12 and 8 on one side and by 12 and 18 on the other. The first pair reduces onto `id % 4`, the second onto `id % 6`, and both keep reporting `bucket(12, id)`. Joining the two returns **8 of 24 rows** with no shuffle. This is also what the pairing in the marker is for. Marking the keys without recording which pair produced them fixes everything else here and still returns those 8 rows. 2. Reducing keys twice. Three tables bucketed by 12, 8 and 6: the first join reduces onto `id % 4`, then the second derives a `bucket(6)` reducer from the reported `bucket(12, id)` and applies it to keys that are already reduced. `(id % 4) % 6` leaves the left keys alone while the right side moves to `id % 6`, so the query loses rows with no shuffle. 3. Reducing keys twice, the other way. Two `days`/`years` joins that each reduce both sides onto one year space, then joined to each other with different key sets on the two legs. The outer join derives reducers again and planning throws `ClassCastException: Long cannot be cast to Integer`. 4. Merging a reduced partitioning in a union. `(bucket12 JOIN bucket8) UNION ALL bucket12` reports `bucket(12, id)` on both sides of the union, so the two are merged although the join side's keys are `id % 4`. A `GROUP BY id` above it returns each id twice. The fifth refusal, `canCreatePartitioning`, guards a hazard the first one opens up. Once a marked partitioning is no longer compatible with anything but its own pairing, `EnsureRequirements` takes the one-side-shuffle path instead, and there it would happily shuffle the other child onto the reduced keys, which places rows by evaluating `bucket(12, id)` against partitions laid out by `id % 4`. Measured with tables bucketed by 12, 8 and 2 and `v2BucketingShuffleEnabled`, with the clause removed the query returns **8 of 12 rows**. ### Does this PR introduce _any_ user-facing change? Yes, it fixes the wrong results and the crash above. A query that used to reach one of them now shuffles instead, so its plan changes. It also refuses two plans that happen to be correct today, In both cases nothing can tell the two apart from the outside. The first is a second join onto an already reduced space. `bucket(12) JOIN bucket(8)` lands on `id % 4`. Meeting a `bucket(4, id)` or `bucket(2, id)` table, `BucketReducer` would compose correctly, since those counts divide 4. Meeting a `bucket(6, id)` table it would not, and that is failure 2 above. The `Reducer` API cannot say which of the two it is, so both now shuffle: `bucket12 JOIN bucket8 JOIN bucket4` goes from 0 shuffles to 2, with the same rows. This only arises where both sides reduced, i.e. where `gcd(a, b) < min(a, b)`. A chain in which one bucket count divides the other takes the one-side path and is unaffected. The second is two reduces that land on the same space through different pairings, e.g. `bucket(12)` with `bucket(8)` and `bucket(12)` with `bucket(20)`, both of which give `id % 4`. They are treated as different spaces and the join shuffles. Both cost a shuffle, not a wrong answer. A follow-up can give them back by letting a `ReducibleFunction` name the transform it reduces onto, which makes this whole shape disappear rather than be refused. Queries whose keys were not reduced on both sides are unaffected, and a join of two sides that were reduced together keeps its plan. ### How was this patch tested? Ten new tests. Six query tests in `KeyGroupedPartitioningSuite`, and one each in `TransformExpressionSuite`, `ShuffleSpecSuite`, `GroupPartitionsExecSuite` and `ProjectedOrderingAndPartitioningSuite`. One existing test changed, see below. Four of the six query tests fail on the base commit with wrong rows or a crash, listed as failures 1 to 4 above. A fifth, `another side is not shuffled onto reduced keys`, returns the right rows there with no shuffle. This PR costs it one shuffle, and it is what pins the `canCreatePartitioning` clause. Each refusal was ablated, and each ablation fails exactly the tests written for it: - `canCreatePartitioning`'s clause removed: `another side is not shuffled onto reduced keys` returns 8 of 12 rows. - the same-pairing allowance in `isExpressionCompatible` made unconditional, i.e. the refusal taken too far. `two sides reduced onto the same keys still join without a shuffle` fails, and so does the existing `SPARK-56164: Reducers with different result types to original keys`. - the pairing thrown away, so that any two marked keys count as one space. `two reduced partitionings are not compatible by their transforms` returns 8 of 24 rows. That is the ablation the test exists for, and it is why the marker records the pair rather than a bit. - the `reducersBothWays` guard removed. `two sides reduced together are not reduced a second time` throws the `ClassCastException`. No other test in the suite reaches that guard, and instrumenting it to throw on entry showed every one of them misses it. That is why the test is built the way it is, with different key sets on the two legs so that the join computes reducers at all. `SPARK-56164` is the existing test for the both-sides-reduce shape, and it gained one assertion, that the join's own requirements still validate, i.e. that the two sides which were reduced together are still co-partitioned. That is the no-regression half of this change, and it belongs on the test that already describes the shape rather than in a copy of it. The four unit tests cover what a query test states only indirectly. They are the pairing relation itself, the `canCreatePartitioning` refusal, that a node which reduces nothing (or reduces another position) inherits the marker from its child, and that a projection drops the marker with the position it belongs to. The last two hold by construction under this representation, which is exactly why they are worth keeping, since an earlier design of this fix carried the marker on `KeyedPartitioning` and got both wrong. Green: `ShuffleSpecSuite`, `DistributionSuite`, `TransformExpressionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, `PlannerSuite`, `UnionSuite`, `DataSourceV2Suite`, 488 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code
2edcd13 to
45784d0Comparepeter-toth
commented
Sep 2, 2026
@dongjoon-hyun, @ulysses-you, I updated the PR and it is now ready for review. |
ulysses-you
left a comment
There was a problem hiding this comment.
How the pass was verified
Claim-propagation audit
The marker's claim — "this expression no longer computes the keys; the keys live in the key space identified by the pairing" — was checked across all three sets:
- Producers (exactly one):
KeyedShuffleSpec.reducersBothWaysmarks both sides in the same step (partitioning.scala:1646/1656), so the marker is symmetric by construction. The(None, None)guard (:1619-1625) sits ahead of both the transform-transform and the identity-transform cases, so no reducer is ever derived from or applied to a marked position, includingIdentityReducer. - Carriers:
KeyedPartitioning.project,AliasAwareOutputExpression.projectKeyedPartitionings,GroupPartitionsExec.outputPartitioning(both the no-reducer and reduce-other-position shapes),UnionExec.prepareOutputPartitioning's attribute rewrite,TransformExpression.withReference, andGroupPartitionsExec.doCanonicalize's positional normalization all keep the marker, because it is a plain case-class field that notransform/canonicalization descends into; dropping a position drops the marker with it (pinned by the two unit tests).PartitioningCollectionmembers cannot disagree on the marker: marking is per-reduce-step and symmetric, and the collection constructor alreadyrequires shared key reference/arity/isCollapsed; the comment at:1013-1017documents whyexpressionsDescribeKeysstays out of the invariant list, andsatisfies0'sexistsover members is safe under it. - Consumers (all enumerated):
isExpressionCompatible(gated by pairing),canCreatePartitioning(gated),reducersBothWays(guarded),keysSatisfy'sOrderedDistributionarm (gated),UnionExec.comparePartitioning(refuses viasemanticEquals, since the field changes expression equality),eval/resolveFunctionCall(refuse, throw rather than misroute), and the executor-sideShuffleExchangeExeckey extractor (:475-490), which is only reachable throughKeyedShuffleSpec.createPartitioning, which is only reachable throughcandidateSpecs, which filters oncanCreatePartitioning— the chain closes.
The ungated arm is the sound one
keysSatisfy's ClusteredDistribution arm deliberately accepts marked partitionings. The clustering property (same clustering value implies same partition) holds because the reduced keys are still a deterministic function of the same attributes, and clustering asks nothing more. Aggregates/windows/joins on top are all safe under exactly that property; the consumers that need more (ordering, placement, re-reduce) are the gated ones.
Write path unreachable
A KeyedPartitioning becomes a shuffle target only via KeyedShuffleSpec.createPartitioning (the only createPartitioning call site producing one is EnsureRequirements.scala:268); write rebalance/repartition always build HashPartitioning/RoundRobinPartitioning from HasPartitionExpressions, never keyed. So the scaladoc claim "the write path never sees one" holds, and the resolveFunctionCall refusal inDistributionAndOrderingUtils is correctly a local gate (its marked arm is unreachable because write-side transforms come from the catalog, unmarked).
Cross-path axes probed
- codegen/interpreted:
doGenCodethrows for allTransformExpressions, marked or not — no asymmetry introduced;evalis the only runtimepath and is gated. - AQE: coalescing a keyed shuffle downgrades
outputPartitioningtoUnknownPartitioning(AQEShuffleReadExec.outputPartitioning),conservative and pre-existing; local-read conversion ofENSURE_REQUIREMENTSshuffles predates this PR and self-corrects through re-optimization (out of scope). - Serialization:
TransformFunctionIdisString+Option[Int], carries no exprIds, canonicalization-invariant — the marker survives the wire as a plain field. - Ordering: the
OrderedDistributionrefusal has documented causality (a reduced position always carries a transform;ORDER BY/requiredChildOrderingchildren are attributes or resolved scalar calls, neverTransformExpression, soareAllClusterKeysMatchedalreadyrefuses every reachable case) — the comment states this rather than a test, which is the accepted alternative.
| // same space through a different pairing, which nothing here can tell apart, and an identity | ||
| // side, which holds raw values. | ||
| (left, right) match { | ||
| case (l: TransformExpression, r: TransformExpression) => l.hasSameReducedKeys(r) |
There was a problem hiding this comment.
+1 to make the condition strict.
The information is already lost at reduce time. After a both-sides reduce the real key space is r1(f1(x)), which no transform names, and the current Reducer API cannot say which space it reduced onto. All the planner has is the reported original expression (e.g. bucket(32, id)).
If the gate were widened to allow deriving another reducer from the reported expression (i.e. reducing already-reduced keys again), it would admit not only the divisible, benign case above but also failure 2 of this PR: bucket(12) JOIN bucket(8) lands on id % 4, then joining bucket(6) derives (id % 4) % 6 — the left keys stay put while the right side moves to id % 6, and the query loses rows silently.
This is not an implementation accident; it is mathematically undecidable from the reduced values: a key of id % 4 = 0 can stand for id = 0, 4, 8, whose id % 6 values are 0, 4, 2 respectively. Mapping from a reduced space to a non-divisible one is simply not well-defined. So unless the exact reduced space is known, "reduce again" is only sound in the divisible case, and the available information cannot tell divisible from non-divisible.
So hasSameReducedKeys admits only the one provably sound case: both sides came out of the same reduce (same pairing), which guarantees the same layout. Everything else has to shuffle. That includes:
- A further reduce onto a divisor (the
32/12vs64/24case above:id % 4vsid % 8). - Different pairings that happen to land on the same space (
12 JOIN 8and12 JOIN 20both giveid % 4). - Meeting an unreduced table that is already laid out on that space (
bucket(12) JOIN bucket(8)reduced ontoid % 4, joined to a rawbucket(4, id)table whose keys are already aligned).
The cost in all three is an extra shuffle, never a wrong answer. On the base these shapes ran with 0 shuffles — correct by accident in the divisible cases, losing rows in the non-divisible ones — and the API cannot keep only the good half, so tightening the gate wholesale was the only safe choice available.
uros-b
commented
Sep 2, 2026
Thank you @peter-toth and @dongjoon-hyun@ulysses-you! |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
LGTM. The four wrong-result/crash cases are fixed and I could not find a regression. Two small nits inline, both fine to address here or in a follow-up.
| * whose result type disagrees with it. `KeyedShuffleSpec.createPartitioning` is the other case. | ||
| * It puts the other child's expressions over these keys with no reducer in sight, so a struct | ||
| * field can be named differently on the two sides. With no key at all the expressions are all | ||
| * there is, and there is no row to read or to place. |
There was a problem hiding this comment.
nit: the two cases described here can overlap, and the doc does not say what happens then. A both-sides-reduced (marked) partitioning can end up with zero partitionKeys, e.g. an inner join with V2_BUCKETING_PARTITION_FILTER_ENABLED whose two sides hold disjoint keys, so mergeAndDedupPartitionKeys(intersect = true) yields Nil. Then keyDataTypes falls back to expressionDataTypes, i.e. the un-reduced transform's type (DateType for days), while the sibling leg of the same pairing reports the reducer's type (LongType). EnsureRequirements compares the two at the reduced-types check without consulting expressionsDescribeKeys, since reducersBothWays returns (None, None), and throws STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES for a correct (empty) plan.
This is pre-existing on the base commit, so not a blocker. But since this PR rewrites the doc, it would be worth one sentence noting that the no-key fallback is not truthful for a marked expression, or a SPARK- reference if you prefer to track it separately. Carrying the reducer's resultType() in the marker would fix it properly.
There was a problem hiding this comment.
Thank you, this reproduces. I measured it both ways, on days/years tables where one leg's two sides hold disjoint keys, so the intersect empties it.
On this PR the query fails with STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES. On the base commit the same query fails with ClassCastException: Long cannot be cast to Integer, from applying a reducer to already reduced values. So the shape is broken either way and nothing here regresses.
One correction to the framing. The untruthful fallback is pre-existing, it arrived with SPARK-59120. The failure at the reduced-types check becomes reachable through this PR, because once no reducer is derived for an already reduced pair both sides read keyDataTypes directly. Before that the reducers were computed and both sides reported the reducer's type, so the check passed and the query died later.
Filed as SPARK-59176 with the repro and the two ways to fix it. The scaladoc now points at it, and I have started on it in a separate branch.
| * Memoised for `eval`, which runs per row. Safe to reuse only because it stays inside this | ||
| * expression, unlike the call `resolveFunctionCall` hands to a caller that plans with it. | ||
| */ | ||
| private lazy val evaluableFunctionCall: Option[Expression] = resolveFunctionCall() |
There was a problem hiding this comment.
nit: I think the fresh-per-call resolveFunctionCall() plus this memoised twin can collapse back into the single lazy val resolvedFunction that was here before, just un-privated and with the reducedWith.isEmpty guard. The sharing hazard the two comments describe does not arise: the only external caller, DistributionAndOrderingUtils.resolveTransformExpression, visits each node once inside expr.transform, and every TransformExpression in a write distribution/ordering is a distinct instance built by V2ExpressionUtils.toCatalyst (distribution and ordering are converted by separate calls). Even if one instance did land in two positions, QueryExecution.cloneWithFreshStatefulExpressions and ExpressionsEvaluator.prepareExpressions already fresh-copy stateful expressions per node and per evaluator. One member and no caveat comments would do the same job.
There was a problem hiding this comment.
Agreed, and both mechanisms are there. QueryExecution.cloneWithFreshStatefulExpressions maps every node's expressions through freshCopyIfContainsStatefulExpression() before optimization, and ExpressionsEvaluator.prepareExpressions does the same per evaluator. So the hazard the split guarded against cannot arise.
Collapsed back into one lazy val resolvedFunction, un-private, with the reducedWith guard, and both caveat comments are gone.
…arker ### What changes were proposed in this pull request? Two review comments on apache#58447, both non-blocking. `TransformExpression.resolveFunctionCall()` and its memoised twin collapse back into one `lazy val resolvedFunction`, un-private and carrying the `reducedWith` guard. The split existed to keep the write path from putting a shared stateful `ApplyFunctionExpression` in a plan, and that hazard does not arise: `QueryExecution.cloneWithFreshStatefulExpressions` and `ExpressionsEvaluator.prepareExpressions` already fresh-copy stateful expressions per node and per evaluator. The `keyDataTypes` scaladoc now says what happens where its two cases meet. A marked partitioning can end up with no key, and the no-key fallback then reports the un-reduced transform's type. ### Why are the changes needed? The first is one member and no caveat comments in place of two members and two. The second closes a gap the rewritten doc left open. With `v2BucketingPartitionFilterEnabled` an inner join whose two sides hold disjoint keys intersects to none, so a leg of a both-sides reduce can report no key. `keyDataTypes` then falls back to the un-reduced transform's type, `DateType` for `days`, while the other leg of the same pairing reports the reducer's `LongType`. A further join between the two legs compares them in `EnsureRequirements`' reduced-types check and throws `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES` for a query whose result is empty. Measured on `days`/`years` tables. The same query throws `ClassCastException: Long cannot be cast to Integer` without the marker, so nothing regresses. SPARK-59176 tracks the fix, which needs the reducer's result type recorded where the key is missing. ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? Existing tests. Green: `ShuffleSpecSuite`, `DistributionSuite`, `TransformExpressionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, `PlannerSuite`, `UnionSuite`, `DataSourceV2Suite`, 498 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code
…reduces the partition keys of both sides ### What changes were proposed in this pull request? This builds on #58335, which makes a reduced key-grouped partitioning report a transform that describes its keys for the two reducer shapes where one exists. When both sides of the join reduce there is none. The keys become `r1(f1(x))` = `r2(f2(x))`, a third key space that neither side's transform names. `bucket(12, id)` joined to `bucket(8, id)` is the flagship example. `BucketFunction.reducer` hands both sides `BucketReducer(4)`, the keys become `id % 4`, and both sides keep reporting the transforms they were built from. That is the gap #58335 names and leaves open, and this PR closes it by saying so rather than by inventing an expression. `TransformExpression` gains a fourth field, `reducedWith: Option[TransformFunctionId]`, which names the transform this one's keys were reduced together with. `KeyedShuffleSpec.reducersBothWays` is the only producer. In its both-sides-reduce branch it now reports `e1.reducedTogetherWith(e2)` instead of the bare `e1`. The marker holds no `Expression`, only a canonical name and a bucket count, so it is safe in a field canonicalization does not descend into. Putting it on the expression rather than on `KeyedPartitioning` is what keeps the change small. Every site that derives a partitioning already carries the expressions along. `AliasAwareOutputExpression` projects them, `GroupPartitionsExec.outputPartitioning` re-reports them, and `TransformExpression.withReference` re-targets them, so the marker is inherited, and dropped with the position it belongs to, without a line of new plumbing. `GroupPartitionsExec` needed no change at all. Four sites then refuse to reason about such keys, and a fifth refusal falls out of expression equality: - `KeyedShuffleSpec.isExpressionCompatible` does not compare marked keys by transform. Two of them are compatible when the same pair was reduced together, which is the pair the join produced. Anything else has to shuffle. - `KeyedShuffleSpec.canCreatePartitioning` does not shuffle another child onto marked keys, because that evaluates the reported expressions per row. - `KeyedShuffleSpec.reducersBothWays` does not reduce marked keys a second time. - `keysSatisfy` does not let marked keys satisfy an `OrderedDistribution`. An ordering is a claim about the key *values*, and nothing makes a reducer order-preserving. This one is a local guard. A marked position always carries a transform, and a SQL `ORDER BY` cannot name one. - `UnionExec.comparePartitioning` compares the children's expressions with `semanticEquals`, so a marked child no longer merges with an unmarked sibling reporting the same transform. No change was needed there. Clustering is the one thing a marked partitioning still satisfies, and that is sound. The keys remain a function of the same attributes, which is all `ClusteredDistribution` asks. Two things promised in the review of SPARK-59120 (#58420) are now delivered, since the marker answers the question they approximated: `KeyedPartitioning.expressionsDescribeKeyShape` and its use in `canCreatePartitioning` are replaced by `expressionsDescribeKeys`, and the three-reader list in the `keyDataTypes` scaladoc is gone. Keeping the type check as a second conjunct was considered and dropped. It is not sufficient, since `bucket(12)` and `bucket(8)` reducing onto `bucket(4)` keep their `IntegerType` and pass it. It is not necessary either. After a one-side reduce the reported expression is the target transform, and `EnsureRequirements` already refuses a reducer whose `resultType()` disagrees with the other side's key types, which are that transform's, with `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES`. So the keys and the expressions agree at every reachable one-side reduce, and a both-sides reduce is marked. The unit test that pinned the type proxy is replaced by one that pins the marker, and it keeps the proxy's struct case. A partitioning whose key row was built at `struct<f>` while it declares `struct<g>`, which is what `createPartitioning` produces, is still accepted. Re-adding any type comparison to the gate fails that assertion. Two smaller things came with it. `TransformExpression.resolvedFunction` refuses a marked expression, so `eval` throws on one instead of computing the un-reduced transform and misrouting the row. That is a local gate rather than a live check, because every consumer of a reduced partitioning refuses it first and the write path never sees one. It also replaces the copy of the rule that prepends the bucket count as a literal argument in `DistributionAndOrderingUtils`, and both copies had to be touched here anyway, since the extractor grew a field. The `keyDataTypes` scaladoc also records where its no-key fallback stops being truthful. A marked partitioning can end up with no key, for instance when `v2BucketingPartitionFilterEnabled` intersects two sides that hold disjoint keys, and it then reports the un-reduced transform's type while the other leg of the same pairing reports the reducer's. SPARK-59176 tracks that, with the repro and two ways to fix it. The same query fails on a `ClassCastException` without this marker, so nothing regresses here. ### Why are the changes needed? Four failures, each one a test here, all measured on this PR's base. All of them need `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, which is off by default, since that is what admits a reducer at all. 1. Two reduces onto different key spaces look co-partitioned. Four tables holding ids 0 until 24, bucketed by 12 and 8 on one side and by 12 and 18 on the other. The first pair reduces onto `id % 4`, the second onto `id % 6`, and both keep reporting `bucket(12, id)`. Joining the two returns **8 of 24 rows** with no shuffle. This is also what the pairing in the marker is for. Marking the keys without recording which pair produced them fixes everything else here and still returns those 8 rows. 2. Reducing keys twice. Three tables bucketed by 12, 8 and 6: the first join reduces onto `id % 4`, then the second derives a `bucket(6)` reducer from the reported `bucket(12, id)` and applies it to keys that are already reduced. `(id % 4) % 6` leaves the left keys alone while the right side moves to `id % 6`, so the query loses rows with no shuffle. 3. Reducing keys twice, the other way. Two `days`/`years` joins that each reduce both sides onto one year space, then joined to each other with different key sets on the two legs. The outer join derives reducers again and planning throws `ClassCastException: Long cannot be cast to Integer`. 4. Merging a reduced partitioning in a union. `(bucket12 JOIN bucket8) UNION ALL bucket12` reports `bucket(12, id)` on both sides of the union, so the two are merged although the join side's keys are `id % 4`. A `GROUP BY id` above it returns each id twice. The fifth refusal, `canCreatePartitioning`, guards a hazard the first one opens up. Once a marked partitioning is no longer compatible with anything but its own pairing, `EnsureRequirements` takes the one-side-shuffle path instead, and there it would happily shuffle the other child onto the reduced keys, which places rows by evaluating `bucket(12, id)` against partitions laid out by `id % 4`. Measured with tables bucketed by 12, 8 and 2 and `v2BucketingShuffleEnabled`, with the clause removed the query returns **8 of 12 rows**. ### Does this PR introduce _any_ user-facing change? Yes, it fixes the wrong results and the crash above. A query that used to reach one of them now shuffles instead, so its plan changes. It also refuses two plans that happen to be correct today, In both cases nothing can tell the two apart from the outside. The first is a second join onto an already reduced space. `bucket(12) JOIN bucket(8)` lands on `id % 4`. Meeting a `bucket(4, id)` or `bucket(2, id)` table, `BucketReducer` would compose correctly, since those counts divide 4. Meeting a `bucket(6, id)` table it would not, and that is failure 2 above. The `Reducer` API cannot say which of the two it is, so both now shuffle: `bucket12 JOIN bucket8 JOIN bucket4` goes from 0 shuffles to 2, with the same rows. This only arises where both sides reduced, i.e. where `gcd(a, b) < min(a, b)`. A chain in which one bucket count divides the other takes the one-side path and is unaffected. The second is two reduces that land on the same space through different pairings, e.g. `bucket(12)` with `bucket(8)` and `bucket(12)` with `bucket(20)`, both of which give `id % 4`. They are treated as different spaces and the join shuffles. Both cost a shuffle, not a wrong answer. A follow-up can give them back by letting a `ReducibleFunction` name the transform it reduces onto, which makes this whole shape disappear rather than be refused. Queries whose keys were not reduced on both sides are unaffected, and a join of two sides that were reduced together keeps its plan. ### How was this patch tested? Ten new tests. Six query tests in `KeyGroupedPartitioningSuite`, and one each in `TransformExpressionSuite`, `ShuffleSpecSuite`, `GroupPartitionsExecSuite` and `ProjectedOrderingAndPartitioningSuite`. One existing test changed, see below. Four of the six query tests fail on the base commit with wrong rows or a crash, listed as failures 1 to 4 above. A fifth, `another side is not shuffled onto reduced keys`, returns the right rows there with no shuffle. This PR costs it one shuffle, and it is what pins the `canCreatePartitioning` clause. Each refusal was ablated, and each ablation fails exactly the tests written for it: - `canCreatePartitioning`'s clause removed: `another side is not shuffled onto reduced keys` returns 8 of 12 rows. - the same-pairing allowance in `isExpressionCompatible` made unconditional, i.e. the refusal taken too far. `two sides reduced onto the same keys still join without a shuffle` fails, and so does the existing `SPARK-56164: Reducers with different result types to original keys`. - the pairing thrown away, so that any two marked keys count as one space. `two reduced partitionings are not compatible by their transforms` returns 8 of 24 rows. That is the ablation the test exists for, and it is why the marker records the pair rather than a bit. - the `reducersBothWays` guard removed. `two sides reduced together are not reduced a second time` throws the `ClassCastException`. No other test in the suite reaches that guard, and instrumenting it to throw on entry showed every one of them misses it. That is why the test is built the way it is, with different key sets on the two legs so that the join computes reducers at all. `SPARK-56164` is the existing test for the both-sides-reduce shape, and it gained one assertion, that the join's own requirements still validate, i.e. that the two sides which were reduced together are still co-partitioned. That is the no-regression half of this change, and it belongs on the test that already describes the shape rather than in a copy of it. The four unit tests cover what a query test states only indirectly. They are the pairing relation itself, the `canCreatePartitioning` refusal, that a node which reduces nothing (or reduces another position) inherits the marker from its child, and that a projection drops the marker with the position it belongs to. The last two hold by construction under this representation, which is exactly why they are worth keeping, since an earlier design of this fix carried the marker on `KeyedPartitioning` and got both wrong. Green: `ShuffleSpecSuite`, `DistributionSuite`, `TransformExpressionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, `PlannerSuite`, `UnionSuite`, `DataSourceV2Suite`, 488 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58447 from peter-toth/SPARK-59121-shape3. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit ed06d80) Signed-off-by: Peter Toth <peter.toth@gmail.com>
peter-toth
commented
Sep 2, 2026
peter-toth
commented
Sep 2, 2026
Thank you everyone for the review! |
What changes were proposed in this pull request?
This builds on #58335, which makes a reduced key-grouped partitioning report a transform that describes its keys for the two reducer shapes where one exists. When both sides of the join reduce there is none. The keys become
r1(f1(x))=r2(f2(x)), a third key space that neither side's transform names.bucket(12, id)joined tobucket(8, id)is the flagship example.BucketFunction.reducerhands both sidesBucketReducer(4), the keys becomeid % 4, and both sides keep reporting the transforms they were built from. That is the gap #58335 names and leaves open, and this PR closes it by saying so rather than by inventing an expression.TransformExpressiongains a fourth field,reducedWith: Option[TransformFunctionId], which names the transform this one's keys were reduced together with.KeyedShuffleSpec.reducersBothWaysis the only producer. In its both-sides-reduce branch it now reportse1.reducedTogetherWith(e2)instead of the baree1. The marker holds noExpression, only a canonical name and a bucket count, so it is safe in a field canonicalization does not descend into.Putting it on the expression rather than on
KeyedPartitioningis what keeps the change small. Every site that derives a partitioning already carries the expressions along.AliasAwareOutputExpressionprojects them,GroupPartitionsExec.outputPartitioningre-reports them, andTransformExpression.withReferencere-targets them, so the marker is inherited, and dropped with the position it belongs to, without a line of new plumbing.GroupPartitionsExecneeded no change at all.Four sites then refuse to reason about such keys, and a fifth refusal falls out of expression equality:
KeyedShuffleSpec.isExpressionCompatibledoes not compare marked keys by transform. Two of them are compatible when the same pair was reduced together, which is the pair the join produced. Anything else has to shuffle.KeyedShuffleSpec.canCreatePartitioningdoes not shuffle another child onto marked keys, because that evaluates the reported expressions per row.KeyedShuffleSpec.reducersBothWaysdoes not reduce marked keys a second time.keysSatisfydoes not let marked keys satisfy anOrderedDistribution. An ordering is a claim about the key values, and nothing makes a reducer order-preserving. This one is a local guard. A marked position always carries a transform, and a SQLORDER BYcannot name one.UnionExec.comparePartitioningcompares the children's expressions withsemanticEquals, so a marked child no longer merges with an unmarked sibling reporting the same transform. No change was needed there.Clustering is the one thing a marked partitioning still satisfies, and that is sound. The keys remain a function of the same attributes, which is all
ClusteredDistributionasks.Two things promised in the review of SPARK-59120 (#58420) are now delivered, since the marker answers the question they approximated:
KeyedPartitioning.expressionsDescribeKeyShapeand its use incanCreatePartitioningare replaced byexpressionsDescribeKeys, and the three-reader list in thekeyDataTypesscaladoc is gone.Keeping the type check as a second conjunct was considered and dropped. It is not sufficient, since
bucket(12)andbucket(8)reducing ontobucket(4)keep theirIntegerTypeand pass it. It is not necessary either. After a one-side reduce the reported expression is the target transform, andEnsureRequirementsalready refuses a reducer whoseresultType()disagrees with the other side's key types, which are that transform's, withSTORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES. So the keys and the expressions agree at every reachable one-side reduce, and a both-sides reduce is marked. The unit test that pinned the type proxy is replaced by one that pins the marker, and it keeps the proxy's struct case. A partitioning whose key row was built atstruct<f>while it declaresstruct<g>, which is whatcreatePartitioningproduces, is still accepted. Re-adding any type comparison to the gate fails that assertion.Two smaller things came with it.
TransformExpression.resolvedFunctionrefuses a marked expression, soevalthrows on one instead of computing the un-reduced transform and misrouting the row. That is a local gate rather than a live check, because every consumer of a reduced partitioning refuses it first and the write path never sees one. It also replaces the copy of the rule that prepends the bucket count as a literal argument inDistributionAndOrderingUtils, and both copies had to be touched here anyway, since the extractor grew a field.The
keyDataTypesscaladoc also records where its no-key fallback stops being truthful. A marked partitioning can end up with no key, for instance whenv2BucketingPartitionFilterEnabledintersects two sides that hold disjoint keys, and it then reports the un-reduced transform's type while the other leg of the same pairing reports the reducer's. SPARK-59176 tracks that, with the repro and two ways to fix it. The same query fails on aClassCastExceptionwithout this marker, so nothing regresses here.Why are the changes needed?
Four failures, each one a test here, all measured on this PR's base. All of them need
spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled, which is off by default, since that is what admits a reducer at all.id % 4, the second ontoid % 6, and both keep reportingbucket(12, id). Joining the two returns 8 of 24 rows with no shuffle. This is also what the pairing in the marker is for. Marking the keys without recording which pair produced them fixes everything else here and still returns those 8 rows.id % 4, then the second derives abucket(6)reducer from the reportedbucket(12, id)and applies it to keys that are already reduced.(id % 4) % 6leaves the left keys alone while the right side moves toid % 6, so the query loses rows with no shuffle.days/yearsjoins that each reduce both sides onto one year space, then joined to each other with different key sets on the two legs. The outer join derives reducers again and planning throwsClassCastException: Long cannot be cast to Integer.(bucket12 JOIN bucket8) UNION ALL bucket12reportsbucket(12, id)on both sides of the union, so the two are merged although the join side's keys areid % 4. AGROUP BY idabove it returns each id twice.The fifth refusal,
canCreatePartitioning, guards a hazard the first one opens up. Once a marked partitioning is no longer compatible with anything but its own pairing,EnsureRequirementstakes the one-side-shuffle path instead, and there it would happily shuffle the other child onto the reduced keys, which places rows by evaluatingbucket(12, id)against partitions laid out byid % 4. Measured with tables bucketed by 12, 8 and 2 andv2BucketingShuffleEnabled, with the clause removed the query returns 8 of 12 rows.Does this PR introduce any user-facing change?
Yes, it fixes the wrong results and the crash above. A query that used to reach one of them now shuffles instead, so its plan changes.
It also refuses two plans that happen to be correct today, In both cases nothing can tell the two apart from the outside.
The first is a second join onto an already reduced space.
bucket(12) JOIN bucket(8)lands onid % 4. Meeting abucket(4, id)orbucket(2, id)table,BucketReducerwould compose correctly, since those counts divide 4. Meeting abucket(6, id)table it would not, and that is failure 2 above. TheReducerAPI cannot say which of the two it is, so both now shuffle:bucket12 JOIN bucket8 JOIN bucket4goes from 0 shuffles to 2, with the same rows. This only arises where both sides reduced, i.e. wheregcd(a, b) < min(a, b). A chain in which one bucket count divides the other takes the one-side path and is unaffected.The second is two reduces that land on the same space through different pairings, e.g.
bucket(12)withbucket(8)andbucket(12)withbucket(20), both of which giveid % 4. They are treated as different spaces and the join shuffles.Both cost a shuffle, not a wrong answer. A follow-up can give them back by letting a
ReducibleFunctionname the transform it reduces onto, which makes this whole shape disappear rather than be refused.Queries whose keys were not reduced on both sides are unaffected, and a join of two sides that were reduced together keeps its plan.
How was this patch tested?
Ten new tests. Six query tests in
KeyGroupedPartitioningSuite, and one each inTransformExpressionSuite,ShuffleSpecSuite,GroupPartitionsExecSuiteandProjectedOrderingAndPartitioningSuite. One existing test changed, see below.Four of the six query tests fail on the base commit with wrong rows or a crash, listed as failures 1 to 4 above. A fifth,
another side is not shuffled onto reduced keys, returns the right rows there with no shuffle. This PR costs it one shuffle, and it is what pins thecanCreatePartitioningclause. Each refusal was ablated, and each ablation fails exactly the tests written for it:canCreatePartitioning's clause removed:another side is not shuffled onto reduced keysreturns 8 of 12 rows.isExpressionCompatiblemade unconditional, i.e. the refusal taken too far.two sides reduced onto the same keys still join without a shufflefails, and so does the existingSPARK-56164: Reducers with different result types to original keys.two reduced partitionings are not compatible by their transformsreturns 8 of 24 rows. That is the ablation the test exists for, and it is why the marker records the pair rather than a bit.reducersBothWaysguard removed.two sides reduced together are not reduced a second timethrows theClassCastException. No other test in the suite reaches that guard, and instrumenting it to throw on entry showed every one of them misses it. That is why the test is built the way it is, with different key sets on the two legs so that the join computes reducers at all.SPARK-56164is the existing test for the both-sides-reduce shape, and it gained one assertion, that the join's own requirements still validate, i.e. that the two sides which were reduced together are still co-partitioned. That is the no-regression half of this change, and it belongs on the test that already describes the shape rather than in a copy of it.The four unit tests cover what a query test states only indirectly. They are the pairing relation itself, the
canCreatePartitioningrefusal, that a node which reduces nothing (or reduces another position) inherits the marker from its child, and that a projection drops the marker with the position it belongs to. The last two hold by construction under this representation, which is exactly why they are worth keeping, since an earlier design of this fix carried the marker onKeyedPartitioningand got both wrong.Green:
ShuffleSpecSuite,DistributionSuite,TransformExpressionSuite,KeyGroupedPartitioningSuite,GroupPartitionsExecSuite,EnsureRequirementsSuite,ProjectedOrderingAndPartitioningSuite,ValidateRequirementsSuite,WriteDistributionAndOrderingSuite,PlannerSuite,UnionSuite,DataSourceV2Suite, 488 tests in all.dev/lint-scalais clean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code