peter-toth opened a new pull request, #58447: URL: https://github.com/apache/spark/pull/58447
### 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 are `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, `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 - `bucket(12)` and `bucket(8)` reducing onto `bucket(4)` keep their `IntegerType` and pass it - and 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, plus an assertion that a struct-typed key needs no special handling now that the gate does not compare types. 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: 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` - 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, both because nothing can tell the two cases 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 - instrumenting it to throw on entry showed every one of them misses it - which 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: 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 - 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. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
