[SPARK-59080][SQL] Pick one ShuffleSpecCollection member for the SPJ pushdown and the re-shuffle - #58527
Conversation
…pushdown and the re-shuffle Under `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys`, `KeyedPartitioning.createShuffleSpec` projects each member of a `PartitioningCollection` onto its own subset of the operation keys. The members of the resulting `ShuffleSpecCollection` can therefore end up with different partition counts. `EnsureRequirements` asked the collection itself for a shuffle template, and `ShuffleSpecCollection.createPartitioning` throws `expected all specs in the collection to have the same number of partitions`. The collection cannot answer that question. `isCompatibleWith` succeeds when *any* member matches, so the collection alone never said which member the two sides agreed on, and reading `specs.head` took whichever the alias cross-product enumerated first. `EnsureRequirements` now resolves that member once, preferring the finest when several qualify, and uses it to build the re-shuffled child's partitioning. The `require` stays as a guard on a method that no longer has a production caller. The `joinKeyPositions` pushed into a compatible child now come from that child's own matching member. They index into the child's own partition expressions, so the best spec's positions were only right when the best spec was that child's. No query reaches the wrong case today: a join is handled by `checkKeyGroupCompatible`, which already pushes each side's own positions, and a cogroup's grouping key is synthesized so neither side stays keyed.
sunchao
left a comment
There was a problem hiding this comment.
One nonblocking P3 finding; no P1/P2 defects found. Reviewed 6136919314809cd61473fb045d0521c202b41fff with five independent reviewers.
Finding
[P3] Correct the claim that keyed cogroups cannot reach this branch. The new comment overlooks Pandas/Arrow cogroups, which preserve named grouping expressions. The existing SPARK-58968 test in this suite already exercises two keyed cogroup children. Qualify the comment and the PR description’s claim that this correction is latent. The implementation itself improves this reachable path.
Validation
| Coverage | Result |
|---|---|
| Affected suites in CI | 202 passed |
| Local focused tests | 69 passed |
| Additional nested-layout and fallback probes | 6 passed |
| Reverted implementation | Reproduced all three claimed regression failures |
| Full-patch and last-commit whitespace checks | Passed |
Local validation used verified CI binaries and source overlays, not a clean local build. The PR head remained unchanged. Nothing was posted to GitHub.
| // two the member that reported the match serves both, and no operator has three today. | ||
| flattenSpec(best) | ||
| .filter(m => matchedMembers.forall(_.exists(m.isCompatibleWith))) | ||
| .maxByOption(_.numPartitions) |
There was a problem hiding this comment.
createKeyedShuffleSpec still resolves a PartitioningCollection with collectFirst, taking the first satisfying member, while this PR makes the per-child branch resolve the finest member both sides agreed on. The default requireAllClusterKeysForCoPartition=true keeps partial-coverage members out of the candidates, so this only shows up with requireAllClusterKeysForCoPartition=false plus allowKeysSubsetOfPartitionKeys=true: when both sides report alias cross-products with a coarse member first, SPJ pairs the coarse members and joins on them, leaving the finer pairing unused; with coarse first on one side and fine first on the other, the picks fail to pair and the per-child branch takes over, grouping each side on its finest matching member, so what is skipped is the SPJ partition-value pushdown, not a shuffle. Results stay correct. If you align it, the choice cannot be an independent per-side finest (a side with only coarse members would then fail to pair); it has to pair like bestMemberOpt does, by finest member compatible with some member of the other side. Either fixing it here or a follow-up works for me.
There was a problem hiding this comment.
Good catch, and I took it into #58531 rather than a separate ticket. createKeyedShuffleSpecs now returns every member's spec and checkKeyGroupCompatible picks the pair, with your constraint respected: not an independent per-side finest, but the pair that agrees on the keys and offers the most parallelism.
Two things I measured that are worth passing back.
Your reachability framing is slightly off, in a way that does not change your conclusion. requireAllClusterKeysForCoPartition = true does not always keep a collection down to one candidate: I instrumented createKeyedShuffleSpec over KeyGroupedPartitioningSuite and EnsureRequirementsSuite and got 31 collection reaches, of which 2 had two qualifying members under the default. But in every one of those the qualifying members produced identical specs, so the pick was not observable. The observable case does need the relaxed conf, as you said — just not for the reason that the members are filtered out.
The shape is narrower than it looks, too. Two members can only differ in coverage if the clustering is wider than the partitioning arity, since the collection requires its members to have matching arity. With that, the repro is: each side offering a member for a different clustering-key subset, in the opposite order, so taking each side's first member pairs two that do not agree. On the fix that pairs correctly and no shuffle is needed at all; without it checkKeyGroupCompatible declines and each side is merely grouped on its own keys.
I also checked the sibling collectFirst at the top of the partially-clustered branch, which recovers the original KeyedPartitioning while the positions come from the picked member. That one is harmless and I am not touching it: projectKeys reads only partitionKeys and the types derived from them, and the collection invariant makes partitionKeys a shared reference, so any member gives the same answer.
| lazy val matchedIndexes = bestSpecOpt.toSeq.flatMap { best => | ||
| childrenIndexes.filter(i => best.isCompatibleWith(specs(i))) | ||
| } | ||
| lazy val bestMemberOpt = bestSpecOpt.flatMap { best => |
There was a problem hiding this comment.
bestSpecOpt is still picked with ShuffleSpecCollection.numPartitions, which comes from specs.head. A collection like [coarse head with 2 partitions, fine member with 200] competes as 2 in the maxBy, loses to a sibling child's plain 100-partition spec, and the side holding the fine member gets re-shuffled onto 100 partitions when it could have stayed unshuffled as the best. The selection line predates this PR, and in this shape it behaves exactly as before (the losing side took the same full re-shuffle), so this is purely pre-existing. Not blocking; worth a follow-up comparing best candidates at member level at the call site (changing numPartitions itself would affect other readers).
There was a problem hiding this comment.
Agreed, and this is exactly what I had queued as the follow-up: #58531 (SPARK-59256). It takes the max over the flattened members at the call site, for the reason you give — changing numPartitions itself would affect other readers.
Worth naming the other reader, since it turned out to want a different aggregation: SinglePartitionShuffleSpec.isCompatibleWith reads other.numPartitions == 1, reached from ValidateRequirements, and there the right answer is exists, not max. Two consumers wanting two different aggregations is what convinced me the collection should not answer numPartitions at all rather than answer it better, so that PR removes it along with createPartitioning.
… latent @sunchao pointed out that Pandas and Arrow cogroups group on real columns, unlike the Scala `CoGroupExec`, whose key comes from an `AppendColumns` that no `KeyedPartitioning` satisfies. Two keyed children do reach the per-child branch, so pushing each child's own `joinKeyPositions` is a reachable correctness fix rather than a latent one. The plan test is rebuilt on `FlatMapCoGroupsInPandasExec` instead of a synthetic parent. Both sides declare the same two key columns in the opposite order, so the cogroup key sits at position 1 on the left and at position 0 on the right, and both project onto the same key set. Without the production change the right side is handed the left side's positions and ends up grouped on its other partition column: `List(Some(List(1)), Some(List(1)))` where `List(Some(List(1)), Some(List(0)))` is right.
|
Thanks @sunchao, and you are right — I checked and it is worse than a wording problem. My "the cogroup key is synthesized" claim came from a measurement I did for a different follow-up, and that measurement only covered the Scala So the Pushed as f6a1442, and the description no longer calls that half latent. The only part that stays latent is the three-or-more clustered children case. |
|
+1! |
| ) | ||
| } | ||
|
|
||
| test("SPARK-59080: a collection whose members cover different key subsets disagrees") { |
There was a problem hiding this comment.
Just a note on this test so the next reader doesn't mistake it for the regression test: it pins the current contract (members with divergent numPartitions keep createPartitioning throwing) and passes both before and after this PR, since catalyst is untouched here. Regression protection rests on the other three tests - the two EnsureRequirementsSuite ones (coarse-member-first, swapped-key-order cogroup) and the e2e - all of which fail on the pre-PR code. No change needed.
There was a problem hiding this comment.
Exactly right, and your list of the three is exactly the set: the coarse-member-first plan test, the Pandas cogroup one with the swapped key order, and the e2e. All three fail on the pre-PR code; this fourth one passes there by design, which the PR description says too.
It became the guard's test rather than the fix's when the design moved: an earlier shape narrowed the collection so the require was satisfiable, and this test asserted that narrowing. The fix stopped touching catalyst, so what was left worth pinning is the contract — the members disagree on purpose, every one of them stays available for isCompatibleWith, and asking the collection for a single partitioning throws.
| // all take the ordinary shuffle. That needs three or more clustered children, since with | ||
| // two the member that reported the match serves both, and no operator has three today. | ||
| flattenSpec(best) | ||
| .filter(m => matchedMembers.forall(_.exists(m.isCompatibleWith))) |
There was a problem hiding this comment.
Coverage note, no change requested: this fallback (no member serving every matched child, so all children take the ordinary shuffle) has no test and leans on the comment that no operator has three clustered children today - which holds at this commit, having checked the requiredChildDistribution implementations under sql/core. A future operator with three clustered children would exercise this branch silently, so if one ever lands it deserves a test then.
There was a problem hiding this comment.
Agreed, and thank you for checking the requiredChildDistribution implementations independently — I measured the same thing and got the same answer: joins and cogroups are the only operators with more than one clustered child, and both have exactly two.
One thing worth adding, since it changes how alarming the untested branch is. That fallback used to guess a member: it took the finest of all of them when no single one served every matched child. A blind review round pointed out that a child then gets declared co-partitioned on a layout it does not have, with no shuffle inserted — silent wrong results. So it was changed to bail out instead, which is what you see. The untested arm is now the conservative one, and if an operator with three clustered children ever lands, the failure mode is a lost SPJ rather than a wrong answer. A test then, agreed.
…pushdown and the re-shuffle ### What changes were proposed in this pull request? `EnsureRequirements` stops asking a `ShuffleSpecCollection` for a single answer. It resolves the one member the matched children agreed on, preferring the finest when several qualify, and uses that member to build a re-shuffled child's partitioning. - `flattenSpec` replaces the head read. It recurses, because `ShuffledJoin.outputPartitioning` builds `PartitioningCollection.fromPartitionings(Seq(left, right))` for an inner join, so a chain of same-key joins nests collections. - The chosen member has to be compatible with every matched child. When no member is, there is no shared layout, so every child takes the ordinary shuffle. Reaching that needs three or more clustered children, and no operator has three today. - The `joinKeyPositions` pushed into a compatible child now come from that child's own matching member, because they index into that child's partition expressions. - `ShuffleSpecCollection.createPartitioning` is untouched. Its `require` stays as a guard, and a new unit test pins it. ### Why are the changes needed? Under `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`, `KeyedPartitioning.createShuffleSpec` projects each member of a `PartitioningCollection` onto *its own* join-key subset and drops the duplicate keys that projection creates. The members of the resulting `ShuffleSpecCollection` can therefore end up with different `numPartitions`. `EnsureRequirements` then asks the collection for a shuffle template, and `ShuffleSpecCollection.createPartitioning` requires all members to agree: ``` java.lang.IllegalArgumentException: requirement failed: expected all specs in the collection to have the same number of partitions ``` so planning fails outright. With `items` partitioned by `[identity(id), identity(arrive_time)]`, one row per split, `purchases` unpartitioned, and `v2BucketingShuffleEnabled=true`, `partiallyClusteredDistribution=false`, `allowKeysSubsetOfPartitionKeys=true`: ```sql SELECT /*+ MERGE(i, p) */ id, t1, t2, i.price AS purchase_price, p.price AS sale_price FROM (SELECT id, arrive_time AS t1, arrive_time AS t2, price FROM testcat.ns.items) i JOIN testcat.ns.purchases p ON i.id = p.item_id AND i.t1 = p.time ``` Selecting `arrive_time` twice under two aliases makes the alias cross-product produce members that cover different numbers of join keys, which is where the counts diverge. The collection cannot answer that question locally. `isCompatibleWith` succeeds when *any* member matches, so the collection alone never said which member the two sides agreed on, and `createPartitioning` fell back to `specs.head`, whichever the alias cross-product enumerated first. Narrowing the collection to its finest members would satisfy the `require`, but it would still be a guess: the right member is the one the *other* side matched, and that is only visible in `EnsureRequirements`. ### Does this PR introduce _any_ user-facing change? Yes. The query above failed to plan and now runs, producing one shuffle and the right rows. The `joinKeyPositions` half is user-facing too. I originally wrote here that it was latent, on the grounds that a cogroup's grouping key is synthesized so neither side stays keyed. sunchao pointed out that this is only true of the Scala `CoGroupExec`, whose key comes from an `AppendColumns` that no `KeyedPartitioning` satisfies. A Pandas or Arrow cogroup groups on real columns, so two keyed children do reach the per-child branch, and this suite already had a `FlatMapCoGroupsInPandasExec` test with two of them. So: with two keyed children whose partition expressions are laid out differently, the second side is handed the first side's positions and ends up grouped on its other partition column. The plan test below reproduces it and fails without the fix, reporting `List(Some(List(1)), Some(List(1)))` where `List(Some(List(1)), Some(List(0)))` is right. I have not built an end-to-end query for it. The only part that stays latent is the three-or-more clustered children case, which no operator has. ### How was this patch tested? Four new tests. Each was measured against the same commit with only the `EnsureRequirements` change reverted. | test | on base | |---|---| | `KeyGroupedPartitioningSuite`: both sides of the join land on the same collection member | fails with the `require` above | | `EnsureRequirementsSuite`: the re-shuffled side lands on the member the keyed side was matched on | fails with the `require` above | | `EnsureRequirementsSuite`: pushed-down positions index into the child's own partition expressions (a Pandas cogroup over two keyed children) | fails | | `ShuffleSpecSuite`: a collection whose members cover different key subsets disagrees | passes, by design | The last one pins the guard rather than the fix. It asserts that the members disagree on purpose, that every one of them stays available for `isCompatibleWith`, and that asking the collection for a single partitioning throws. That turns the `require` from untested prose into a pinned contract, which matters now that the method has no production caller. Green: `ShuffleSpecSuite`, `EnsureRequirementsSuite`, `KeyGroupedPartitioningSuite`, 202 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes #58527 from peter-toth/SPARK-59080-shufflespec-numpartitions. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 4605662) Signed-off-by: Peter Toth <peter.toth@gmail.com>
|
Thank you all for the review. I will open backport PRs soon. |
… SPJ pushdown and the re-shuffle ### What changes were proposed in this pull request? `EnsureRequirements` stops asking a `ShuffleSpecCollection` for a single answer. It resolves the one member the matched children agreed on, preferring the finest when several qualify, and uses that member to build a re-shuffled child's partitioning. - `flattenSpec` replaces the head read. It recurses, because `ShuffledJoin.outputPartitioning` builds `PartitioningCollection.fromPartitionings(Seq(left, right))` for an inner join, so a chain of same-key joins nests collections. - The chosen member has to be compatible with every matched child. When no member is, there is no shared layout, so every child takes the ordinary shuffle. Reaching that needs three or more clustered children, and no operator has three today. - The `joinKeyPositions` pushed into a compatible child now come from that child's own matching member, because they index into that child's partition expressions. - `ShuffleSpecCollection.createPartitioning` is untouched. Its `require` stays as a guard, and a new unit test pins it. ### Why are the changes needed? Under `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`, `KeyedPartitioning.createShuffleSpec` projects each member of a `PartitioningCollection` onto *its own* join-key subset and drops the duplicate keys that projection creates. The members of the resulting `ShuffleSpecCollection` can therefore end up with different `numPartitions`. `EnsureRequirements` then asks the collection for a shuffle template, and `ShuffleSpecCollection.createPartitioning` requires all members to agree: ``` java.lang.IllegalArgumentException: requirement failed: expected all specs in the collection to have the same number of partitions ``` so planning fails outright. With `items` partitioned by `[identity(id), identity(arrive_time)]`, one row per split, `purchases` unpartitioned, and `v2BucketingShuffleEnabled=true`, `partiallyClusteredDistribution=false`, `allowKeysSubsetOfPartitionKeys=true`: ```sql SELECT /*+ MERGE(i, p) */ id, t1, t2, i.price AS purchase_price, p.price AS sale_price FROM (SELECT id, arrive_time AS t1, arrive_time AS t2, price FROM testcat.ns.items) i JOIN testcat.ns.purchases p ON i.id = p.item_id AND i.t1 = p.time ``` Selecting `arrive_time` twice under two aliases makes the alias cross-product produce members that cover different numbers of join keys, which is where the counts diverge. The collection cannot answer that question locally. `isCompatibleWith` succeeds when *any* member matches, so the collection alone never said which member the two sides agreed on, and `createPartitioning` fell back to `specs.head`, whichever the alias cross-product enumerated first. Narrowing the collection to its finest members would satisfy the `require`, but it would still be a guess: the right member is the one the *other* side matched, and that is only visible in `EnsureRequirements`. ### Does this PR introduce _any_ user-facing change? Yes. The query above failed to plan and now runs, producing one shuffle and the right rows. The `joinKeyPositions` half is user-facing too. I originally wrote here that it was latent, on the grounds that a cogroup's grouping key is synthesized so neither side stays keyed. sunchao pointed out that this is only true of the Scala `CoGroupExec`, whose key comes from an `AppendColumns` that no `KeyedPartitioning` satisfies. A Pandas or Arrow cogroup groups on real columns, so two keyed children do reach the per-child branch, and this suite already had a `FlatMapCoGroupsInPandasExec` test with two of them. So: with two keyed children whose partition expressions are laid out differently, the second side is handed the first side's positions and ends up grouped on its other partition column. The plan test below reproduces it and fails without the fix, reporting `List(Some(List(1)), Some(List(1)))` where `List(Some(List(1)), Some(List(0)))` is right. I have not built an end-to-end query for it. The only part that stays latent is the three-or-more clustered children case, which no operator has. ### How was this patch tested? Four new tests. Each was measured against the same commit with only the `EnsureRequirements` change reverted. | test | on base | |---|---| | `KeyGroupedPartitioningSuite`: both sides of the join land on the same collection member | fails with the `require` above | | `EnsureRequirementsSuite`: the re-shuffled side lands on the member the keyed side was matched on | fails with the `require` above | | `EnsureRequirementsSuite`: pushed-down positions index into the child's own partition expressions (a Pandas cogroup over two keyed children) | fails | | `ShuffleSpecSuite`: a collection whose members cover different key subsets disagrees | passes, by design | The last one pins the guard rather than the fix. It asserts that the members disagree on purpose, that every one of them stays available for `isCompatibleWith`, and that asking the collection for a single partitioning throws. That turns the `require` from untested prose into a pinned contract, which matters now that the method has no production caller. Green: `ShuffleSpecSuite`, `EnsureRequirementsSuite`, `KeyGroupedPartitioningSuite`, 202 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes apache#58527 from peter-toth/SPARK-59080-shufflespec-numpartitions. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 4605662)
… SPJ pushdown and the re-shuffle ### Backport of #58527 to `branch-4.3` Clean cherry-pick of `4605662b6e4`, no tailoring. Rebased onto `dacfdba1818`, ulysses-you's follow-up that fixes the subset-join-key test on this branch, as he asked on #58279. Green on this branch: `ShuffleSpecSuite` 18 tests, and `EnsureRequirementsSuite` + `KeyGroupedPartitioningSuite` 195 tests, all passing. `dev/lint-scala` is clean. --- ### What changes were proposed in this pull request? `EnsureRequirements` stops asking a `ShuffleSpecCollection` for a single answer. It resolves the one member the matched children agreed on, preferring the finest when several qualify, and uses that member to build a re-shuffled child's partitioning. - `flattenSpec` replaces the head read. It recurses, because `ShuffledJoin.outputPartitioning` builds `PartitioningCollection.fromPartitionings(Seq(left, right))` for an inner join, so a chain of same-key joins nests collections. - The chosen member has to be compatible with every matched child. When no member is, there is no shared layout, so every child takes the ordinary shuffle. Reaching that needs three or more clustered children, and no operator has three today. - The `joinKeyPositions` pushed into a compatible child now come from that child's own matching member, because they index into that child's partition expressions. - `ShuffleSpecCollection.createPartitioning` is untouched. Its `require` stays as a guard, and a new unit test pins it. ### Why are the changes needed? Under `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`, `KeyedPartitioning.createShuffleSpec` projects each member of a `PartitioningCollection` onto *its own* join-key subset and drops the duplicate keys that projection creates. The members of the resulting `ShuffleSpecCollection` can therefore end up with different `numPartitions`. `EnsureRequirements` then asks the collection for a shuffle template, and `ShuffleSpecCollection.createPartitioning` requires all members to agree: ``` java.lang.IllegalArgumentException: requirement failed: expected all specs in the collection to have the same number of partitions ``` so planning fails outright. With `items` partitioned by `[identity(id), identity(arrive_time)]`, one row per split, `purchases` unpartitioned, and `v2BucketingShuffleEnabled=true`, `partiallyClusteredDistribution=false`, `allowKeysSubsetOfPartitionKeys=true`: ```sql SELECT /*+ MERGE(i, p) */ id, t1, t2, i.price AS purchase_price, p.price AS sale_price FROM (SELECT id, arrive_time AS t1, arrive_time AS t2, price FROM testcat.ns.items) i JOIN testcat.ns.purchases p ON i.id = p.item_id AND i.t1 = p.time ``` Selecting `arrive_time` twice under two aliases makes the alias cross-product produce members that cover different numbers of join keys, which is where the counts diverge. The collection cannot answer that question locally. `isCompatibleWith` succeeds when *any* member matches, so the collection alone never said which member the two sides agreed on, and `createPartitioning` fell back to `specs.head`, whichever the alias cross-product enumerated first. Narrowing the collection to its finest members would satisfy the `require`, but it would still be a guess: the right member is the one the *other* side matched, and that is only visible in `EnsureRequirements`. ### Does this PR introduce _any_ user-facing change? Yes. The query above failed to plan and now runs, producing one shuffle and the right rows. The `joinKeyPositions` half is user-facing too. I originally wrote here that it was latent, on the grounds that a cogroup's grouping key is synthesized so neither side stays keyed. sunchao pointed out that this is only true of the Scala `CoGroupExec`, whose key comes from an `AppendColumns` that no `KeyedPartitioning` satisfies. A Pandas or Arrow cogroup groups on real columns, so two keyed children do reach the per-child branch, and this suite already had a `FlatMapCoGroupsInPandasExec` test with two of them. So: with two keyed children whose partition expressions are laid out differently, the second side is handed the first side's positions and ends up grouped on its other partition column. The plan test below reproduces it and fails without the fix, reporting `List(Some(List(1)), Some(List(1)))` where `List(Some(List(1)), Some(List(0)))` is right. I have not built an end-to-end query for it. The only part that stays latent is the three-or-more clustered children case, which no operator has. ### How was this patch tested? Four new tests. Each was measured against the same commit with only the `EnsureRequirements` change reverted. | test | on base | |---|---| | `KeyGroupedPartitioningSuite`: both sides of the join land on the same collection member | fails with the `require` above | | `EnsureRequirementsSuite`: the re-shuffled side lands on the member the keyed side was matched on | fails with the `require` above | | `EnsureRequirementsSuite`: pushed-down positions index into the child's own partition expressions (a Pandas cogroup over two keyed children) | fails | | `ShuffleSpecSuite`: a collection whose members cover different key subsets disagrees | passes, by design | The last one pins the guard rather than the fix. It asserts that the members disagree on purpose, that every one of them stays available for `isCompatibleWith`, and that asking the collection for a single partitioning throws. That turns the `require` from untested prose into a pinned contract, which matters now that the method has no production caller. Green: `ShuffleSpecSuite`, `EnsureRequirementsSuite`, `KeyGroupedPartitioningSuite`, 202 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes #58564 from peter-toth/SPARK-59080-shufflespec-numpartitions-4.3. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
… SPJ pushdown and the re-shuffle ### Backport of #58527 to `branch-4.2` Cherry-pick of `4605662b6e4` with two test-only adjustments; the production change applies unchanged. - The subset opt-in is named `spark.sql.sources.v2.bucketing.allowJoinKeysSubsetOfPartitionKeys.enabled` on this branch, so the four `V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS` references in the new tests become `V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS`. - `PartitioningCollection.fromPartitionings` does not exist here, and neither does the invariant it maintains: this branch's `PartitioningCollection` only requires its members to agree on `numPartitions`, with no shared `partitionKeys` reference to intern. The two fixtures use the plain constructor, which the members' equal key lists make sound. Green on this branch: `ShuffleSpecSuite` 13 tests, `EnsureRequirementsSuite` + `KeyGroupedPartitioningSuite` 162 tests, all passing. `dev/lint-scala` is clean. --- ### What changes were proposed in this pull request? `EnsureRequirements` stops asking a `ShuffleSpecCollection` for a single answer. It resolves the one member the matched children agreed on, preferring the finest when several qualify, and uses that member to build a re-shuffled child's partitioning. - `flattenSpec` replaces the head read. It recurses, because `ShuffledJoin.outputPartitioning` builds `PartitioningCollection.fromPartitionings(Seq(left, right))` for an inner join, so a chain of same-key joins nests collections. - The chosen member has to be compatible with every matched child. When no member is, there is no shared layout, so every child takes the ordinary shuffle. Reaching that needs three or more clustered children, and no operator has three today. - The `joinKeyPositions` pushed into a compatible child now come from that child's own matching member, because they index into that child's partition expressions. - `ShuffleSpecCollection.createPartitioning` is untouched. Its `require` stays as a guard, and a new unit test pins it. ### Why are the changes needed? Under `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`, `KeyedPartitioning.createShuffleSpec` projects each member of a `PartitioningCollection` onto *its own* join-key subset and drops the duplicate keys that projection creates. The members of the resulting `ShuffleSpecCollection` can therefore end up with different `numPartitions`. `EnsureRequirements` then asks the collection for a shuffle template, and `ShuffleSpecCollection.createPartitioning` requires all members to agree: ``` java.lang.IllegalArgumentException: requirement failed: expected all specs in the collection to have the same number of partitions ``` so planning fails outright. With `items` partitioned by `[identity(id), identity(arrive_time)]`, one row per split, `purchases` unpartitioned, and `v2BucketingShuffleEnabled=true`, `partiallyClusteredDistribution=false`, `allowKeysSubsetOfPartitionKeys=true`: ```sql SELECT /*+ MERGE(i, p) */ id, t1, t2, i.price AS purchase_price, p.price AS sale_price FROM (SELECT id, arrive_time AS t1, arrive_time AS t2, price FROM testcat.ns.items) i JOIN testcat.ns.purchases p ON i.id = p.item_id AND i.t1 = p.time ``` Selecting `arrive_time` twice under two aliases makes the alias cross-product produce members that cover different numbers of join keys, which is where the counts diverge. The collection cannot answer that question locally. `isCompatibleWith` succeeds when *any* member matches, so the collection alone never said which member the two sides agreed on, and `createPartitioning` fell back to `specs.head`, whichever the alias cross-product enumerated first. Narrowing the collection to its finest members would satisfy the `require`, but it would still be a guess: the right member is the one the *other* side matched, and that is only visible in `EnsureRequirements`. ### Does this PR introduce _any_ user-facing change? Yes. The query above failed to plan and now runs, producing one shuffle and the right rows. The `joinKeyPositions` half is user-facing too. I originally wrote here that it was latent, on the grounds that a cogroup's grouping key is synthesized so neither side stays keyed. sunchao pointed out that this is only true of the Scala `CoGroupExec`, whose key comes from an `AppendColumns` that no `KeyedPartitioning` satisfies. A Pandas or Arrow cogroup groups on real columns, so two keyed children do reach the per-child branch, and this suite already had a `FlatMapCoGroupsInPandasExec` test with two of them. So: with two keyed children whose partition expressions are laid out differently, the second side is handed the first side's positions and ends up grouped on its other partition column. The plan test below reproduces it and fails without the fix, reporting `List(Some(List(1)), Some(List(1)))` where `List(Some(List(1)), Some(List(0)))` is right. I have not built an end-to-end query for it. The only part that stays latent is the three-or-more clustered children case, which no operator has. ### How was this patch tested? Four new tests. Each was measured against the same commit with only the `EnsureRequirements` change reverted. | test | on base | |---|---| | `KeyGroupedPartitioningSuite`: both sides of the join land on the same collection member | fails with the `require` above | | `EnsureRequirementsSuite`: the re-shuffled side lands on the member the keyed side was matched on | fails with the `require` above | | `EnsureRequirementsSuite`: pushed-down positions index into the child's own partition expressions (a Pandas cogroup over two keyed children) | fails | | `ShuffleSpecSuite`: a collection whose members cover different key subsets disagrees | passes, by design | The last one pins the guard rather than the fix. It asserts that the members disagree on purpose, that every one of them stays available for `isCompatibleWith`, and that asking the collection for a single partitioning throws. That turns the `require` from untested prose into a pinned contract, which matters now that the method has no production caller. Green: `ShuffleSpecSuite`, `EnsureRequirementsSuite`, `KeyGroupedPartitioningSuite`, 202 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes #58565 from peter-toth/SPARK-59080-shufflespec-numpartitions-4.2. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
…not by enumeration order ### What changes were proposed in this pull request? A `PartitioningCollection` offers several layouts, and which one is right depends on what the other side matched. Four places in `EnsureRequirements` decided it by enumeration order instead, and none of them could see both sides: 1. the per-child branch that shuffles a child read `specs.head` through `ShuffleSpecCollection.createPartitioning` — fixed by #58527; 2. the child ranking, `finalCandidateSpecs.values.maxBy(_.numPartitions)`, reads the collection's `numPartitions`, which is `specs.head`'s; 3. `SinglePartitionShuffleSpec.isCompatibleWith` reads `other.numPartitions`, the same head; 4. `createKeyedShuffleSpec` collapses a collection to one member with `collectFirst`, per side, before either side has seen the other. This PR is 2, 3 and 4. 2 and 4 were raised by LuciferYang in review of #58527. **The type split.** A new `LeafShuffleSpec` sub-trait carries the two single-member methods, and `ShuffleSpec` is sealed: ``` ShuffleSpec (sealed) isCompatibleWith, canCreatePartitioning, flatten <- LeafShuffleSpec + numPartitions, createPartitioning SinglePartitionShuffleSpec, RangeShuffleSpec, HashShuffleSpec, NullAwareHashShuffleSpec, CoalescedHashShuffleSpec, KeyedShuffleSpec, ShufflePartitionIdPassThroughSpec <- ShuffleSpecCollection ``` `ShuffleSpecCollection` loses `createPartitioning`, which had no production caller after #58527, and `numPartitions`, which had two consumers wanting two different aggregations: the child ranking wants the max over the members, and `SinglePartitionShuffleSpec.isCompatibleWith` wants it to hold for every one. Both now say so at the call site. `flatten` replaces a private helper #58527 added to `EnsureRequirements`. **The pairing.** `createKeyedShuffleSpecs` returns every member's spec, and `checkKeyGroupCompatible` picks the pair that agrees on the keys and offers the most parallelism, with ties going to a pair both children report as it stands. The ranking reads only the side whose keys survive on the join types where the merge drops the other's, and two cases where it stays a heuristic are named in the code comment. **One thing the pairing needed, which is a small fix of its own.** `joinKeyPositions` could not answer "is this spec the child's own layout", because an identity projection still went through `toGrouped` and reported `Some(0 until n)` even where nothing changed. It now reports `None` there. That also drops a `GroupPartitionsExec` that projects nothing, since the per-child alignment path decides whether to wrap a child by matching on `Some(positions)`. The reasoning behind each choice is in the code comments rather than here. ### Why are the changes needed? `isCompatibleWith` succeeds when *any* member matches, which is the whole point of the type. The two single-member questions are not wrong in the same way. `createPartitioning` has no local answer at all, since the right member is the one the other side matched. `numPartitions` has an answer, but two different ones depending on who asks. For the pairing the cost is concrete: two sides pick members that do not agree, `checkKeyGroupCompatible` declines, and the join loses the storage-partitioned pushdown even though a pairing existed. ### Does this PR introduce _any_ user-facing change? **Yes**, three of them. **Plans change out of the box.** `spark.sql.sources.v2.bucketing.pushPartValues.enabled` defaults to **true**, and it is all the push branch in `checkKeyGroupCompatible` needs. Measured with no config set at all, on the key-position-swap shape the tests use: taking each side's first member makes the join decline, `bestSpecOpt` is then empty because a keyed spec cannot be a shuffle reference without `v2BucketingShuffleEnabled` (which *is* off by default), and both children are shuffled onto the default 200 partitions. With the pairing the join runs with no shuffle and two grouping nodes. The ranking and the `SinglePartitionShuffleSpec` change are no-ops unless `allowKeysSubsetOfPartitionKeys` is on, since `PartitioningCollection` requires its members to agree on `numPartitions` and only that config makes a spec report a different count from its partitioning's. **Sealing `ShuffleSpec` is a source break** for out-of-tree code that extends it, which has to move to `LeafShuffleSpec`, and removing the collection's two methods is a binary break for old bytecode. Catalyst is in the `defaultExcludes` section of `MimaExcludes` and treated as internals, so CI does not flag it and no excludes are needed, the same as for the `KeyGrouped*` to `Keyed*` rename. Raised by LuciferYang. **A new failure mode.** `reducersBothWays` now runs on a pair the old code could not form, so a connector whose `Reducer.resultType()` violates the `r(f1(x)) = f2(x)` contract can raise `storagePartitionJoinIncompatibleReducedTypesError` where the join used to fall back silently. That is what the error exists to catch, but it is newly reachable. ### How was this patch tested? Eleven new tests, three existing ones re-anchored. **A plain fail-on-base measurement is not available**: the tests name `LeafShuffleSpec`, so they cannot compile against the parent commit. Instead each old decision was reinstated under the new types, one at a time: | old decision reinstated | tests that fail | |---|---| | `maxBy(_.flatten.head.numPartitions)` | the child ranking one | | per-side `(leftCandidates.head, rightCandidates.head)` | 5 | | `agreeingPairs.headOption` | 2 | | ranking without the tie-break | the tie one | | `max` for every join type | the filtered one-sided one | | `isCompatibleWith(specs.head)` | the single-partition one | | reporting `Some(joinKeyPositions)` unconditionally | 4 | Eight of the eleven discriminate; the other three pin a contract that holds either way and say so in their own comments. Two changes have no test, both deliberately. The early `return None` when no pair agrees is observationally identical to carrying the head pair to the end, so the only thing to assert on is a log line that no longer appears. And `rank`'s `RightOuter` arm shares a test with the `LeftOuter` one, since the two are mirror images over the same fixture. Green: `ShuffleSpecSuite`, `DistributionSuite`, `EnsureRequirementsSuite`, `ValidateRequirementsSuite`, `KeyGroupedPartitioningSuite`, `PlannerSuite`, `GroupPartitionsExecSuite`, `ProjectedOrderingAndPartitioningSuite`, 430 tests, plus both `TPCDS*PlanStabilitySuite`. `dev/lint-scala` is clean. One hunk is a 184-line re-indent, from dropping a guard that became provably true. Read it with `git diff -w`, where it is four added and three removed lines. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes #58531 from peter-toth/SPARK-59256-shufflespec-collection-split. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
…not by enumeration order ### What changes were proposed in this pull request? A `PartitioningCollection` offers several layouts, and which one is right depends on what the other side matched. Four places in `EnsureRequirements` decided it by enumeration order instead, and none of them could see both sides: 1. the per-child branch that shuffles a child read `specs.head` through `ShuffleSpecCollection.createPartitioning` — fixed by #58527; 2. the child ranking, `finalCandidateSpecs.values.maxBy(_.numPartitions)`, reads the collection's `numPartitions`, which is `specs.head`'s; 3. `SinglePartitionShuffleSpec.isCompatibleWith` reads `other.numPartitions`, the same head; 4. `createKeyedShuffleSpec` collapses a collection to one member with `collectFirst`, per side, before either side has seen the other. This PR is 2, 3 and 4. 2 and 4 were raised by LuciferYang in review of #58527. **The type split.** A new `LeafShuffleSpec` sub-trait carries the two single-member methods, and `ShuffleSpec` is sealed: ``` ShuffleSpec (sealed) isCompatibleWith, canCreatePartitioning, flatten <- LeafShuffleSpec + numPartitions, createPartitioning SinglePartitionShuffleSpec, RangeShuffleSpec, HashShuffleSpec, NullAwareHashShuffleSpec, CoalescedHashShuffleSpec, KeyedShuffleSpec, ShufflePartitionIdPassThroughSpec <- ShuffleSpecCollection ``` `ShuffleSpecCollection` loses `createPartitioning`, which had no production caller after #58527, and `numPartitions`, which had two consumers wanting two different aggregations: the child ranking wants the max over the members, and `SinglePartitionShuffleSpec.isCompatibleWith` wants it to hold for every one. Both now say so at the call site. `flatten` replaces a private helper #58527 added to `EnsureRequirements`. **The pairing.** `createKeyedShuffleSpecs` returns every member's spec, and `checkKeyGroupCompatible` picks the pair that agrees on the keys and offers the most parallelism, with ties going to a pair both children report as it stands. The ranking reads only the side whose keys survive on the join types where the merge drops the other's, and two cases where it stays a heuristic are named in the code comment. **One thing the pairing needed, which is a small fix of its own.** `joinKeyPositions` could not answer "is this spec the child's own layout", because an identity projection still went through `toGrouped` and reported `Some(0 until n)` even where nothing changed. It now reports `None` there. That also drops a `GroupPartitionsExec` that projects nothing, since the per-child alignment path decides whether to wrap a child by matching on `Some(positions)`. The reasoning behind each choice is in the code comments rather than here. ### Why are the changes needed? `isCompatibleWith` succeeds when *any* member matches, which is the whole point of the type. The two single-member questions are not wrong in the same way. `createPartitioning` has no local answer at all, since the right member is the one the other side matched. `numPartitions` has an answer, but two different ones depending on who asks. For the pairing the cost is concrete: two sides pick members that do not agree, `checkKeyGroupCompatible` declines, and the join loses the storage-partitioned pushdown even though a pairing existed. ### Does this PR introduce _any_ user-facing change? **Yes**, three of them. **Plans change out of the box.** `spark.sql.sources.v2.bucketing.pushPartValues.enabled` defaults to **true**, and it is all the push branch in `checkKeyGroupCompatible` needs. Measured with no config set at all, on the key-position-swap shape the tests use: taking each side's first member makes the join decline, `bestSpecOpt` is then empty because a keyed spec cannot be a shuffle reference without `v2BucketingShuffleEnabled` (which *is* off by default), and both children are shuffled onto the default 200 partitions. With the pairing the join runs with no shuffle and two grouping nodes. The ranking and the `SinglePartitionShuffleSpec` change are no-ops unless `allowKeysSubsetOfPartitionKeys` is on, since `PartitioningCollection` requires its members to agree on `numPartitions` and only that config makes a spec report a different count from its partitioning's. **Sealing `ShuffleSpec` is a source break** for out-of-tree code that extends it, which has to move to `LeafShuffleSpec`, and removing the collection's two methods is a binary break for old bytecode. Catalyst is in the `defaultExcludes` section of `MimaExcludes` and treated as internals, so CI does not flag it and no excludes are needed, the same as for the `KeyGrouped*` to `Keyed*` rename. Raised by LuciferYang. **A new failure mode.** `reducersBothWays` now runs on a pair the old code could not form, so a connector whose `Reducer.resultType()` violates the `r(f1(x)) = f2(x)` contract can raise `storagePartitionJoinIncompatibleReducedTypesError` where the join used to fall back silently. That is what the error exists to catch, but it is newly reachable. ### How was this patch tested? Eleven new tests, three existing ones re-anchored. **A plain fail-on-base measurement is not available**: the tests name `LeafShuffleSpec`, so they cannot compile against the parent commit. Instead each old decision was reinstated under the new types, one at a time: | old decision reinstated | tests that fail | |---|---| | `maxBy(_.flatten.head.numPartitions)` | the child ranking one | | per-side `(leftCandidates.head, rightCandidates.head)` | 5 | | `agreeingPairs.headOption` | 2 | | ranking without the tie-break | the tie one | | `max` for every join type | the filtered one-sided one | | `isCompatibleWith(specs.head)` | the single-partition one | | reporting `Some(joinKeyPositions)` unconditionally | 4 | Eight of the eleven discriminate; the other three pin a contract that holds either way and say so in their own comments. Two changes have no test, both deliberately. The early `return None` when no pair agrees is observationally identical to carrying the head pair to the end, so the only thing to assert on is a log line that no longer appears. And `rank`'s `RightOuter` arm shares a test with the `LeftOuter` one, since the two are mirror images over the same fixture. Green: `ShuffleSpecSuite`, `DistributionSuite`, `EnsureRequirementsSuite`, `ValidateRequirementsSuite`, `KeyGroupedPartitioningSuite`, `PlannerSuite`, `GroupPartitionsExecSuite`, `ProjectedOrderingAndPartitioningSuite`, 430 tests, plus both `TPCDS*PlanStabilitySuite`. `dev/lint-scala` is clean. One hunk is a 184-line re-indent, from dropping a guard that became provably true. Read it with `git diff -w`, where it is four added and three removed lines. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes #58531 from peter-toth/SPARK-59256-shufflespec-collection-split. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 9d33be7) Signed-off-by: Peter Toth <peter.toth@gmail.com>
What changes were proposed in this pull request?
EnsureRequirementsstops asking aShuffleSpecCollectionfor a single answer. It resolves the one member the matched children agreed on, preferring the finest when several qualify, and uses that member to build a re-shuffled child's partitioning.flattenSpecreplaces the head read. It recurses, becauseShuffledJoin.outputPartitioningbuildsPartitioningCollection.fromPartitionings(Seq(left, right))for an inner join, so a chain of same-key joins nests collections.joinKeyPositionspushed into a compatible child now come from that child's own matching member, because they index into that child's partition expressions.ShuffleSpecCollection.createPartitioningis untouched. Itsrequirestays as a guard, and a new unit test pins it.Why are the changes needed?
Under
spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled,KeyedPartitioning.createShuffleSpecprojects each member of aPartitioningCollectiononto its own join-key subset and drops the duplicate keys that projection creates. The members of the resultingShuffleSpecCollectioncan therefore end up with differentnumPartitions.EnsureRequirementsthen asks the collection for a shuffle template, andShuffleSpecCollection.createPartitioningrequires all members to agree:so planning fails outright. With
itemspartitioned by[identity(id), identity(arrive_time)], one row per split,purchasesunpartitioned, andv2BucketingShuffleEnabled=true,partiallyClusteredDistribution=false,allowKeysSubsetOfPartitionKeys=true:Selecting
arrive_timetwice under two aliases makes the alias cross-product produce members that cover different numbers of join keys, which is where the counts diverge.The collection cannot answer that question locally.
isCompatibleWithsucceeds when any member matches, so the collection alone never said which member the two sides agreed on, andcreatePartitioningfell back tospecs.head, whichever the alias cross-product enumerated first. Narrowing the collection to its finest members would satisfy therequire, but it would still be a guess: the right member is the one the other side matched, and that is only visible inEnsureRequirements.Does this PR introduce any user-facing change?
Yes. The query above failed to plan and now runs, producing one shuffle and the right rows.
The
joinKeyPositionshalf is user-facing too. I originally wrote here that it was latent, on the grounds that a cogroup's grouping key is synthesized so neither side stays keyed. @sunchao pointed out that this is only true of the ScalaCoGroupExec, whose key comes from anAppendColumnsthat noKeyedPartitioningsatisfies. A Pandas or Arrow cogroup groups on real columns, so two keyed children do reach the per-child branch, and this suite already had aFlatMapCoGroupsInPandasExectest with two of them.So: with two keyed children whose partition expressions are laid out differently, the second side is handed the first side's positions and ends up grouped on its other partition column. The plan test below reproduces it and fails without the fix, reporting
List(Some(List(1)), Some(List(1)))whereList(Some(List(1)), Some(List(0)))is right. I have not built an end-to-end query for it.The only part that stays latent is the three-or-more clustered children case, which no operator has.
How was this patch tested?
Four new tests. Each was measured against the same commit with only the
EnsureRequirementschange reverted.KeyGroupedPartitioningSuite: both sides of the join land on the same collection memberrequireaboveEnsureRequirementsSuite: the re-shuffled side lands on the member the keyed side was matched onrequireaboveEnsureRequirementsSuite: pushed-down positions index into the child's own partition expressions (a Pandas cogroup over two keyed children)ShuffleSpecSuite: a collection whose members cover different key subsets disagreesThe last one pins the guard rather than the fix. It asserts that the members disagree on purpose, that every one of them stays available for
isCompatibleWith, and that asking the collection for a single partitioning throws. That turns therequirefrom untested prose into a pinned contract, which matters now that the method has no production caller.Green:
ShuffleSpecSuite,EnsureRequirementsSuite,KeyGroupedPartitioningSuite, 202 tests in all.dev/lint-scalais clean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code