[SPARK-59256][SQL] Choose a ShuffleSpecCollection member by pairing, not by enumeration order - #58531
Draft
peter-toth wants to merge 3 commits into
Draft
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.
… 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.
peter-toth
force-pushed
the
SPARK-59256-shufflespec-collection-split
branch
from
September 5, 2026 12:12
e2d49bc to
5056713
Compare
…not by enumeration order A `PartitioningCollection` offers several layouts, and which one is right depends on what the other side matched. Three places decided it by enumeration order instead, and none of them could see both sides. SPARK-59080 fixed the first, the per-child branch that shuffles a child. This addresses the other two, and makes the type say why a collection cannot answer alone. **The type split.** A new `LeafShuffleSpec` sub-trait carries `numPartitions` and `createPartitioning`, the seven concrete specs extend it, and `ShuffleSpecCollection` extends `ShuffleSpec` alone. `ShuffleSpec` is sealed, so the two kinds are the only kinds and `flatten` can return `Seq[LeafShuffleSpec]` without a fallback case. It moves from a private helper in `EnsureRequirements` onto the hierarchy itself, since the sealing is what makes it total. The collection's `createPartitioning` had no production caller after SPARK-59080, and only a runtime `require` stood between a future caller and a wrong partitioning. `numPartitions` had two consumers wanting two different aggregations, which is why the answer belongs to each caller: - `EnsureRequirements` ranks the children by parallelism to pick the reference layout. It wants the max, and it now takes it over the flattened members. - `SinglePartitionShuffleSpec.isCompatibleWith` asks whether the other side is a single partition. It wants `exists`, and it now unwraps a collection the way every other spec already does. **The pairing.** `createKeyedShuffleSpec` collapsed a collection to one member with `collectFirst`, per side, before either side had seen the other. Two sides can then pick members that do not agree, `checkKeyGroupCompatible` declines, and the join loses the storage-partitioned pushdown even though a pairing existed. It now returns every member's spec, and `checkKeyGroupCompatible` picks the pair that agrees on the keys and offers the most parallelism. One rule, not two: a pair that is co-partitioned as it stands is not preferred over a finer one that needs a grouping node. I could not measure a case where the two rules differ, so the extra step would have been an unmeasured refinement. Everything after that derivation is untouched and works on the chosen pair as before. When no pair agrees, any pair fails the same checks and the method returns `None`, as it did before. **What is and is not gated by a config.** The ranking and the `SinglePartitionShuffleSpec` change are no-ops unless `allowKeysSubsetOfPartitionKeys` is on: `PartitioningCollection` requires its members to agree on `numPartitions`, and every spec reports its own partitioning's count, so `max` equals `head` and `exists(_.numPartitions == 1)` equals `head.numPartitions == 1`. Only the projection that config turns on can make members disagree. The pairing reads no count, so that argument does not cover it. It changes the plan when a collection has two members that both qualify and sit at different clustering key positions, which needs `requireAllClusterKeysForCoPartition` off, since that check otherwise refuses a member that does not cover every key. That config is not the default either, so still no default-configuration plan change, but the two are gated differently and it is worth not conflating them. `CoalescedHashShuffleSpec.from` is typed `LeafShuffleSpec` too. It is structurally the one spec the coalesced spec was built from, and the declared return types of `HashPartitioning.createShuffleSpec` and `NullAwareHashPartitioning.createShuffleSpec` are refined to their own spec types so that needs no cast. Two smaller things the split brought out. `KeyedShuffleSpec.isCompatibleWith` read `other.numPartitions` inside a branch that had already matched `other` as a `KeyedShuffleSpec`, and now reads the narrowed value, which is the same object. `ShuffleSpecCollection` takes over the `require(specs.nonEmpty, ...)` that `numPartitions` used to carry, so the invariant survives the method that stated it. A dangling pre-split scaladoc for `ShuffleSpec`, stranded above `ShufflePartitionIdPassThrough`, is deleted rather than left to contradict the new trait doc.
peter-toth
force-pushed
the
SPARK-59256-shufflespec-collection-split
branch
from
September 5, 2026 13:24
5056713 to
7b2789a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes were proposed in this pull request?
Stacked on #58527 (SPARK-59080), which is the first two commits here. Please review only the last commit, and do not merge this before #58527. Draft until then.
A
PartitioningCollectionoffers several layouts, and which one is right depends on what the other side matched. Three places inEnsureRequirementsdecided it by enumeration order instead, and none of them could see both sides:specs.headthroughShuffleSpecCollection.createPartitioning— fixed by [SPARK-59080][SQL] Pick one ShuffleSpecCollection member for the SPJ pushdown and the re-shuffle #58527;finalCandidateSpecs.values.maxBy(_.numPartitions), reads the collection'snumPartitions, which isspecs.head's;createKeyedShuffleSpeccollapses a collection to one member withcollectFirst, per side, before either side has seen the other.This PR is 2 and 3, and it makes the type say why a collection cannot answer alone. Both were raised by @LuciferYang in review of #58527.
The type split. A new
LeafShuffleSpecsub-trait carries the two single-member methods:Sealing is what makes the split mean something: it is why
flattencan returnSeq[LeafShuffleSpec]with no fallback case, and it is checked — nothing outsidepartitioning.scalaextendsShuffleSpec.flattenreplaces a private helper #58527 added toEnsureRequirements, and lives on the hierarchy because that is where the totality comes from.The two answers each caller now gives itself. The collection's
createPartitioninghad no production caller after #58527, and only a runtimerequirestood between a future caller and a wrong partitioning.numPartitionshad two consumers wanting two different aggregations:EnsureRequirementsranks the children by parallelism to pick the reference layout. It wants the max, and it now takes it over the flattened members.SinglePartitionShuffleSpec.isCompatibleWithasks whether the other side is a single partition. It wants exists, and it now unwraps a collection the way every other spec already does.The pairing.
createKeyedShuffleSpecsreturns every member's spec, andcheckKeyGroupCompatiblepicks the pair that agrees on the keys and offers the most parallelism. The derivation ofleftSpec/rightSpecis the only thing that changes; everything after it works on the chosen pair as before. When no pair agrees, any pair fails the same checks and the method returnsNone, as it did before.The pick cannot be an independent per-side finest, as @LuciferYang noted: a side whose only members are coarse would then fail to pair. It is also one rule and not two — a pair that is co-partitioned as it stands is not preferred over a finer one that needs a grouping node. I could not measure a case where the two rules differ, so the extra step would have been an unmeasured refinement.
Why are the changes needed?
isCompatibleWithsucceeds when any member matches, which is the whole point of the type. The two single-member questions are not wrong in the same way.createPartitioninghas no local answer at all, since the right member is the one the other side matched.numPartitionshas an answer, but two different ones depending on who asks, which is the same thing as not having one.For the pairing the cost is concrete: two sides pick members that do not agree,
checkKeyGroupCompatibledeclines, and the join loses the storage-partitioned pushdown even though a pairing existed.Does this PR introduce any user-facing change?
No, under a default configuration. The three behaviour changes are gated differently, though, and it is worth not conflating them.
The ranking and the
SinglePartitionShuffleSpecchange are no-ops unlessspark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabledis on.PartitioningCollectionrequires its members to agree onnumPartitions, and every spec reports its own partitioning's count, somaxequalsheadandexists(_.numPartitions == 1)equalshead.numPartitions == 1. Only the projection that config turns on can make members disagree.The pairing reads no count, so that argument does not cover it. It changes the plan when a collection has two members that both qualify and sit at different clustering key positions, which needs
spark.sql.requireAllClusterKeysForCoPartitionoff — that check is what otherwise refuses a member not covering every key. Not the default either, so still no default-configuration change.One thing worth flagging rather than leaving to be found. The only production consumer of the changed
SinglePartitionShuffleSpecanswer isValidateRequirements, throughspecs.tail.forall(_.isCompatibleWith(specs.head));EnsureRequirementsnever reaches it, becauseSinglePartitionShuffleSpec.canCreatePartitioningis false and the collection's is aforall. And there the answer is about a layout nobody builds: a projectedKeyedShuffleSpec'snumPartitionsis the count after theGroupPartitionsExecthatEnsureRequirementswould insert, whileValidateRequirementsinserts nothing. So "the other side is a single partition" can be true of a child that physically has more. The head read had that same problem on a different arbitrary member, so this is a pre-existing conflation rather than a regression, butexistsdoes widen it from one member to any. I could not build a query that reaches it, and I did not add a test, because pinning an answer I am not sure is right would be worse than leaving it uncovered.How was this patch tested?
Four new tests. A plain fail-on-base measurement is not available here: the tests name
LeafShuffleSpec, so they cannot compile against the parent commit. Instead I reinstated each old decision under the new types, one at a time, and ran the tests against that.EnsureRequirementsSuite: a collection is ranked on its best member, not on whichever came firstflatten(spec).headEnsureRequirementsSuite: the join is planned on a member pair, not on each side's first member(leftCandidates.head, rightCandidates.head)ShuffleSpecSuite: a single-partition side matches a collection through any memberisCompatibleWith(specs.head)ShuffleSpecSuite: flattening reaches the members of a nested collectionThe first three assert their member counts as an ordered
Seqrather than aSet, so each pins its own premise: that the member a head read would take is the wrong one.I also wrote two further tests, for the pair ranking and for the pairing's config gating, and deleted them again: both passed with the old decision reinstated, so they measured nothing. A
PartitioningCollectionforces matching arity on its members, so "one member covers fewer clustering keys than another" needs a partition expression outside the clustering, which neither fixture built. Rather than ship tests that assert nothing, I am naming the gap: the ranking among agreeing pairs is not covered, only the fact that pairing happens at all.Deleted:
createPartitioning: other specshad a case asserting that a collection delegates tospecs.head. The method is gone and the type now rejects the call. The same test's expectedclassNamein theUNSUPPORTED_CALLerror moves fromShuffleSpectoLeafShuffleSpec, since the defaulted method moved. A dangling pre-split scaladoc forShuffleSpec, stranded aboveShufflePartitionIdPassThrough, is deleted rather than left to contradict the new trait doc.CoalescedHashShuffleSpec.fromis typedLeafShuffleSpectoo — it is structurally the one spec the coalesced spec was built from, which is the refactor's own argument applied to the one field that had escaped it. Refining the declared return types ofHashPartitioning.createShuffleSpecandNullAwareHashPartitioning.createShuffleSpecto their own spec types means that needs no cast.Also corrected:
GroupPartitionsExeccarried the only explanation of why re-deriving a different collection member is safe, and it explained it in terms ofcreateKeyedShuffleSpec'scollectFirst, which no longer exists. It now gives the argument that actually holds — the members share theirpartitionKeysreference and arity, and reducing reads the key values at their own types, so every member reduces to the same values.Green:
ShuffleSpecSuite,DistributionSuite,EnsureRequirementsSuite,ValidateRequirementsSuite,KeyGroupedPartitioningSuite,PlannerSuite,GroupPartitionsExecSuite, 323 tests in all.dev/lint-scalais clean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code