[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
peter-toth
force-pushed
the
SPARK-59256-shufflespec-collection-split
branch
3 times, most recently
from
September 6, 2026 09:44
6f5add3 to
cdfb690
Compare
…not by enumeration order A `PartitioningCollection` offers several layouts, and which one is right depends on what the other side matched. Four 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 three, 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. `flatten` moves from a private helper in `EnsureRequirements` onto the hierarchy and returns `Seq[LeafShuffleSpec]`. `ShuffleSpec` is sealed, which is not what makes `flatten` total - it is a virtual method with an implementation per kind. What sealing buys is that "every spec is one layout or a choice of layouts" becomes a guarantee, which is what makes that return type honest, and that `SinglePartitionShuffleSpec`'s two-case match compiles as exhaustive. 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, and it now answers `forall` over the members rather than reading the head's count. Not the `exists` the other specs use for a collection: they ask whether *some* member matches them and then plan on that member, while this asks a property of the child itself. The child is whichever member the other side picks, so a single partition has to hold for every one of them. Reading the head could claim a co-partitioning that does not hold, because a member's count is the count *after* the projection `createShuffleSpec` promises, not the child's own. **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. The pick cannot be an independent per-side finest: 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. The zero-node path is not what that would protect: it needs `left.outputPartitioning.exists(_ == leftPartitioning)`, which only an identity projection satisfies, and an identity projection reports the side's own physical count, which is maximal. `maxByOption` keeps the earliest element on a tie, and the earliest pair is the old per-side pick, so the pairing never loses the as-is path that pick used to find. What the ranking does trade, measured rather than hypothetical: between two pairs that both need grouping nodes, it takes 4 partitions with 2 empty ones on one side over 2 exactly-matched partitions. More parallelism for some padding. Everything after that derivation works on the chosen pair as before. One path changes reachability rather than behaviour: `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 a new failure mode. When no pair agrees, each side's first member is reported, which is what the per-side pick took, and the checks below fail on it exactly as they did before. **What changes at a default configuration, which is more than I first thought.** `pushPartValues` is on by default, and it is all the push branch in `checkKeyGroupCompatible` needs, so **the pairing changes plans out of the box**. Measured on the transform-difference shape with no config set at all: the per-side pick declines, `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. The pairing finds the agreeing pair and the join runs with no shuffle and two grouping nodes. The other two changes 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 the `forall` equals the head read. For the pairing to matter at all, a collection's members have to differ in a way `areKeysCompatible` can see, and there are two ways. `requireAllClusterKeysForCoPartition` off lets members cover different clustering keys. A transform difference does it with that requirement at its default, because a collection forces its members to agree on the key rows, and through those on the transforms' result types, but not on the transforms themselves. **Smaller things.** `CoalescedHashShuffleSpec.from` is typed `LeafShuffleSpec`, since it is structurally the one spec the coalesced spec was built from. Refining the declared return types of `HashPartitioning`, `NullAwareHashPartitioning` and `KeyedPartitioning`'s `createShuffleSpec` to their own spec types is what lets that be a type rather than a cast, and the `KeyedPartitioning` one deletes a production cast in `createKeyedShuffleSpecs` and two more in tests. `KeyedShuffleSpec.isCompatibleWith` read `other.numPartitions` inside a branch that had already matched `other` as a `KeyedShuffleSpec`, and now reads the narrowed value, the same object. `ShuffleSpecCollection` takes over the `require(specs.nonEmpty, ...)` that `numPartitions` used to carry, which is what keeps `flatten` non-empty and the ranking's `max` total. A dangling pre-split scaladoc for `ShuffleSpec`, stranded above `ShufflePartitionIdPassThrough`, is deleted rather than left to contradict the new trait doc. Planning cost: the per-side derivation is now linear in the number of satisfying members instead of stopping at the first, and the pairing is their cross product. Bounded in practice, since members of one collection reference different attributes and usually only one satisfies a given join's clustering.
peter-toth
force-pushed
the
SPARK-59256-shufflespec-collection-split
branch
from
September 6, 2026 15:05
cdfb690 to
4799ca4
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. Four 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;SinglePartitionShuffleSpec.isCompatibleWithreadsother.numPartitions, the same head;createKeyedShuffleSpeccollapses a collection to one member withcollectFirst, per side, before either side has seen the other.This PR is 2, 3 and 4, and it makes the type say why a collection cannot answer alone. 2 and 4 were raised by @LuciferYang in review of #58527.
The type split. A new
LeafShuffleSpecsub-trait carries the two single-member methods:flattenreplaces a private helper #58527 added toEnsureRequirementsand returnsSeq[LeafShuffleSpec]. Sealing is not what makes it total — it is a virtual method with an implementation per kind. What sealing buys is that "every spec is one layout or a choice of layouts" becomes a guarantee, which is what makes that return type honest, and thatSinglePartitionShuffleSpec's two-case match compiles as exhaustive. Nothing outsidepartitioning.scalaextendsShuffleSpec.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, and it now answersforallover the members. Deliberately not theexiststhe other specs use for a collection: they ask whether some member matches them and then plan on that member, while this asks a property of the child itself. The child is whichever member the other side picks, so a single partition has to hold for every one of them. The head read could claim a co-partitioning that does not hold, because a member's count is the count after the projectioncreateShuffleSpecpromises, not the child's own — a three-partition child whose head member projects to one used to answer "co-partitioned with a single partition".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.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. The zero-node path is not what such a preference would protect: it needs
left.outputPartitioning.exists(_ == leftPartitioning), which only an identity projection satisfies, and an identity projection reports the side's own physical count, which is maximal.maxByOptionkeeps the earliest element on a tie (Maximizedfolds with a strictgt), and the earliest pair is the old per-side pick, so the pairing never loses the as-is path that pick used to find.What the ranking does trade, and this one is measured rather than hypothetical: between two pairs that both need grouping nodes, it takes 4 partitions with 2 empty ones on one side over 2 exactly-matched partitions. More parallelism for some padding. If reviewers prefer the other side of that trade, the change is one
findbefore themaxByOption.When no pair agrees, each side's first member is reported — what the per-side pick took — and the checks below fail on it exactly as before, so the method returns
Noneas it did.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?
Yes, and correcting that is the main thing this revision does. I first wrote "no" here on the basis that both gating configs default to false.
spark.sql.sources.v2.bucketing.pushPartValues.enableddefaults to true, and it is all the push branch incheckKeyGroupCompatibleneeds, so the pairing changes plans out of the box.Measured on the transform-difference shape with no config set at all: taking each side's first member makes the join decline,
bestSpecOptis then empty because a keyed spec cannot be a shuffle reference withoutv2BucketingShuffleEnabled(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. It is an improvement, but it is a plan change for anyone with V2 bucketing on, not a no-op.The other two changes are no-ops unless
allowKeysSubsetOfPartitionKeysis on:PartitioningCollectionrequires its members to agree onnumPartitions, and every spec reports its own partitioning's count, somaxequalsheadand theforallequals the head read.For the pairing to matter at all, a collection's members have to differ in a way
areKeysCompatiblecan see, and there are two ways.spark.sql.requireAllClusterKeysForCoPartitionoff lets members cover different clustering keys. A transform difference does it with that requirement at its default, because a collection forces its members to agree on the key rows, and through those on the transforms' result types, but not on the transforms themselves.One path changes reachability rather than behaviour, and it is worth naming:
reducersBothWaysnow runs on a pair the old code could not form, so a connector whoseReducer.resultType()violates ther(f1(x)) = f2(x)contract can raisestoragePartitionJoinIncompatibleReducedTypesErrorwhere the join used to fall back silently. That is what the error exists to catch, but it is a new failure mode.How was this patch tested?
Eight new tests. A plain fail-on-base measurement is not available: 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 firstmaxBy(_.flatten.head.numPartitions)EnsureRequirementsSuite: the join is planned on a member pair, not on each side's first member(leftCandidates.head, rightCandidates.head)EnsureRequirementsSuite: the finest agreeing pair wins, not the first oneagreeingPairs.headOptionShuffleSpecSuite: a single-partition side needs every member of a collection to be oneisCompatibleWith(specs.head)EnsureRequirementsSuite: the pairing reaches a default configuration through a transform difference(leftCandidates.head, rightCandidates.head)EnsureRequirementsSuite: no agreeing pair leaves the join alone however many members each side has(leftCandidates.head, rightCandidates.head)ShuffleSpecSuite: an empty collection is rejected where it used to answer for its headnumPartitionsShuffleSpecSuite: flattening reaches the members of a nested collectionThe four discriminating ones 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.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 with it. A dangling pre-split scaladoc forShuffleSpec, stranded aboveShufflePartitionIdPassThrough, is deleted rather than left to contradict the new trait doc.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 holds — the members share their key rows and the types those rows were built with, so every one reduces to the same values, and only the key attribute is re-targeted.Smaller things in the same direction:
CoalescedHashShuffleSpec.fromis typedLeafShuffleSpec, since it is structurally the one spec the coalesced spec was built from. Refining the declared return types ofHashPartitioning,NullAwareHashPartitioning,KeyedPartitioningandPartitioningCollection'screateShuffleSpecto their own spec types is what lets that be a type rather than a cast, and it deletes threeasInstanceOfon the way, one of them in production code.Planning cost, for the record: the per-side derivation is now linear in the number of satisfying members instead of stopping at the first, and the pairing is their cross product. Bounded in practice, since members of one collection reference different attributes and usually only one satisfies a given join's clustering.
Green:
ShuffleSpecSuite,DistributionSuite,EnsureRequirementsSuite,ValidateRequirementsSuite,KeyGroupedPartitioningSuite,PlannerSuite,GroupPartitionsExecSuite, 327 tests in all.dev/lint-scalais clean.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code