Skip to content

[SPARK-59256][SQL] Choose a ShuffleSpecCollection member by pairing, not by enumeration order - #58531

Draft
peter-toth wants to merge 3 commits into
apache:masterfrom
peter-toth:SPARK-59256-shufflespec-collection-split
Draft

[SPARK-59256][SQL] Choose a ShuffleSpecCollection member by pairing, not by enumeration order#58531
peter-toth wants to merge 3 commits into
apache:masterfrom
peter-toth:SPARK-59256-shufflespec-collection-split

Conversation

@peter-toth

@peter-toth peter-toth commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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 PartitioningCollection offers several layouts, and which one is right depends on what the other side matched. Three 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 [SPARK-59080][SQL] Pick one ShuffleSpecCollection member for the SPJ pushdown and the re-shuffle #58527;
  2. the child ranking, finalCandidateSpecs.values.maxBy(_.numPartitions), reads the collection's numPartitions, which is specs.head's;
  3. createKeyedShuffleSpec collapses a collection to one member with collectFirst, 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 LeafShuffleSpec sub-trait carries the two single-member methods:

ShuffleSpec (sealed)             isCompatibleWith, canCreatePartitioning, flatten
  <- LeafShuffleSpec             + numPartitions, createPartitioning
       SinglePartitionShuffleSpec, RangeShuffleSpec, HashShuffleSpec,
       NullAwareHashShuffleSpec, CoalescedHashShuffleSpec, KeyedShuffleSpec,
       ShufflePartitionIdPassThroughSpec
  <- ShuffleSpecCollection

Sealing is what makes the split mean something: it is why flatten can return Seq[LeafShuffleSpec] with no fallback case, and it is checked — nothing outside partitioning.scala extends ShuffleSpec. flatten replaces a private helper #58527 added to EnsureRequirements, and lives on the hierarchy because that is where the totality comes from.

The two answers each caller now gives itself. The collection's createPartitioning had no production caller after #58527, and only a runtime require stood between a future caller and a wrong partitioning. numPartitions had two consumers wanting two different aggregations:

  • 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. createKeyedShuffleSpecs returns every member's spec, and checkKeyGroupCompatible picks the pair that agrees on the keys and offers the most parallelism. The derivation of leftSpec / rightSpec is 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 returns None, 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?

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, which is the same thing as not having one.

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?

No, under a default configuration. The three behaviour changes are gated differently, though, and it is worth not conflating them.

The ranking and the SinglePartitionShuffleSpec change are no-ops unless spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled 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 spark.sql.requireAllClusterKeysForCoPartition off — 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 SinglePartitionShuffleSpec answer is ValidateRequirements, through specs.tail.forall(_.isCompatibleWith(specs.head)); EnsureRequirements never reaches it, because SinglePartitionShuffleSpec.canCreatePartitioning is false and the collection's is a forall. And there the answer is about a layout nobody builds: a projected KeyedShuffleSpec's numPartitions is the count after the GroupPartitionsExec that EnsureRequirements would insert, while ValidateRequirements inserts 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, but exists does 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.

test old decision reinstated result
EnsureRequirementsSuite: a collection is ranked on its best member, not on whichever came first ranking reads flatten(spec).head fails
EnsureRequirementsSuite: the join is planned on a member pair, not on each side's first member (leftCandidates.head, rightCandidates.head) fails
ShuffleSpecSuite: a single-partition side matches a collection through any member isCompatibleWith(specs.head) fails
ShuffleSpecSuite: flattening reaches the members of a nested collection n/a — pins the helper's contract, which the parent's private version also had passes, by design

The first three assert their member counts as an ordered Seq rather than a Set, 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 PartitioningCollection forces 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 specs had a case asserting that a collection delegates to specs.head. The method is gone and the type now rejects the call. The same test's expected className in the UNSUPPORTED_CALL error moves from ShuffleSpec to LeafShuffleSpec, since the defaulted method moved. A dangling pre-split scaladoc for ShuffleSpec, stranded above ShufflePartitionIdPassThrough, is deleted rather than left to contradict the new trait doc.

CoalescedHashShuffleSpec.from is typed LeafShuffleSpec too — 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 of HashPartitioning.createShuffleSpec and NullAwareHashPartitioning.createShuffleSpec to their own spec types means that needs no cast.

Also corrected: GroupPartitionsExec carried the only explanation of why re-deriving a different collection member is safe, and it explained it in terms of createKeyedShuffleSpec's collectFirst, which no longer exists. It now gives the argument that actually holds — the members share their partitionKeys reference 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-scala is clean.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code

…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
peter-toth force-pushed the SPARK-59256-shufflespec-collection-split branch from e2d49bc to 5056713 Compare September 5, 2026 12:12
@peter-toth peter-toth changed the title [SPARK-59256][SQL] Remove createPartitioning and numPartitions from ShuffleSpecCollection [SPARK-59256][SQL] Choose a ShuffleSpecCollection member by pairing, not by enumeration order Sep 5, 2026
…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
peter-toth force-pushed the SPARK-59256-shufflespec-collection-split branch from 5056713 to 7b2789a Compare September 5, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant