Uh oh!
There was an error while loading. Please reload this page.
[SPARK-59057][SQL] Make KeyedPartitioning.isNarrowed mean actual key collapse, and rename it to isCollapsed - #58351
Conversation
peter-toth
commented
Aug 27, 2026
cc @dongjoon-hyun -- this is the follow-up to #58316 I mentioned there. One note on reading the diff: this PR is based on #58338 (SPARK-58974), which is still open, so the first two commits here belong to that PR. Only the last commit belongs to this one. I will rebase onto master once #58338 lands. |
dongjoon-hyun
commented
Aug 27, 2026
Thanks for the detailed writeup. I read through the main changes and the tests, and I think the core direction is right: the gate's comment described collapse while the flag recorded provenance, and One property worth noting, because it makes the new class doc read consistently: a projection preserves the partition count, so A few things below. 1. One of this PR's own producers breaks the doc rule the PR addsThe class doc gains:
but valisCollapsed= k.isCollapsed || projectedDistinctKeyCount < k.distinctKeyCountThe comment right above it says "There can be multiple 2. |
BTW,
|
…ingCollection Addresses review comments on apache#58351. `isCollapsed` describes the shared physical layout, not one member's naming of it: if any member of a `PartitioningCollection` is coarser than the layout it was derived from, an output partition really does cover several of the finer ones, whichever member's expressions name it. So members must agree on the flag. `PartitioningCollection.fromPartitionings` now normalizes it by OR, the same way it interns `partitionKeys` references, and `checkKeyedPartitioningInvariant` checks it. Both stay O(members) per level: one representative per member is enough, because every collection agrees on the flag internally by the same construction, and a nested collection is only descended into when it disagrees. That removes two loose ends at once. `GroupPartitionsExec.outputPartitioning` read the flag per member while `PartitioningPreservingUnaryExecNode` ORed across them, so the two producers disagreed; the flag is now computed once, outside the transform. And `EnsureRequirements`' `nonGrouped.find(_.groupedSatisfies(distribution))` accepts when a single member does, so a collection holding one coarsened and one plain member could reach a `GroupPartitionsExec` through the plain one. Uniformity closes that, and the class doc no longer has to carry either caveat. Also from the review: the intent at `KeyedShuffleSpec.createPartitioning` is now stated at the call site -- the shuffled side has no finer layout of its own, so inheriting the flag is deliberate conservatism -- and the comment above the gate is trimmed to what the class doc does not already say. New unit test that a coarsened member marks the whole collection, including through a nested one.
peter-toth
commented
Aug 27, 2026
Thanks for the review. You are right on (2), and it reverses a call I made earlier: I had left the members free to disagree, reasoning that each records its own side's history. But they describe one physical layout and share the key list, so if any side is coarse an output partition really does cover several finer ones, whichever member's expressions name it. And the So That answers (1) too: the flag is now computed once in On (3): deliberate conservatism, and I have written that at the call site. The shuffled side has no finer layout of its own -- its partitions are what a hash partitioning would give -- but the two sides are co-located on one key set, and a later grouping of that key set carries the coarsened side's risk. It can only add shuffles, never remove one. (4) done: the gate comment is trimmed to what the class doc does not already carry. Also added a unit test that a coarsened member marks the whole collection, including through a nested one. Pushed as a separate commit. |
| * For `OrderedDistribution`, `GroupPartitionsExec` must also sort the partition keys to meet the | ||
| * ordering requirement. | ||
| * | ||
| * == Coarsened Partitionings == |
There was a problem hiding this comment.
this is just collapsed right? is another new term necessary? is 'grouping' ok?
On that note, wdyt we have a quick glossary somewhere for the terms we are defining here?
- Key collapse: A projection or reduction maps two distinct old keys to the same new key.
(1,A), (1,B) -> 1, 1 - Grouping: Physically combines partitions that now share the same key.
1, 1, 2 -> 1, 2
Maybe its me but the javadoc is a bit hard to read , i think example is worth 1000 words
There was a problem hiding this comment.
Good point, and dongjoon made the same one about the section title -- "coarsened" was a second word for what isCollapsed already names. Unified on collapse, and the word is gone from all the touched files.
The section is now == Key Collapse == and opens with your two entries, with the examples:
- Key collapse: a projection or a reduction maps two keys that were distinct onto the same new key.
[(1, 'a'), (1, 'b'), (2, 'c')]projected onto the first position gives[1, 1, 2]: three distinct keys became two.isCollapsedrecords this. - Grouping:
GroupPartitionsExecphysically combines the partitions that share a key.[1, 1, 2]becomes[1, 2].isGroupedsays the keys are unique, however they got that way -- a source with natively unique keys reports it too.
Then one paragraph on why the pair matters: grouping after a collapse is what produces a partition larger than any the source declared, which is what needs the config; grouping without one only merges partitions that already shared a key, and needs no opt-in.
Writing it out also caught a real bug in the config doc, which was still phrased the old way: it claimed the config gates "a partitioning that was narrowed to a subset of its keys and whose keys are no longer distinct". Both halves are false now, and two of this PR's own tests prove it -- a subset projection that keeps every distinct key is not gated, and a reduction that drops no key position at all is. Fixed in the same wording as the class doc.
dongjoon-hyun
commented
Aug 28, 2026
Please rebase this once more to resolve the conflicts, @peter-toth . |
070705e to
9029519Comparepeter-toth
commented
Aug 28, 2026
Rebased on master and squashed to a single commit, so #58338 is out of the diff now that it has landed. The description is updated to match. Three other things went in with the rebase, each from a review pass over the change:
|
f12e744 to
a8e1a1aComparepeter-toth
commented
Aug 28, 2026
Two corrections after a closer look at how this relates to #58316. Reclassified SPARK-59057 as a Bug (it was an Improvement), affected versions 4.3.0 / 4.4.0 / 5.0.0. Provenance leaves the gate open in three ways, and the description now leads with them rather than with the false refusals:
Took the shuffle-template chain from #58316's end-to-end test, with the expectation these semantics call for. I had left it out because its @dongjoon-hyun your |
dongjoon-hyun
left a comment
There was a problem hiding this comment.
Findings from a review pass, most severe first: one behavioral issue (perf-only, never wrong results) and a few efficiency/maintainability notes. Details inline.
| // the keys -- once per member for the same answer. | ||
| val isCollapsed = PartitioningCollection.flatten(p).collectFirst { | ||
| case k: KeyedPartitioning => | ||
| k.isCollapsed || projectedDistinctKeyCount < k.distinctKeyCount |
There was a problem hiding this comment.
projectedDistinctKeyCount is taken from keyToPartitionIndices.sizebeforealignToExpectedKeys prunes to the join-agreed key set, so a collapse confined entirely to keys that the intersection then filters out still marks the output collapsed, even though every surviving partition maps 1:1 to a source partition.
Reachable on the reducer path with allowCompatibleTransforms + partitionFilter on and the subset opt-in off: ids [0, 4, 5] reduced onto buckets [0, 0, 1] with bucket 0 pruned by the other side gives 2 < 3, flagging an output whose only surviving partition was never merged. Since the flag is sticky, a later union + GROUP BY then hits the isCollapsed && !isGrouped gate and pays a shuffle the pre-PR code planned shuffle-free.
The new "filtering partition keys out is not a key collapse" test covers pruning without a collapse, but not this variant where the pruned keys are themselves the collapsed ones. Perf-only, never wrong results, but it contradicts the flag's documented meaning ("one partition here can stand for several of the original ones").
There was a problem hiding this comment.
Good catch, and it took two goes to get right.
The flag no longer compares key counts at all. groupedPartitionsTuple now asks the question directly, of the key groups it keeps: does any of them cover more than one of the child's own partition keys? Counting the child's keys rather than its partitions is what tells a collapse from a source reporting several splits per key, and asking it of the kept groups is what tells it from filtering. Your shape is a test now: identity(id) with ids 0, 4, 5 reduced onto buckets [0, 0, 1], bucket 0 dropped because the other side has no rows for it, so the only surviving key covers id 5 alone.
The second go: my first version read the answer off the partitions alignToExpectedKeys emits, which is blind under distributePartitions -- that branch spreads a group's splits over one partition each, so no partition ever holds two, and the whole mode reported nothing collapsed. That was a regression against the state you reviewed. Asking the key groups is right for both modes, and there is a second test for that direction, so the two tests are each other's control.
| // One member is enough: they share the `partitionKeys` reference and the flag, so they | ||
| // also share `distinctKeyCount`. Reading them all would force that count -- a pass over | ||
| // the keys -- once per member for the same answer. | ||
| val isCollapsed = PartitioningCollection.flatten(p).collectFirst { |
There was a problem hiding this comment.
outputPartitioning is a def, so this block re-runs on every call: flatten allocates a Seq just to collectFirst one KP, and k.distinctKeyCount is forced on a fresh child KP instance each time (DataSourceV2ScanExecBase.outputPartitioning rebuilds its KeyedPartitioning per call), so the lazy val never amortizes -- an O(#splits) distinct pass per consultation during EnsureRequirements/validation/AQE.
Computing the flag once inside the memoized groupedPartitionsTuple -- which already collectFirsts the same child KP and holds keyToPartitionIndices.size -- is semantically identical, and would also remove the unreachable .getOrElse(false) (groupedPartitions, forced just above via partitionKeys, throws when no KP exists) and the second, divergent first-KP lookup idiom in this file (line 148 uses plain collectFirst).
There was a problem hiding this comment.
Done. The flag is computed inside groupedPartitionsTuple, so outputPartitioning no longer forces distinctKeyCount on a child partitioning that DataSourceV2ScanExecBase rebuilt on every call. The child lookup is now a shared childKeyedPartitioning used by both the grouping and the flag, which removes the second idiom and the unreachable getOrElse(false) as well.
| // give -- so this is deliberate conservatism: the two sides are co-located on one key set, and | ||
| // a later grouping of that key set carries the collapsed side's risk. It can only add shuffles, | ||
| // never remove one. | ||
| KeyedPartitioning(newExpressions, partitioning.partitionKeys, partitioning.isGrouped, |
There was a problem hiding this comment.
Since isCollapsed is a constructor param, it participates in case-class equality and survives canonicalization. Two otherwise-identical exchanges -- one templated from a collapsed partitioning, one from an equivalent non-collapsed one (two independent joins over a shared subplan; the collection OR-normalization doesn't reach across them) -- no longer compare equal under sameResult, so ReuseExchangeAndSubquery shuffles the shared subplan twice where it previously reused the exchange.
Narrow configuration, and arguably defensible since the metadata genuinely differs (reusing a false-flag exchange where a true one was planned would re-launder the protection under AQE re-planning) -- but it deserves a conscious decision.
There was a problem hiding this comment.
Conscious decision: keep it. The metadata genuinely differs, and reusing an exchange planned from a non-collapsed partitioning where a collapsed one was expected would re-launder the protection under AQE re-planning, which is the failure this change exists to close. The cost needs the opt-in plus two independent joins over a shared subplan, and it is a lost reuse rather than a wrong answer.
| // a later grouping of that key set carries the collapsed side's risk. It can only add shuffles, | ||
| // never remove one. | ||
| KeyedPartitioning(newExpressions, partitioning.partitionKeys, partitioning.isGrouped, | ||
| partitioning.isCollapsed) |
There was a problem hiding this comment.
The inherited flag is also stamped on join types that expose only the shuffled side (LeftOuter/LeftSemi/LeftAnti/LeftExistence in ShuffledJoin.outputPartitioning); for semi/anti the output carries zero collapsed-side rows, yet inherits the flag forever. The created KP is grouped (canCreatePartitioning requires isGrouped), so the flag only bites after a union reintroduces duplicate keys -- exactly the case the class doc blesses as groupable without opt-in -- and the flag's documented contract is false for these partitionings.
The spec can't know the consuming join type, so if this is worth tightening, the place would be ShuffledJoin.outputPartitioning (clear the flag for LeftExistence). Fine to keep as the documented safe-direction conservatism, but worth noting the cost and the labeling mismatch.
There was a problem hiding this comment.
I would keep it, and I do not think the labelling is wrong. The flag describes key granularity, not which rows survive: a semi-join output laid out on the template's collapsed key space really does have partitions covering several of the source's keys, whichever side's rows flow through. Clearing it for LeftExistence would claim the layout is finer than it is, and the first union that reintroduces duplicate keys would then group genuinely coarse partitions without the opt-in.
| @transient partitionKeys: Seq[InternalRowComparableWrapper], | ||
| isGrouped: Boolean, | ||
| isNarrowed: Boolean = false) extends Expression with Partitioning with Unevaluable { | ||
| isCollapsed: Boolean = false) extends Expression with Partitioning with Unevaluable { |
There was a problem hiding this comment.
Keeping isCollapsed: Boolean = false as a defaulted parameter leaves the laundering vector this PR closes open for the next producer: a future operator that derives a KeyedPartitioning and calls the 3-arg constructor silently gets false and bypasses the gate with no compiler or test signal -- the same failure mode as SPARK-59026, and the wrong code looks identical to correct pre-existing code.
Only the companion apply (a provably-fresh source partitioning) actually relies on the default; dropping the default and passing isCollapsed = false explicitly there would make the compiler force every future producer to decide the flag.
There was a problem hiding this comment.
Done, the default is gone. In main code only the companion apply relied on it, and it now passes isCollapsed = false explicitly with a comment saying why a fresh source partitioning is the layout everything else is compared against.
It also caught a live instance of exactly the laundering you describe, in test code: DistributionAndOrderingSuiteBase.resolvePartitioning destructured the flag away with _ and rebuilt the partitioning without it. That is the concrete payoff for the compile-time change, so it is in the commit message.
| // The inherited flag is read from all inputs rather than from the key source alone. A | ||
| // `PartitioningCollection` normalizes it across its members, so the two agree today; reading | ||
| // all of them keeps this producer correct without depending on that. | ||
| val isCollapsed = kps.exists(_.isCollapsed) || |
There was a problem hiding this comment.
This inherited || projected distinct < source distinct predicate is now hand-rolled at three producer sites: here, KeyedPartitioning.createShuffleSpec, and GroupPartitionsExec.outputPartitioning, each with site-specific inputs and caveats. Given the PR's own observation that one producer laundering the flag is how the protection went missing, a shared helper on KeyedPartitioning, e.g.
defcollapsedAfterProjection(projectedDistinctKeyCount: Int):Boolean=
isCollapsed || projectedDistinctKeyCount < distinctKeyCountwould keep the correctness rule in one place instead of three files that must stay in sync.
There was a problem hiding this comment.
Added, as KeyedPartitioning.collapsesOnProjection. One correction to the shape you sketched: the isCollapsed || disjunct would be dead at both call sites, because each already carries the inherited flag outside it -- and AliasAwareOutputExpression has to, since it reads the flag from every input rather than only from the one whose keys it counts. So the helper is the count comparison alone, and its scaladoc says that the callers own the inherited term.
GroupPartitionsExec deliberately does not use it: it can answer the question exactly from the key groups it keeps, and the scaladoc points at that as the reference definition.
There was a problem hiding this comment.
Update after a rewrite, because the helper I named here no longer exists.
collapsesOnProjection is gone, and the rule it held moved inside KeyedPartitioning.project, which both producers now call for the whole projection. So the rule is in one place rather than the three you started from, and there is no helper left that a future producer could forget to call.
GroupPartitionsExec still answers the question its own way, from the key groups it keeps, for the reason in my earlier reply.
| // what the key count comparison catches. The gate in `groupedSatisfies` is bypassed while | ||
| // this config is on, so the flag decides nothing here today, but it travels with the | ||
| // partitioning and leaving a producer to launder it is how the protection went missing. | ||
| val projectedCollapsed = isCollapsed || projectedKeys.distinct.length < distinctKeyCount |
There was a problem hiding this comment.
Two O(n) distinct passes over the same list: projectedKeys.distinct.length here, then .toGrouped on the next statement re-runs partitionKeys.distinct.sorted over the same projectedKeys; for an ungrouped, uncollapsed source, forcing distinctKeyCount adds a third pass over the source keys. The block also runs when joinKeyPositions selects every position, where the comparison is tautologically false.
Materializing val d = projectedKeys.distinct once and building the grouped KP directly (same ordering source as toGrouped), and/or guarding on positions actually dropped, is semantically identical and cheaper. Planning-time only, so minor -- but it runs per createShuffleSpec call from EnsureRequirements/ValidateRequirements.
There was a problem hiding this comment.
Done. The count now comes off the grouped partitioning that toGrouped already builds, so there is one distinct where there were two, and the comparison is skipped entirely when joinKeyPositions selects every position -- which, as you note, cannot collapse anything.
There was a problem hiding this comment.
Update, and the intermediate state was worse than what I told you here. Extracting the projection into KeyedPartitioning.project brought the second distinct back for a while, because project counted the projected keys and toGrouped then deduped the same list again.
It is one pass now, and not a distinct at all. project walks the projected keys alongside the keys they came from and fills a projectedKey -> sourceKey map. A second, different source key on an existing projected key is the collapse, and it also means the projected keys are not unique, so the walk stops there. The source's own distinct count is not computed any more, and the source keys are never hashed, only compared where a projected key repeats.
Measured on the worst case for that test, a 50k-split partitioning with 25k distinct keys and 12-position keys, projected ten times over, 20 evaluations: 345 ms for the old provenance formula, 1291 ms for this one, 2137 ms for the two-distinct form. toGrouped after it also skips its own dedup when the keys are already unique.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
LGTM, thanks for the thorough follow-up. The group-based collapse detection resolves the pre-alignment counting concern cleanly in both directions -- the two new tests (the pruned-collapse shape and the distributePartitions mode) pin exactly the states that matter -- and removing the constructor default immediately catching a live laundering instance in DistributionAndOrderingSuiteBase is a nice payoff for the compile-time change. The keep decisions on exchange reuse and the shuffled-side flag inheritance both come with sound rationale.
Two non-blocking nits inline, plus one note that has no diff line to attach to: the reworked collapsesKeys branches (kept-groups path, getOrElse(Seq.empty) miss path, childIsCollapsed passthrough) are covered only end-to-end through SPJ planning. An operator-level test in GroupPartitionsExecSuite pinning the tuple's third element against a hand-built child partitioning would keep them covered if planner routing ever changes. Fine as a follow-up or not at all.
| if (expectedPartitionKeys.isDefined) { | ||
| alignToExpectedKeys(keyToPartitionIndices) | ||
| val (alignedPartitions, grouped) = alignToExpectedKeys(keyToPartitionIndices) | ||
| val keptGroups = expectedPartitionKeys.get.map { case (key, _) => |
There was a problem hiding this comment.
Nit, non-blocking: this re-derives the kept groups with its own keyToPartitionIndices.getOrElse lookups, duplicating the key matching alignToExpectedKeys just performed -- if that method's matching ever changes (normalization, emitting instead of dropping unexpected keys), the emitted partitions and the flag would silently be computed from different group selections. Computing the collapse bit inside alignToExpectedKeys in the same pass would keep one source of truth; short of that, fusing the map into expectedPartitionKeys.get.exists { case (key, _) => coversSeveralChildKeys(keyToPartitionIndices.getOrElse(key, Seq.empty)) } at least drops the intermediate Seq and short-circuits.
There was a problem hiding this comment.
This is your second form now, the exists over expectedPartitionKeys, so there is no intermediate Seq and it short-circuits.
I went your first way to begin with and computed the collapse bit inside alignToExpectedKeys, then moved it back out. Inside, the aligner had to grow a mutable buffer and a third tuple element that it never used itself, and its doc had to explain a return value that belonged to the caller.
What the current form gives up is the single lookup site you were after. keyToPartitionIndices.get(key) now appears twice in the file, and if the aligner's key matching changed, the two could drift. One thing did get better with the move: both branches of the scan take their groups from keyToPartitionIndices, so a padded expected key cannot produce an empty group at all, and the getOrElse(key, Seq.empty) plus its guard are gone.
| val projectedCollapsed = isCollapsed || | ||
| (joinKeyPositions.length < expressions.length && | ||
| collapsesOnProjection(grouped.numPartitions)) | ||
| val projectedPartitioning = grouped.copy(isCollapsed = projectedCollapsed) |
There was a problem hiding this comment.
Nit, non-blocking: this copies even when projectedCollapsed == grouped.isCollapsed (both false in the common no-collapse case); if (projectedCollapsed) grouped.copy(isCollapsed = true) else grouped avoids the redundant allocation per spec creation.
There was a problem hiding this comment.
Moot now. createShuffleSpec does not copy at all any more, it is project(joinKeyPositions).toGrouped, and project decides the flag in the constructor call.
| * overlap), and needs no opt-in. `OrderedDistribution` is not gated at all: `GroupPartitionsExec` | ||
| * pads that path out to the expected split counts rather than coalescing, so nothing is merged. | ||
| * | ||
| * A collapsed partitioning is still kept rather than dropped to `UnknownPartitioning`, because |
There was a problem hiding this comment.
Could we state the two cases explicitly here?
- Collapsed and ungrouped: duplicate keys remain, so
ClusteredDistributionis refused when the config is off. - Collapsed and grouped: keys are already unique, so it is accepted regardless of the config.
The current paragraph mixes these states, which makes “a collapsed partitioning” ambiguous.
There was a problem hiding this comment.
Done, in your words:
- Collapsed and ungrouped: duplicate keys remain, so grouping them would merge partitions the source held apart.
mayGroupToSatisfy()refuses aClusteredDistributionunless the config is on. - Collapsed and grouped: the keys are already unique, so grouping merges nothing and
satisfies()accepts aClusteredDistributionwhatever the config says. A partitioning reaches this state by being grouped with the config on, or by having its keys reduced onto a coarser transform.
The paragraph that mixed the two states is gone. What follows the bullets is only what is common to both, which is why a collapsed partitioning is still reported rather than dropped to UnknownPartitioning.
9972665 to
122e126Comparepeter-toth
commented
Aug 29, 2026
Rebased and pushed. SPARK-58974 merged, so this is no longer stacked and the PR is one commit against master. The description is regenerated from it. The flag's contract and every producer are as reviewed, and two things around them changed shape.
One projection. Sidenote: both point the same way as item 5 of the follow-up list on #58262. The remaining half is that which positions are operation keys is still derived in more than one place, and a comment in Two decisions from your review are unchanged and stated in the threads: |
peter-toth
commented
Aug 29, 2026
@ulysses-you the report I promised on #58338, on how the tightened notion reconciles with Your shape is the one this PR fixes. A source reporting several splits per partition key, projected onto exactly the operator's clustering, no longer trips the gate, because the flag means an actual collapse instead of "positions were dropped". Measured on it with the opt-in off: before, no So the mismatch you named is gone. Duplicated keys on their own no longer refuse anything, which is the tolerance On the two gates sharing one notion, I ended up thinking they should not. They ask different questions and neither subsumes the other. |
@dongjoon-hyun, @szehon-ho, I've updated the PR with splitting @HeartSaVioR, I would like to include this PR into 4.3.0 if possible, as besides this is a bugfix, it changes the partition narrowing semantics added in this version (SPARK-46367 / #55519). So if we make this change now, then we don't need a migration guide addition later. |
HeartSaVioR
commented
Aug 30, 2026
I'm OK with merging this in 4.3. Whether this has to go in 4.3 RC1 is the main question, but I don't know whether this is an issue since we know RC1 tends to fail, so I don't think it is a big deal. |
…collapse, and rename it to isCollapsed `KeyedPartitioning` carries a flag that gates whether `GroupPartitionsExec` may coalesce its duplicate partition keys without `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`. Today the flag records *provenance*, meaning "a projection dropped key positions, or my input already had some dropped". The gate's own comment describes something else, a *collapse*: "partitions that held distinct keys in the original finer-grained partitioning". The two are not the same, and the gate reads the wrong one. This changes the flag to mean what the gate needs, and renames it from `isNarrowed` to `isCollapsed`. A collapse is a projection or a reduction mapping keys that were distinct in the input onto the same key, so one partition here stands for several of the original ones. Dropping key positions no longer sets the flag on its own. The projected keys have to actually lose distinctness. It stays sticky, since neither grouping nor a further projection can make a partitioning finer again. The gate still rests on the same two conditions, but each now sits where it belongs. `isCollapsed` says the collapse happened, and it gates `mayGroupToSatisfy`, the question `EnsureRequirements` asks before it inserts a `GroupPartitionsExec`. The second condition is that there is still something left to merge, and that one is the caller. `EnsureRequirements` asks the question only of partitionings that are not grouped. Once the keys are unique, grouping merges nothing and there is nothing left to gate, however coarse the partitioning already is, so `satisfies` needs no gate at all. That splits the old `groupedSatisfies` in two, because its two callers were asking different questions through one method: * `keysSatisfy(required)`: whether the partition keys match what the distribution asks for, ignoring duplicate keys. `satisfies` is this plus `isGrouped`. * `mayGroupToSatisfy(required)`: `keysSatisfy` plus a check that coalescing the duplicates is allowed. `EnsureRequirements` asks this of non-grouped partitionings. That check is a conjunct rather than a case inside the key matching. SPARK-58974 established it there by hoisting it above the `requireAllClusterKeys` branch, since the risk it guards does not depend on which key sets count as matching. `OrderedDistribution` stays exempt, because `GroupPartitionsExec` pads that path out to the expected split counts instead of coalescing. Producers: * `PartitioningPreservingUnaryExecNode` goes through a new `KeyedPartitioning.project`, which computes `isGrouped` and `isCollapsed` together from the projected keys. The flag is inherited, or set when the projection leaves fewer distinct keys than the input had. A projection that keeps every position returns the partitioning unchanged, with no pass over the keys. * `UnionExec`'s keyed merge moves to `KeyedPartitioning.concat`, which joins `apply`, `project` and `toGrouped` as the fourth rule for how the flags travel. The rule itself is unchanged: it ORs the children's flags, so a child that really did collapse its keys marks the union. What changes for a union is upstream of it, since the children's flags are now precise. * `GroupPartitionsExec`, `KeyedPartitioning.toGrouped`, `KeyedPartitioning.createShuffleSpec` and `KeyedShuffleSpec.createPartitioning` all propagate the flag. Each of them used to build a partitioning through the 3-argument constructor, which defaulted the flag to `false`. That default is gone from the parameter, so every producer now has to state it. * `createShuffleSpec` projects onto the operation keys, so it computes its own collapse with `project`. `GroupPartitionsExec` decides both answers from the key groups it keeps, by asking whether a group spans more than one of its own child's partition keys. It does not compare against the aligned key list, because that list is the one both join sides agreed on. Such a list can be missing keys this side had (partition filtering) or repeat them (padding), and neither is a collapse. `GroupPartitionsExec` computes the flag once, from one member of the child's partitioning. `PartitioningCollection` normalizes the flag across its members by OR, alongside the `partitionKeys` interning it already did, and its invariant check enforces the result. The flag describes the shared physical layout rather than one member's naming of it, and consumers read it off a single member. `satisfies0` and `EnsureRequirements` accept when any one member satisfies the distribution, so a member that under-reported the collapse would let the gate through. Uniformity also means a consumer can read one representative instead of every member. The coverage there is structural. Once the flag is uniform, the mixed collection that could slip through cannot be built, so the test asserts the invariant rather than a plan shape. This supersedes apache#58316 (SPARK-59026), which restores the flag in two of the producers above. Propagating it is that PR's finding, and its unit test is taken here with credit. Its end-to-end test is not taken. Its table has unique ids, so dropping the second key column keeps every key distinct, and that is exactly the scenario this change reclassifies as no collapse. The two cannot both land as written. The class doc is reorganized around the new meaning. It leads with what a partition key is and what `EnsureRequirements` uses the keys for, and it gains a "Key Collapse" section that tells a collapse apart from a grouping. That section also carries why a collapsed partitioning is still reported rather than dropped to `UnknownPartitioning`, which was nowhere in the code. Both the method split and `project` point the same way as item 5 of apache#58262 (comment), which is that the SPJ decisions in `EnsureRequirements` are worth untangling on their own. `project` settles one half of that here. Applying a projection to a partitioning now happens in one place instead of once per producer. The other half is left to the follow-up. Which positions to keep is still asked in two places, `KeyedShuffleSpec.keyPositions` (where `createShuffleSpec` takes its `joinKeyPositions` from) and the overlap test in `keysSatisfy`. A comment there now records that the two are the same test, and that the answer only becomes true once the projection is applied. Provenance leaves the gate open in three ways, which is why this is filed as a bug: * **The flag is dropped wherever a partitioning is rebuilt.** That happens in `toGrouped`, in `createShuffleSpec`'s projected partitioning, in `KeyedShuffleSpec.createPartitioning` and in `GroupPartitionsExec`, so the gate cannot see the risk even when it exists. This is the defect apache#58316 (SPARK-59026) reports, and it is fixed here. * **A reduction never sets the flag at all.** Under `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join maps four distinct keys onto two, which is a real collapse. It drops no key position, so provenance misses the whole class of them and the opt-in is bypassed. * **A `PartitioningCollection` whose members disagreed could be entered through the plain one.** `EnsureRequirements` looks for any single member that a `GroupPartitionsExec` would make satisfy the distribution, so a collection holding one collapsed and one plain member reached one regardless. Normalizing the flag across members closes that. Provenance also over-refuses, and it does so on one of the commonest shapes. `!isGrouped` has causes that have nothing to do with dropping key positions: * A data source reports one partition key per input split, so a table with several splits for the same partition value already has duplicate keys before any projection. * `UnionExec` computes `isGrouped` over the concatenation of its children's keys, so two children that are individually key-distinct make it false by overlapping with each other. In both cases grouping merges only partitions that already shared a key. That is what `GroupPartitionsExec` does for any partitioning that never went through a projection, and it needs no opt-in. Today the mere presence of a narrowing projection anywhere upstream turns that into a refusal, so a plan gets a shuffle that protects nothing. Measured on the multi-split shape. A table partitioned by `(id, dept)` has two splits for the same `(1, 'x')` value, is projected down to `id`, and is joined on it with the opt-in off. Before: no `GroupPartitionsExec` and 2 shuffles, and the projected partitioning reports the flag set. After: 1 `GroupPartitionsExec`, 0 shuffles, flag clear. It should reach `branch-4.3` and `branch-4.x` as well as master, since 4.3.0 is where the flag first ships. Yes, a plan-level change. Storage-partitioned operations now proceed without `allowKeysSubsetOfPartitionKeys.enabled` in the cases above, where they previously fell back to a shuffle. Query results are unchanged. No migration guide entry is needed, because the flag and its gate arrived in 4.3.0 (SPARK-46367), which is unreleased, so no released version behaves the old way. Propagating the flag through `createShuffleSpec` also makes the spec's projected partitioning compare equal to the child's in one more case. That case is the opt-in on, every position selected, and the source already collapsed and grouped. `EnsureRequirements`' "child partitionings not modified" fast path then fires where it previously did not. The partitions are the same, since that path is only reached when both sides' keys already match, but the existing `GroupPartitionsExec` keeps `expectedPartitionKeys = None`, so `explain` loses its `ExpectedPartitionKeys` line. Reducers are a second case. With `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join really does collapse its keys, so its `GroupPartitionsExec` output now carries the flag where it did not before, and a coalescing further up the plan needs the opt-in. That direction is a narrowing, not a widening, and it is what the flag is supposed to say. Planning cost was measured on the worst case for the collapse test. The partitioning is projected ten times over, each projection dropping one key position, which is what a plan with stacked `Project`s asks for. Every projection drops a position, so the identity fast path never fires, and no projection collapses a key, so the walk never stops early. Over a 50k-split, 25k-distinct-key partitioning with 12-position keys, 20 evaluations of that chain took 345 ms for the provenance formula and 1291 ms for this one, i.e. about 5 ms per projection that drops a position. The obvious two-pass form of the same test, one `distinct` over the projected keys and another over the source keys, took 2137 ms in the same harness, which is why `project` walks the two key lists in lockstep instead. A projection that keeps every position pays nothing, since it cannot collapse anything. The trade is deliberate: a few milliseconds of planning buys the removal of a runtime shuffle in the cases above, and it also closes the three holes that let the gate through. * New unit test in `ProjectedOrderingAndPartitioningSuite` for the multi-split shape. A source with duplicate keys, projected down, is ungrouped but not collapsed, and `mayGroupToSatisfy` accepts it with the opt-in off. * New end-to-end test in `KeyGroupedPartitioningSuite` for the same shape through a real plan, asserting the grouping happens and the shuffles disappear. * New unit test that a collapsed member anywhere in a `PartitioningCollection` marks every member of it, including through a nested collection, so the outcome does not depend on which join side the collapse came from. * New operator-level test in `GroupPartitionsExecSuite` that the output flag reports what the node's grouping merges. It covers the replicating and the distributing mode, and the case where the join's agreed keys drop the merged one. * New end-to-end test for the shuffle-template chain from apache#58316, with the expectation these semantics call for. Its `items` has unique ids, so dropping `name` collapses nothing, and the final aggregate may group the union's overlapping keys with the opt-in off. That keeps the chain covered while pinning the reclassification. * New end-to-end test that reducing keys onto a coarser transform collapses them. With `allowCompatibleTransforms`, an `identity(item_id)` side reduced onto `bucket(4, id)` maps several ids onto one bucket, and the flag now says so. * New end-to-end test that filtering partition keys out is not a collapse. With `partitionFilter` on, an inner join plans both sides on the intersection of their keys, and the side that lost a key must not report a collapse. Otherwise the sticky flag costs a shuffle above a later union. * One existing expectation flipped, which is the contract change. In `SPARK-46367: narrowing projection with duplicate keys ...`, the scenario whose projected keys stay distinct now asserts the flag is clear. * Every new expectation is guarded by an ablation, verified one at a time. The old provenance formula fails four of them: the multi-split unit test, its end-to-end counterpart, the flipped `SPARK-46367` scenario and the shuffle-template chain. Deciding the flag from a key count taken after the alignment, instead of from the key groups kept, fails the partition-filter test. Dropping the collection normalization fails its unit test. Dropping the `GroupPartitionsExec` term fails the reducer test. * `DistributionSuite` covers the propagation through `toGrouped` and `KeyedShuffleSpec.createPartitioning`. That test comes from apache#58316. * Also ran `DistributionSuite`, `ShuffleSpecSuite`, `KeyGroupedPartitioningSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `PlannerSuite`, `WriteDistributionAndOrderingSuite`, `DataFrameSetOperationsSuite`, `AdaptiveQueryExecSuite`, `CoalesceShufflePartitionsSuite` and the TPC-DS plan stability suites. No golden file changed. Generated-by: Claude Code (Opus 5)
122e126 to
fea1493Compare| * | ||
| * - '''Grouping without a collapse''' merges only partitions that already shared a key. A source | ||
| * reporting several splits per key produces those, and so does a union of children whose keys | ||
| * overlap. Every partitioning that never went through a projection is in this case, so no opt-in |
There was a problem hiding this comment.
This claim is not true for reductions: a reducer can map distinct keys onto a coarser transform without the partitioning ever going through a projection, which is exactly the case covered by the new reducing keys onto a coarser transform collapses keys test. Could we remove this sentence or qualify it so the class doc does not contradict the collapse definition above?
There was a problem hiding this comment.
Thanks, you are right, and the doc contradicted itself: the collapse definition thirteen lines above names both a projection and a reduction, and this bullet only excluded the projection. Fixed in 6d2aa2b by naming both producers, so the reducing keys onto a coarser transform collapses keys case is no longer contradicted.
szehon-ho
left a comment
There was a problem hiding this comment.
thanks this looks much clearer. left one comment about comment correctness, so its quite minor, feel free to apply when convenient.
…Partitioning doc The `Grouping without a collapse` bullet said "every partitioning that never went through a projection is in this case", which contradicts the collapse definition thirteen lines above it: that one names both a projection *and* a reduction. A reducer maps distinct keys onto a coarser transform with no projection involved, which is what the `reducing keys onto a coarser transform collapses keys` test covers. Name both producers instead of only the projection. Doc only, no behaviour change.
…collapse, and rename it to isCollapsed ### What changes were proposed in this pull request? `KeyedPartitioning` carries a flag that gates whether `GroupPartitionsExec` may coalesce its duplicate partition keys without `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`. Today the flag records *provenance*, meaning "a projection dropped key positions, or my input already had some dropped". The gate's own comment describes something else, a *collapse*: "partitions that held distinct keys in the original finer-grained partitioning". The two are not the same, and the gate reads the wrong one. This changes the flag to mean what the gate needs, and renames it from `isNarrowed` to `isCollapsed`. A collapse is a projection or a reduction mapping keys that were distinct in the input onto the same key, so one partition here stands for several of the original ones. Dropping key positions no longer sets the flag on its own. The projected keys have to actually lose distinctness. It stays sticky, since neither grouping nor a further projection can make a partitioning finer again. The gate still rests on the same two conditions, but each now sits where it belongs. `isCollapsed` says the collapse happened, and it gates `mayGroupToSatisfy`, the question `EnsureRequirements` asks before it inserts a `GroupPartitionsExec`. The second condition is that there is still something left to merge, and that one is the caller. `EnsureRequirements` asks the question only of partitionings that are not grouped. Once the keys are unique, grouping merges nothing and there is nothing left to gate, however coarse the partitioning already is, so `satisfies` needs no gate at all. That splits the old `groupedSatisfies` in two, because its two callers were asking different questions through one method: * `keysSatisfy(required)`: whether the partition keys match what the distribution asks for, ignoring duplicate keys. `satisfies` is this plus `isGrouped`. * `mayGroupToSatisfy(required)`: `keysSatisfy` plus a check that coalescing the duplicates is allowed. `EnsureRequirements` asks this of non-grouped partitionings. That check is a conjunct rather than a case inside the key matching. SPARK-58974 established it there by hoisting it above the `requireAllClusterKeys` branch, since the risk it guards does not depend on which key sets count as matching. `OrderedDistribution` stays exempt, because `GroupPartitionsExec` pads that path out to the expected split counts instead of coalescing. Producers: * `PartitioningPreservingUnaryExecNode` goes through a new `KeyedPartitioning.project`, which computes `isGrouped` and `isCollapsed` together from the projected keys. The flag is inherited, or set when the projection leaves fewer distinct keys than the input had. A projection that keeps every position returns the partitioning unchanged, with no pass over the keys. * `UnionExec`'s keyed merge moves to `KeyedPartitioning.concat`, which joins `apply`, `project` and `toGrouped` as the fourth rule for how the flags travel. The rule itself is unchanged: it ORs the children's flags, so a child that really did collapse its keys marks the union. What changes for a union is upstream of it, since the children's flags are now precise. * `GroupPartitionsExec`, `KeyedPartitioning.toGrouped`, `KeyedPartitioning.createShuffleSpec` and `KeyedShuffleSpec.createPartitioning` all propagate the flag. Each of them used to build a partitioning through the 3-argument constructor, which defaulted the flag to `false`. That default is gone from the parameter, so every producer now has to state it. * `createShuffleSpec` projects onto the operation keys, so it computes its own collapse with `project`. `GroupPartitionsExec` decides both answers from the key groups it keeps, by asking whether a group spans more than one of its own child's partition keys. It does not compare against the aligned key list, because that list is the one both join sides agreed on. Such a list can be missing keys this side had (partition filtering) or repeat them (padding), and neither is a collapse. `GroupPartitionsExec` computes the flag once, from one member of the child's partitioning. `PartitioningCollection` normalizes the flag across its members by OR, alongside the `partitionKeys` interning it already did, and its invariant check enforces the result. The flag describes the shared physical layout rather than one member's naming of it, and consumers read it off a single member. `satisfies0` and `EnsureRequirements` accept when any one member satisfies the distribution, so a member that under-reported the collapse would let the gate through. Uniformity also means a consumer can read one representative instead of every member. The coverage there is structural. Once the flag is uniform, the mixed collection that could slip through cannot be built, so the test asserts the invariant rather than a plan shape. This supersedes #58316 (SPARK-59026), which restores the flag in two of the producers above. Propagating it is that PR's finding, and its unit test is taken here with credit. Its end-to-end test is not taken. Its table has unique ids, so dropping the second key column keeps every key distinct, and that is exactly the scenario this change reclassifies as no collapse. The two cannot both land as written. The class doc is reorganized around the new meaning. It leads with what a partition key is and what `EnsureRequirements` uses the keys for, and it gains a "Key Collapse" section that tells a collapse apart from a grouping. That section also carries why a collapsed partitioning is still reported rather than dropped to `UnknownPartitioning`, which was nowhere in the code. Sidenote: both the method split and `project` point the same way as item 5 of #58262 (comment), which is that the SPJ decisions in `EnsureRequirements` are worth untangling on their own. `project` settles one half of that here. Applying a projection to a partitioning now happens in one place instead of once per producer. The other half is left to the follow-up. Which positions to keep is still asked in two places, `KeyedShuffleSpec.keyPositions` (where `createShuffleSpec` takes its `joinKeyPositions` from) and the overlap test in `keysSatisfy`. A comment there now records that the two are the same test, and that the answer only becomes true once the projection is applied. ### Why are the changes needed? Provenance leaves the gate open in three ways, which is why this is filed as a bug: * **The flag is dropped wherever a partitioning is rebuilt.** That happens in `toGrouped`, in `createShuffleSpec`'s projected partitioning, in `KeyedShuffleSpec.createPartitioning` and in `GroupPartitionsExec`, so the gate cannot see the risk even when it exists. This is the defect #58316 (SPARK-59026) reports, and it is fixed here. * **A reduction never sets the flag at all.** Under `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join maps four distinct keys onto two, which is a real collapse. It drops no key position, so provenance misses the whole class of them and the opt-in is bypassed. * **A `PartitioningCollection` whose members disagreed could be entered through the plain one.** `EnsureRequirements` looks for any single member that a `GroupPartitionsExec` would make satisfy the distribution, so a collection holding one collapsed and one plain member reached one regardless. Normalizing the flag across members closes that. Provenance also over-refuses, and it does so on one of the commonest shapes. `!isGrouped` has causes that have nothing to do with dropping key positions: * A data source reports one partition key per input split, so a table with several splits for the same partition value already has duplicate keys before any projection. * `UnionExec` computes `isGrouped` over the concatenation of its children's keys, so two children that are individually key-distinct make it false by overlapping with each other. In both cases grouping merges only partitions that already shared a key. That is what `GroupPartitionsExec` does for any partitioning that never went through a projection, and it needs no opt-in. Today the mere presence of a narrowing projection anywhere upstream turns that into a refusal, so a plan gets a shuffle that protects nothing. Measured on the multi-split shape. A table partitioned by `(id, dept)` has two splits for the same `(1, 'x')` value, is projected down to `id`, and is joined on it with the opt-in off. Before: no `GroupPartitionsExec` and 2 shuffles, and the projected partitioning reports the flag set. After: 1 `GroupPartitionsExec`, 0 shuffles, flag clear. ### Does this PR introduce _any_ user-facing change? It should reach `branch-4.3` and `branch-4.x` as well as master, since 4.3.0 is where the flag first ships. Yes, a plan-level change. Storage-partitioned operations now proceed without `allowKeysSubsetOfPartitionKeys.enabled` in the cases above, where they previously fell back to a shuffle. Query results are unchanged. No migration guide entry is needed, because the flag and its gate arrived in 4.3.0 (SPARK-46367), which is unreleased, so no released version behaves the old way. Propagating the flag through `createShuffleSpec` also makes the spec's projected partitioning compare equal to the child's in one more case. That case is the opt-in on, every position selected, and the source already collapsed and grouped. `EnsureRequirements`' "child partitionings not modified" fast path then fires where it previously did not. The partitions are the same, since that path is only reached when both sides' keys already match, but the existing `GroupPartitionsExec` keeps `expectedPartitionKeys = None`, so `explain` loses its `ExpectedPartitionKeys` line. Reducers are a second case. With `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join really does collapse its keys, so its `GroupPartitionsExec` output now carries the flag where it did not before, and a coalescing further up the plan needs the opt-in. That direction is a narrowing, not a widening, and it is what the flag is supposed to say. Planning cost was measured on the worst case for the collapse test. The partitioning is projected ten times over, each projection dropping one key position, which is what a plan with stacked `Project`s asks for. Every projection drops a position, so the identity fast path never fires, and no projection collapses a key, so the walk never stops early. Over a 50k-split, 25k-distinct-key partitioning with 12-position keys, 20 evaluations of that chain took 345 ms for the provenance formula and 1291 ms for this one, i.e. about 5 ms per projection that drops a position. The obvious two-pass form of the same test, one `distinct` over the projected keys and another over the source keys, took 2137 ms in the same harness, which is why `project` walks the two key lists in lockstep instead. A projection that keeps every position pays nothing, since it cannot collapse anything. The trade is deliberate: a few milliseconds of planning buys the removal of a runtime shuffle in the cases above, and it also closes the three holes that let the gate through. ### How was this patch tested? * New unit test in `ProjectedOrderingAndPartitioningSuite` for the multi-split shape. A source with duplicate keys, projected down, is ungrouped but not collapsed, and `mayGroupToSatisfy` accepts it with the opt-in off. * New end-to-end test in `KeyGroupedPartitioningSuite` for the same shape through a real plan, asserting the grouping happens and the shuffles disappear. * New unit test that a collapsed member anywhere in a `PartitioningCollection` marks every member of it, including through a nested collection, so the outcome does not depend on which join side the collapse came from. * New operator-level test in `GroupPartitionsExecSuite` that the output flag reports what the node's grouping merges. It covers the replicating and the distributing mode, and the case where the join's agreed keys drop the merged one. * New end-to-end test for the shuffle-template chain from #58316, with the expectation these semantics call for. Its `items` has unique ids, so dropping `name` collapses nothing, and the final aggregate may group the union's overlapping keys with the opt-in off. That keeps the chain covered while pinning the reclassification. * New end-to-end test that reducing keys onto a coarser transform collapses them. With `allowCompatibleTransforms`, an `identity(item_id)` side reduced onto `bucket(4, id)` maps several ids onto one bucket, and the flag now says so. * New end-to-end test that filtering partition keys out is not a collapse. With `partitionFilter` on, an inner join plans both sides on the intersection of their keys, and the side that lost a key must not report a collapse. Otherwise the sticky flag costs a shuffle above a later union. * One existing expectation flipped, which is the contract change. In `SPARK-46367: narrowing projection with duplicate keys ...`, the scenario whose projected keys stay distinct now asserts the flag is clear. * Every new expectation is guarded by an ablation, verified one at a time. The old provenance formula fails four of them: the multi-split unit test, its end-to-end counterpart, the flipped `SPARK-46367` scenario and the shuffle-template chain. Deciding the flag from a key count taken after the alignment, instead of from the key groups kept, fails the partition-filter test. Dropping the collection normalization fails its unit test. Dropping the `GroupPartitionsExec` term fails the reducer test. * `DistributionSuite` covers the propagation through `toGrouped` and `KeyedShuffleSpec.createPartitioning`. That test comes from #58316. * Also ran `DistributionSuite`, `ShuffleSpecSuite`, `KeyGroupedPartitioningSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `PlannerSuite`, `WriteDistributionAndOrderingSuite`, `DataFrameSetOperationsSuite`, `AdaptiveQueryExecSuite`, `CoalesceShufflePartitionsSuite` and the TPC-DS plan stability suites. No golden file changed. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5) Closes#58351 from peter-toth/SPARK-59057-collapse-semantics. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 8fd856c) Signed-off-by: Peter Toth <peter.toth@gmail.com>
…collapse, and rename it to isCollapsed `KeyedPartitioning` carries a flag that gates whether `GroupPartitionsExec` may coalesce its duplicate partition keys without `spark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled`. Today the flag records *provenance*, meaning "a projection dropped key positions, or my input already had some dropped". The gate's own comment describes something else, a *collapse*: "partitions that held distinct keys in the original finer-grained partitioning". The two are not the same, and the gate reads the wrong one. This changes the flag to mean what the gate needs, and renames it from `isNarrowed` to `isCollapsed`. A collapse is a projection or a reduction mapping keys that were distinct in the input onto the same key, so one partition here stands for several of the original ones. Dropping key positions no longer sets the flag on its own. The projected keys have to actually lose distinctness. It stays sticky, since neither grouping nor a further projection can make a partitioning finer again. The gate still rests on the same two conditions, but each now sits where it belongs. `isCollapsed` says the collapse happened, and it gates `mayGroupToSatisfy`, the question `EnsureRequirements` asks before it inserts a `GroupPartitionsExec`. The second condition is that there is still something left to merge, and that one is the caller. `EnsureRequirements` asks the question only of partitionings that are not grouped. Once the keys are unique, grouping merges nothing and there is nothing left to gate, however coarse the partitioning already is, so `satisfies` needs no gate at all. That splits the old `groupedSatisfies` in two, because its two callers were asking different questions through one method: * `keysSatisfy(required)`: whether the partition keys match what the distribution asks for, ignoring duplicate keys. `satisfies` is this plus `isGrouped`. * `mayGroupToSatisfy(required)`: `keysSatisfy` plus a check that coalescing the duplicates is allowed. `EnsureRequirements` asks this of non-grouped partitionings. That check is a conjunct rather than a case inside the key matching. SPARK-58974 established it there by hoisting it above the `requireAllClusterKeys` branch, since the risk it guards does not depend on which key sets count as matching. `OrderedDistribution` stays exempt, because `GroupPartitionsExec` pads that path out to the expected split counts instead of coalescing. Producers: * `PartitioningPreservingUnaryExecNode` goes through a new `KeyedPartitioning.project`, which computes `isGrouped` and `isCollapsed` together from the projected keys. The flag is inherited, or set when the projection leaves fewer distinct keys than the input had. A projection that keeps every position returns the partitioning unchanged, with no pass over the keys. * `UnionExec`'s keyed merge moves to `KeyedPartitioning.concat`, which joins `apply`, `project` and `toGrouped` as the fourth rule for how the flags travel. The rule itself is unchanged: it ORs the children's flags, so a child that really did collapse its keys marks the union. What changes for a union is upstream of it, since the children's flags are now precise. * `GroupPartitionsExec`, `KeyedPartitioning.toGrouped`, `KeyedPartitioning.createShuffleSpec` and `KeyedShuffleSpec.createPartitioning` all propagate the flag. Each of them used to build a partitioning through the 3-argument constructor, which defaulted the flag to `false`. That default is gone from the parameter, so every producer now has to state it. * `createShuffleSpec` projects onto the operation keys, so it computes its own collapse with `project`. `GroupPartitionsExec` decides both answers from the key groups it keeps, by asking whether a group spans more than one of its own child's partition keys. It does not compare against the aligned key list, because that list is the one both join sides agreed on. Such a list can be missing keys this side had (partition filtering) or repeat them (padding), and neither is a collapse. `GroupPartitionsExec` computes the flag once, from one member of the child's partitioning. `PartitioningCollection` normalizes the flag across its members by OR, alongside the `partitionKeys` interning it already did, and its invariant check enforces the result. The flag describes the shared physical layout rather than one member's naming of it, and consumers read it off a single member. `satisfies0` and `EnsureRequirements` accept when any one member satisfies the distribution, so a member that under-reported the collapse would let the gate through. Uniformity also means a consumer can read one representative instead of every member. The coverage there is structural. Once the flag is uniform, the mixed collection that could slip through cannot be built, so the test asserts the invariant rather than a plan shape. This supersedes #58316 (SPARK-59026), which restores the flag in two of the producers above. Propagating it is that PR's finding, and its unit test is taken here with credit. Its end-to-end test is not taken. Its table has unique ids, so dropping the second key column keeps every key distinct, and that is exactly the scenario this change reclassifies as no collapse. The two cannot both land as written. The class doc is reorganized around the new meaning. It leads with what a partition key is and what `EnsureRequirements` uses the keys for, and it gains a "Key Collapse" section that tells a collapse apart from a grouping. That section also carries why a collapsed partitioning is still reported rather than dropped to `UnknownPartitioning`, which was nowhere in the code. Sidenote: both the method split and `project` point the same way as item 5 of #58262 (comment), which is that the SPJ decisions in `EnsureRequirements` are worth untangling on their own. `project` settles one half of that here. Applying a projection to a partitioning now happens in one place instead of once per producer. The other half is left to the follow-up. Which positions to keep is still asked in two places, `KeyedShuffleSpec.keyPositions` (where `createShuffleSpec` takes its `joinKeyPositions` from) and the overlap test in `keysSatisfy`. A comment there now records that the two are the same test, and that the answer only becomes true once the projection is applied. Provenance leaves the gate open in three ways, which is why this is filed as a bug: * **The flag is dropped wherever a partitioning is rebuilt.** That happens in `toGrouped`, in `createShuffleSpec`'s projected partitioning, in `KeyedShuffleSpec.createPartitioning` and in `GroupPartitionsExec`, so the gate cannot see the risk even when it exists. This is the defect #58316 (SPARK-59026) reports, and it is fixed here. * **A reduction never sets the flag at all.** Under `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join maps four distinct keys onto two, which is a real collapse. It drops no key position, so provenance misses the whole class of them and the opt-in is bypassed. * **A `PartitioningCollection` whose members disagreed could be entered through the plain one.** `EnsureRequirements` looks for any single member that a `GroupPartitionsExec` would make satisfy the distribution, so a collection holding one collapsed and one plain member reached one regardless. Normalizing the flag across members closes that. Provenance also over-refuses, and it does so on one of the commonest shapes. `!isGrouped` has causes that have nothing to do with dropping key positions: * A data source reports one partition key per input split, so a table with several splits for the same partition value already has duplicate keys before any projection. * `UnionExec` computes `isGrouped` over the concatenation of its children's keys, so two children that are individually key-distinct make it false by overlapping with each other. In both cases grouping merges only partitions that already shared a key. That is what `GroupPartitionsExec` does for any partitioning that never went through a projection, and it needs no opt-in. Today the mere presence of a narrowing projection anywhere upstream turns that into a refusal, so a plan gets a shuffle that protects nothing. Measured on the multi-split shape. A table partitioned by `(id, dept)` has two splits for the same `(1, 'x')` value, is projected down to `id`, and is joined on it with the opt-in off. Before: no `GroupPartitionsExec` and 2 shuffles, and the projected partitioning reports the flag set. After: 1 `GroupPartitionsExec`, 0 shuffles, flag clear. It should reach `branch-4.3` and `branch-4.x` as well as master, since 4.3.0 is where the flag first ships. Yes, a plan-level change. Storage-partitioned operations now proceed without `allowKeysSubsetOfPartitionKeys.enabled` in the cases above, where they previously fell back to a shuffle. Query results are unchanged. No migration guide entry is needed, because the flag and its gate arrived in 4.3.0 (SPARK-46367), which is unreleased, so no released version behaves the old way. Propagating the flag through `createShuffleSpec` also makes the spec's projected partitioning compare equal to the child's in one more case. That case is the opt-in on, every position selected, and the source already collapsed and grouped. `EnsureRequirements`' "child partitionings not modified" fast path then fires where it previously did not. The partitions are the same, since that path is only reached when both sides' keys already match, but the existing `GroupPartitionsExec` keeps `expectedPartitionKeys = None`, so `explain` loses its `ExpectedPartitionKeys` line. Reducers are a second case. With `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join really does collapse its keys, so its `GroupPartitionsExec` output now carries the flag where it did not before, and a coalescing further up the plan needs the opt-in. That direction is a narrowing, not a widening, and it is what the flag is supposed to say. Planning cost was measured on the worst case for the collapse test. The partitioning is projected ten times over, each projection dropping one key position, which is what a plan with stacked `Project`s asks for. Every projection drops a position, so the identity fast path never fires, and no projection collapses a key, so the walk never stops early. Over a 50k-split, 25k-distinct-key partitioning with 12-position keys, 20 evaluations of that chain took 345 ms for the provenance formula and 1291 ms for this one, i.e. about 5 ms per projection that drops a position. The obvious two-pass form of the same test, one `distinct` over the projected keys and another over the source keys, took 2137 ms in the same harness, which is why `project` walks the two key lists in lockstep instead. A projection that keeps every position pays nothing, since it cannot collapse anything. The trade is deliberate: a few milliseconds of planning buys the removal of a runtime shuffle in the cases above, and it also closes the three holes that let the gate through. * New unit test in `ProjectedOrderingAndPartitioningSuite` for the multi-split shape. A source with duplicate keys, projected down, is ungrouped but not collapsed, and `mayGroupToSatisfy` accepts it with the opt-in off. * New end-to-end test in `KeyGroupedPartitioningSuite` for the same shape through a real plan, asserting the grouping happens and the shuffles disappear. * New unit test that a collapsed member anywhere in a `PartitioningCollection` marks every member of it, including through a nested collection, so the outcome does not depend on which join side the collapse came from. * New operator-level test in `GroupPartitionsExecSuite` that the output flag reports what the node's grouping merges. It covers the replicating and the distributing mode, and the case where the join's agreed keys drop the merged one. * New end-to-end test for the shuffle-template chain from #58316, with the expectation these semantics call for. Its `items` has unique ids, so dropping `name` collapses nothing, and the final aggregate may group the union's overlapping keys with the opt-in off. That keeps the chain covered while pinning the reclassification. * New end-to-end test that reducing keys onto a coarser transform collapses them. With `allowCompatibleTransforms`, an `identity(item_id)` side reduced onto `bucket(4, id)` maps several ids onto one bucket, and the flag now says so. * New end-to-end test that filtering partition keys out is not a collapse. With `partitionFilter` on, an inner join plans both sides on the intersection of their keys, and the side that lost a key must not report a collapse. Otherwise the sticky flag costs a shuffle above a later union. * One existing expectation flipped, which is the contract change. In `SPARK-46367: narrowing projection with duplicate keys ...`, the scenario whose projected keys stay distinct now asserts the flag is clear. * Every new expectation is guarded by an ablation, verified one at a time. The old provenance formula fails four of them: the multi-split unit test, its end-to-end counterpart, the flipped `SPARK-46367` scenario and the shuffle-template chain. Deciding the flag from a key count taken after the alignment, instead of from the key groups kept, fails the partition-filter test. Dropping the collection normalization fails its unit test. Dropping the `GroupPartitionsExec` term fails the reducer test. * `DistributionSuite` covers the propagation through `toGrouped` and `KeyedShuffleSpec.createPartitioning`. That test comes from #58316. * Also ran `DistributionSuite`, `ShuffleSpecSuite`, `KeyGroupedPartitioningSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `PlannerSuite`, `WriteDistributionAndOrderingSuite`, `DataFrameSetOperationsSuite`, `AdaptiveQueryExecSuite`, `CoalesceShufflePartitionsSuite` and the TPC-DS plan stability suites. No golden file changed. Tailored for this branch. Everything above describes the master commit, and this cherry-pick deviates from it in two places. * `UnionExec`'s keyed merge lifts the merged partitioning to the union's output, `toUnionOutput(KeyedPartitioning.concat(kps))`. `concat` returns it in the first child's attribute space. On master `prepareOutputPartitioning` normalizes to that space anyway, so `concat`'s result can be returned as it comes. This branch has no such normalization, so the lift is explicit here, the same way the pass-through case below it does it. * `SPARK-59057: a collapse is reported when the splits are distributed, not replicated` also turns `spark.sql.requireAllClusterKeysForCoPartition` off. Its `items` is partitioned by more keys than the join uses, and this branch's co-partition gate still asks the partition attributes to match the clustering keys one for one, so it refuses that shape. SPARK-58558 relaxed the gate to "every clustering key is covered" and that is master-only. The multi-split shape measured above needs no such opt-out. It projects down to `id` before the join, so its one-expression partitioning matches the clustering keys one for one, and the numbers hold on this branch too. `DataSourceV2CatalystRuntimeFilterSuite` is not touched here. Master updates one `KeyedPartitioning` call in it for the removed default, and this branch's copy of the suite has no such call. Every other file is master's, and so are all thirteen new tests. Ran `DistributionSuite`, `ShuffleSpecSuite`, `KeyGroupedPartitioningSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite` and `DataSourceV2CatalystRuntimeFilterSuite` on this branch. The suite list above is master's, so read it as that commit's coverage rather than this one's. Generated-by: Claude Code (Opus 5) Closes#58351 from peter-toth/SPARK-59057-collapse-semantics. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 8fd856c) Signed-off-by: Peter Toth <peter.toth@gmail.com>
peter-toth
commented
Sep 1, 2026
Thank you @dongjoon-hyun and @szehon-ho for the review. |
… for non-join operators This builds on two changes that are now on master. apache#58351 (SPARK-59057) renamed `KeyedPartitioning.isNarrowed` to `isCollapsed` and split `groupedSatisfies` into `keysSatisfy` and `mayGroupToSatisfy`. Two things here follow from that. The classification asks whether a member can satisfy the distribution once a `GroupPartitionsExec` is allowed, which is `keysSatisfy` for a grouped member and `mayGroupToSatisfy` for a non-grouped one, since only the second may coalesce duplicate keys and only it needs the collapse permission. That composition is a new `KeyedPartitioning.keysMaySatisfy`, which keeps `keysSatisfy` private. And the tests that build a `KeyedPartitioning` state `isCollapsed` explicitly, because that parameter lost its default. apache#58420 (SPARK-59120) made every reader of a `KeyedPartitioning`'s partition keys take its types from `keyDataTypes`, the schema the keys were written under, rather than from the partition expressions. This PR projects those keys on more paths, so it needs that fix underneath it. Without it, projecting the keys of a partitioning whose keys a reducer rewrote throws `ClassCastException` at planning. This is an alternative to apache#58245, which fixes the same JIRA by adding the projection to one of the two branches below. `EnsureRequirements` split a child's `KeyedPartitioning`s by `isGrouped` and then had two branches that each had to insert a `GroupPartitionsExec`. This PR classifies by what still has to happen to the data instead. - `splitKeyedPartitionings` now takes the required distribution and answers two questions, in this order. Whether a non-`KeyedPartitioning` member already satisfies it, and if not, how a `KeyedPartitioning` member can. As it is, or after a `GroupPartitionsExec` projecting to the partition expression positions returned with it, with `None` positions when the node only has to coalesce duplicate partition keys. At most one partitioning comes back, because the caller acts on a single one of them. The order is deliberate, since the keyed half is the one that projects partition keys, so it only runs when no plain member is enough. - A new `clusterKeyPositions` helper derives those positions from the required clustering, and a new `KeyedPartitioning.numPartitionsProjectedOn` answers how many partitions a projection onto them would leave. - The four-way match collapses to three cases, because the two arms that each had to insert a `GroupPartitionsExec` become one. It now matches on the distribution and the resolution together, since two of the three arms are decided by the resolution rather than by the distribution. The `OrderedDistribution` arm keeps its own insertion, as on `master`. `clusterKeyPositions` keeps a partition expression when it is one of the operation keys. `keysSatisfy` recognises that at the *reference* level, where a `bucket(4, a)` transform covers the cluster key `a`, and also at the *expression* level, where the cluster key is the partition expression itself. Both are honoured here, which is why the positions are derived from the clustering rather than taken from `KeyedShuffleSpec.keyPositions`. That answers only the first, which is the right question for a storage-partitioned join but would drop a partition expression that is itself an operation key. The expression-level test is what keeps a position whose expression is clustered on while its references are not. No production path builds such a clustering today. `V2ExpressionUtils.toCatalystTransformOpt` maps `IdentityTransform` to the resolved attribute itself, so the reference-level test matches the same position anyway, and `DistributionAndOrderingUtils.prepareQuery` maps `resolveTransformExpression` over a write's clustering, so a `TransformExpression` does not survive into one. The test builds the shape by hand, with a table partitioned by `(id, years(ts))` and clustered on those same two expressions. The check is here because `keysSatisfy` already accepts that shape, so deriving the positions any other way would make the two disagree. A member whose expressions cover no operation key at all is skipped rather than projected to no position, which would put every partition into one. Only a partitioning whose expressions have no references can get there. A DSv2 scan cannot report one, because `supportsExpressions` refuses it, but nothing rejects it at `KeyedPartitioning` construction. A partitioning that satisfies the distribution is kept as it is when nothing is left for a node to do, which holds in two ways. Either the projection drops no position, so `satisfies` is not the over-claim this fix is about. Or a position is dropped but the projection merges nothing, so every operation key already lives on a single partition. Keeping the partitioning is then better than projecting, because `KeyedPartitioning([id, name])` and `KeyedPartitioning([id])` describe the same number of partitions and only the first lets a downstream operator co-partition on `name` too. This is where the collapse gives the single insertion point a new way to be wrong, namely inserting a node that merges nothing, which cannot happen on `master`, where a grouped partitioning got no node at all. That question is asked of every member of the child's partitioning, not just of the one a node would be built from, because a child can report a whole `PartitioningCollection` and its members can disagree about which positions are operation keys. An inner join is where they do. Its `outputPartitioning` is the two sides' partitionings, and unlike `AliasAwareOutputExpression` it does not enumerate the mixed combinations, so a window keyed on one side's first key column and the other side's remaining ones sees one member covering position 0 and one covering the rest. The first needs no node, the second would coalesce for nothing. Among the members that do need a node, the one whose projection leaves the most partitions is used, and that answer is exact rather than a heuristic. Only the position sets not contained in another one have to be projected. Projecting to fewer positions can merge partitions but never split them, so a contained set can never leave more partitions than the set containing it, and can only tie. Dropping it therefore costs no parallelism, and on a tie it settles the choice toward the wider set, which still names the keys the narrower one would have dropped. In the ordinary case one set contains all the others and a single projection settles it, and only sets that genuinely disagree, neither containing the other, each cost one. That matters because the projection is the expensive step here, since `KeyedPartitioning.projectKeys` allocates a row per input partition and `InternalRowComparableWrapper.hashCode` is uncached. So the count is memoized per position set as well, a set covering every position needs no projection at all, being the identity on the key values, and a single surviving candidate with no required count needs none either, which is the shape the default config produces. Keeping one member per position set costs nothing either, because `PartitioningCollection` guarantees its members share the `partitionKeys` reference and their arity, so position `i` addresses the same key column in all of them. `Distribution.requiredNumPartitions` needs care, because a `GroupPartitionsExec` derives its number of partitions from the partition keys it is handed rather than from the operator. The requirement is therefore checked against the count such a node *would* produce, not against the count the partitioning happens to have now, and it filters the candidates rather than vetoing the winner. One that would land on the required number must not lose the ranking to one that cannot honour it and send the whole child to a shuffle instead. As-is satisfaction goes through `satisfies`, which enforces the count on its own. For an operator that co-partitions more than one child no projection is done here, because that belongs to the multi-child block below, which a storage-partitioned join reaches through `checkKeyGroupCompatible` and anything else through `withJoinKeyPositions`. Projecting inline as well would leave that block deriving positions from an already projected partitioning and applying them to the unprojected keys. `KeyedPartitioning.satisfies` is not touched, so nothing outside `EnsureRequirements` changes behaviour. It does still answer `true` for a partitioning that needs a projection first, which means `ValidateRequirements` cannot catch a missing `GroupPartitionsExec`. Giving that check the strict test directly, without changing what `satisfies` answers, is a follow-up we are working on. The `OrderedDistribution` arm also loses a `MatchError`. It tested that the partition keys are sorted with `partitionKeys.sliding(2)`, which yields one short window for a single-key partitioning, and `case Seq(k1, k2)` cannot match it, so planning threw. A v2 table whose rows all share one partition value reports such a partitioning, since `DataSourceV2ScanExecBase` has no single-partition short-circuit, and with `v2BucketingAllowSorting` on, a global sort on the partition key reaches this branch. The test is now a zip of the keys with their successors, which is vacuously true for one key. Pre-existing, but the branch is rewritten here anyway. Two scaladocs are corrected as well. `KeyedPartitioning` taught `isGrouped` as the axis this PR replaces. `GroupPartitionsExec.joinKeyPositions` described its projection as being "for join compatibility", and it now carries the projection for a single-child operator too. With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, `KeyedPartitioning.keysSatisfy` only requires that some operation key overlaps the partition attributes. A partitioning grouped on `(id, name)` therefore reports that it satisfies `ClusteredDistribution([id])` while rows sharing an `id` still sit on separate partitions. A storage-partitioned join projects the keys down to the operation keys in `checkKeyGroupCompatible`, and for a non-join operator nothing did. `isGrouped` is the wrong thing to classify on, because it only says the *full* partition keys are unique and says nothing about whether the *projected* keys are. A `GroupPartitionsExec` is needed either to coalesce duplicate partition keys, or to project down to the operation keys, or both, and splitting by provenance put those two reasons in different branches. Both branches returned wrong results. 1. A grouped partitioning over a superset of the operation keys got no node at all, so a top-k window over `PARTITION BY id` on an `(id, name)`-partitioned table surfaced `id=1` twice, once per `(1,'aa')` and `(1,'bb')` partition. 2. A non-grouped partitioning over a superset got a node without a projection, so it coalesced by the full partition keys and left the operation key split. Any source reporting more than one split per partition value hits this. `SUM(price) OVER (PARTITION BY id)` over the same table with two splits for `(1,'aa')` returned 25.0 and 20.0 instead of 45.0. Both reach back to 4.2.0, where `GroupPartitionsExec` and this classification were introduced. A reduced storage-partitioned join reaches the same wrong result through a third shape. Joining an `identity(ts)`-partitioned table to a `years(ts)`-partitioned one under `v2BucketingAllowCompatibleTransforms` leaves both sides grouped on `(year, bucket)`, so two rows sharing a `ts` in different buckets sit on separate partitions. `SUM(v) OVER (PARTITION BY ts)` then returns 10 and 20 on `master` where the answer is 30 and 30, and the projection this PR inserts is what merges the two partitions. Before apache#58420 that same query threw `ClassCastException` at planning once the keys were read, so the wrong answer only became observable when that fix landed. Yes, it fixes a data correctness issue. With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, a single-child operator whose keys are a subset of the partition keys now produces correct results, namely a window `PARTITION BY` and the single-pass aggregate shapes (`FlatMapGroupsInBatchExec`, `ArrowAggregatePythonExec`, `MapGroupsExec`). A two-phase SQL aggregate was already correct, because its partial `HashAggregate` is a `PartitioningPreservingUnaryExecNode`, so it narrows `KP([id, name])` to `KP([id])` before the final aggregate sees it. A cogroup is unaffected either way, because the projection there is left to the multi-child block, exactly as before. Two further changes are not gated on that config. The `requiredNumPartitions` rule applies whether `allowKeysSubsetOfPartitionKeys` is on or off, since a scan still needs `spark.sql.sources.v2.bucketing.enabled` to report a `KeyedPartitioning` at all, and `master` has no count check on the grouping path. A non-grouped `KeyedPartitioning` with 3 partitions and 2 distinct keys under `ClusteredDistribution([k], requiredNumPartitions = Some(3))` got a `GroupPartitionsExec` with 2 partitions on `master` and now gets a shuffle with 3. I could not find a query where such a distribution meets a `KeyedPartitioning` today, so this is robustness rather than a reachable wrong result. Only `StatefulOperatorPartitioning` and `AQEUtils` ever set the requirement. The `AQEUtils` one fires only over a `HashPartitioning` child. `StatefulOperatorPartitioning` sets it through a plain `ClusteredDistribution` when `spark.sql.streaming.statefulOperator.useStrictDistribution` is off, and a streaming scan never reports a `KeyedPartitioning`, because `MicroBatchScanExec`, `ContinuousScanExec` and `RealTimeStreamScanExec` all leave `keyGroupedPartitioning` at `None`. The initial-state child of `flatMapGroupsWithState` and `transformWithState` is a batch plan though, so it can be a scan that does. That operator co-partitions its two children, so the shape is the multi-child block's to decide either way, and what changes here is only that a candidate which cannot honour the count no longer wins the resolution. The requirement is part of the `ClusteredDistribution` contract though, the two could meet more widely as this area grows, and honouring it costs nothing where nobody asks for a count, so it seemed the wrong thing to keep ignoring. The `MatchError` fix is gated on `v2BucketingAllowSorting` instead, which is also off by default. `explain` gains one label where the new projection happens. A `GroupPartitionsExec` inserted for a single-child operator now carries `joinKeyPositions`, so the node prints `JoinKeyPositions: [...]` where it printed nothing before. The name is historical, and the scaladoc now says the projection is not join-specific. This needs `allowKeysSubsetOfPartitionKeys` on, so no golden file moves. Added regression tests in `KeyGroupedPartitioningSuite`: - window top-k over `PARTITION BY` a subset of the partition keys, for both `PARTITION BY id` and the duplicated `PARTITION BY id, id` - window top-k over union output partitioning - a plain window over a subset of the partition keys on a non-grouped `KeyedPartitioning`, asserting the inserted node projects to the operation key rather than only coalescing - no `GroupPartitionsExec` and no shuffle when projecting to the operation keys merges nothing - a window over an inner join's two-member `PartitioningCollection`, keyed on the left side's first key column and the right side's remaining two, asserting no node is inserted because the left member needs none, even though the right one covers more operation keys - a window over a join that reduced one side's keys onto the other side's key space, asserting the two rows sharing a `ts` end up on one partition and in `EnsureRequirementsSuite`: - a `FlatMapCoGroupsInPandasExec` over `(n, i)`-partitioned children grouped on `i`, asserting both sides are grouped on `i` and not on `n` - a grouped `KeyedPartitioning` whose count differs from `requiredNumPartitions`, asserting the count is still honoured with a shuffle - an `(n, i)`-partitioned `KeyedPartitioning` whose count matches `requiredNumPartitions` but which needs a projection, asserting it falls back to a shuffle rather than to a node that would break the count it just passed - a projecting `KeyedPartitioning` whose *post-projection* count matches `requiredNumPartitions`, asserting it still groups on the operation key with no shuffle - a non-grouped `KeyedPartitioning` whose *post-grouping* count matches `requiredNumPartitions`, asserting it still groups without a shuffle - the same count rule with `allowKeysSubsetOfPartitionKeys` left at its default, asserting the shuffle - an `(id, years(ts))`-partitioned `KeyedPartitioning` clustered on those same two expressions, with and without `requireAllClusterKeys`, asserting no node is inserted when a cluster key is the partition expression itself - a `(bucket(4, a), b)`-partitioned `KeyedPartitioning` clustered on `a` alone, asserting the node projects to the bucket position. This is the one test where the reference-level match decides a kept position, and it is the shape the single-reference soundness argument is about. `master` inserts no node at all here, so the partitions sharing a bucket stay apart. - a single-partition `KeyedPartitioning` under `OrderedDistribution`, asserting planning no longer throws a `MatchError` - a `KeyedPartitioning` with no partition expressions, asserting it is still kept as it is, since without that guard it reaches the shuffle branch, where `UnspecifiedDistribution.createPartitioning` throws - a `KeyedPartitioning` whose expressions reference no column, asserting the child is shuffled rather than projected to no position at all - a `PartitioningCollection` whose two members cover a different number of operation keys, asserting the wider one supplies the projection - two members whose position sets are nested and whose projections leave the same number of partitions, asserting the containing set still wins, so the projection keeps naming the key the other would have dropped - two members whose position sets are nested, where only the narrower one's projection lands on `requiredNumPartitions`, asserting it is used rather than pruned and lost to the wider one - two members whose position sets are not nested and where the narrower one leaves more partitions, asserting the narrower projection wins over the wider coverage - two members covering one position each whose projections leave different numbers of partitions, asserting the one leaving the most supplies the projection - two members covering one position each whose projections leave the same number of partitions, asserting the one the child reports first wins, in both collection orders and in `ProjectedOrderingAndPartitioningSuite` a grouped and collapsed `KeyedPartitioning`, asserting `keysMaySatisfy` accepts it where `mayGroupToSatisfy` refuses it, because a grouped partitioning has no duplicate keys left for a node to coalesce. Seventeen of the twenty-four fail without the production change in this commit, measured on `master`. Those are the four window tests, the reference-free expressions, the transform position, the `MatchError`, four of the six `requiredNumPartitions` tests, all five multi-member ones and the `keysMaySatisfy` one, which does not even compile there. The other seven pass already and guard this PR's own insertion decision. Removing the condition each one covers makes it fail, with one exception worth naming. `the candidate covering the most operation keys wins` survives the removal of either the containment prune or the ranking, because either mechanism alone picks the same winner, so it earns its place by documenting the shape rather than by pinning a single condition. The join test is one of the seven. The base leaves that plan alone too, and it is here because an earlier revision of this PR inserted a node there. Removing the `isCoPartitioned` guard also fails two pre-existing SPJ tests. `DistributionSuite` and `ShuffleSpecSuite` pass with 29 tests, and `KeyGroupedPartitioningSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite` and `PlannerSuite` pass with 295. The window `PARTITION BY` tests come from apache#58245. Generated-by: Claude Code Co-authored-by: Xiduo You <ulyssesyou@apache.org>
… for non-join operators ### What changes were proposed in this pull request? This builds on two changes that are now on master. #58351 (SPARK-59057) renamed `KeyedPartitioning.isNarrowed` to `isCollapsed` and split `groupedSatisfies` into `keysSatisfy` and `mayGroupToSatisfy`. Two things here follow from that. The classification asks whether a member can satisfy the distribution once a `GroupPartitionsExec` is allowed, which is `keysSatisfy` for a grouped member and `mayGroupToSatisfy` for a non-grouped one, since only the second may coalesce duplicate keys and only it needs the collapse permission. That composition is a new `KeyedPartitioning.keysMaySatisfy`, which keeps `keysSatisfy` private. And the tests that build a `KeyedPartitioning` state `isCollapsed` explicitly, because that parameter lost its default. #58420 (SPARK-59120) made every reader of a `KeyedPartitioning`'s partition keys take its types from `keyDataTypes`, the schema the keys were written under, rather than from the partition expressions. This PR projects those keys on more paths, so it needs that fix underneath it. Without it, projecting the keys of a partitioning whose keys a reducer rewrote throws `ClassCastException` at planning. This is an alternative to #58245, which fixes the same JIRA by adding the projection to one of the two branches below. `EnsureRequirements` split a child's `KeyedPartitioning`s by `isGrouped` and then had two branches that each had to insert a `GroupPartitionsExec`. This PR classifies by what still has to happen to the data instead. - `splitKeyedPartitionings` now takes the required distribution and answers two questions, in this order. Whether a non-`KeyedPartitioning` member already satisfies it, and if not, how a `KeyedPartitioning` member can. As it is, or after a `GroupPartitionsExec` projecting to the partition expression positions returned with it, with `None` positions when the node only has to coalesce duplicate partition keys. At most one partitioning comes back, because the caller acts on a single one of them. The order is deliberate, since the keyed half is the one that projects partition keys, so it only runs when no plain member is enough. - A new `clusterKeyPositions` helper derives those positions from the required clustering, and a new `KeyedPartitioning.numPartitionsProjectedOn` answers how many partitions a projection onto them would leave. - The four-way match collapses to three cases, because the two arms that each had to insert a `GroupPartitionsExec` become one. It now matches on the distribution and the resolution together, since two of the three arms are decided by the resolution rather than by the distribution. The `OrderedDistribution` arm keeps its own insertion, as on `master`. `clusterKeyPositions` keeps a partition expression when it is one of the operation keys. `keysSatisfy` recognises that at the *reference* level, where a `bucket(4, a)` transform covers the cluster key `a`, and also at the *expression* level, where the cluster key is the partition expression itself. Both are honoured here, which is why the positions are derived from the clustering rather than taken from `KeyedShuffleSpec.keyPositions`. That answers only the first, which is the right question for a storage-partitioned join but would drop a partition expression that is itself an operation key. The expression-level test is what keeps a position whose expression is clustered on while its references are not. No production path builds such a clustering today. `V2ExpressionUtils.toCatalystTransformOpt` maps `IdentityTransform` to the resolved attribute itself, so the reference-level test matches the same position anyway, and `DistributionAndOrderingUtils.prepareQuery` maps `resolveTransformExpression` over a write's clustering, so a `TransformExpression` does not survive into one. The test builds the shape by hand, with a table partitioned by `(id, years(ts))` and clustered on those same two expressions. The check is here because `keysSatisfy` already accepts that shape, so deriving the positions any other way would make the two disagree. A member whose expressions cover no operation key at all is skipped rather than projected to no position, which would put every partition into one. Only a partitioning whose expressions have no references can get there. A DSv2 scan cannot report one, because `supportsExpressions` refuses it, but nothing rejects it at `KeyedPartitioning` construction. A partitioning that satisfies the distribution is kept as it is when nothing is left for a node to do, which holds in two ways. Either the projection drops no position, so `satisfies` is not the over-claim this fix is about. Or a position is dropped but the projection merges nothing, so every operation key already lives on a single partition. Keeping the partitioning is then better than projecting, because `KeyedPartitioning([id, name])` and `KeyedPartitioning([id])` describe the same number of partitions and only the first lets a downstream operator co-partition on `name` too. This is where the collapse gives the single insertion point a new way to be wrong, namely inserting a node that merges nothing, which cannot happen on `master`, where a grouped partitioning got no node at all. That question is asked of every member of the child's partitioning, not just of the one a node would be built from, because a child can report a whole `PartitioningCollection` and its members can disagree about which positions are operation keys. An inner join is where they do. Its `outputPartitioning` is the two sides' partitionings, and unlike `AliasAwareOutputExpression` it does not enumerate the mixed combinations, so a window keyed on one side's first key column and the other side's remaining ones sees one member covering position 0 and one covering the rest. The first needs no node, the second would coalesce for nothing. Among the members that do need a node, the one whose projection leaves the most partitions is used, and that answer is exact rather than a heuristic. Only the position sets not contained in another one have to be projected. Projecting to fewer positions can merge partitions but never split them, so a contained set can never leave more partitions than the set containing it, and can only tie. Dropping it therefore costs no parallelism, and on a tie it settles the choice toward the wider set, which still names the keys the narrower one would have dropped. In the ordinary case one set contains all the others and a single projection settles it, and only sets that genuinely disagree, neither containing the other, each cost one. That matters because the projection is the expensive step here, since `KeyedPartitioning.projectKeys` allocates a row per input partition and `InternalRowComparableWrapper.hashCode` is uncached. So the count is memoized per position set as well, a set covering every position needs no projection at all, being the identity on the key values, and a single surviving candidate with no required count needs none either, which is the shape the default config produces. Keeping one member per position set costs nothing either, because `PartitioningCollection` guarantees its members share the `partitionKeys` reference and their arity, so position `i` addresses the same key column in all of them. `Distribution.requiredNumPartitions` needs care, because a `GroupPartitionsExec` derives its number of partitions from the partition keys it is handed rather than from the operator. The requirement is therefore checked against the count such a node *would* produce, not against the count the partitioning happens to have now, and it filters the candidates rather than vetoing the winner. One that would land on the required number must not lose the ranking to one that cannot honour it and send the whole child to a shuffle instead. As-is satisfaction goes through `satisfies`, which enforces the count on its own. For an operator that co-partitions more than one child no projection is done here, because that belongs to the multi-child block below, which a storage-partitioned join reaches through `checkKeyGroupCompatible` and anything else through `withJoinKeyPositions`. Projecting inline as well would leave that block deriving positions from an already projected partitioning and applying them to the unprojected keys. `KeyedPartitioning.satisfies` is not touched, so nothing outside `EnsureRequirements` changes behaviour. It does still answer `true` for a partitioning that needs a projection first, which means `ValidateRequirements` cannot catch a missing `GroupPartitionsExec`. Giving that check the strict test directly, without changing what `satisfies` answers, is a follow-up we are working on. The `OrderedDistribution` arm also loses a `MatchError`. It tested that the partition keys are sorted with `partitionKeys.sliding(2)`, which yields one short window for a single-key partitioning, and `case Seq(k1, k2)` cannot match it, so planning threw. A v2 table whose rows all share one partition value reports such a partitioning, since `DataSourceV2ScanExecBase` has no single-partition short-circuit, and with `v2BucketingAllowSorting` on, a global sort on the partition key reaches this branch. The test is now a zip of the keys with their successors, which is vacuously true for one key. Pre-existing, but the branch is rewritten here anyway. Two scaladocs are corrected as well. `KeyedPartitioning` taught `isGrouped` as the axis this PR replaces. `GroupPartitionsExec.joinKeyPositions` described its projection as being "for join compatibility", and it now carries the projection for a single-child operator too. ### Why are the changes needed? With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, `KeyedPartitioning.keysSatisfy` only requires that some operation key overlaps the partition attributes. A partitioning grouped on `(id, name)` therefore reports that it satisfies `ClusteredDistribution([id])` while rows sharing an `id` still sit on separate partitions. A storage-partitioned join projects the keys down to the operation keys in `checkKeyGroupCompatible`, and for a non-join operator nothing did. `isGrouped` is the wrong thing to classify on, because it only says the *full* partition keys are unique and says nothing about whether the *projected* keys are. A `GroupPartitionsExec` is needed either to coalesce duplicate partition keys, or to project down to the operation keys, or both, and splitting by provenance put those two reasons in different branches. Both branches returned wrong results. 1. A grouped partitioning over a superset of the operation keys got no node at all, so a top-k window over `PARTITION BY id` on an `(id, name)`-partitioned table surfaced `id=1` twice, once per `(1,'aa')` and `(1,'bb')` partition. 2. A non-grouped partitioning over a superset got a node without a projection, so it coalesced by the full partition keys and left the operation key split. Any source reporting more than one split per partition value hits this. `SUM(price) OVER (PARTITION BY id)` over the same table with two splits for `(1,'aa')` returned 25.0 and 20.0 instead of 45.0. Both reach back to 4.2.0, where `GroupPartitionsExec` and this classification were introduced. A reduced storage-partitioned join reaches the same wrong result through a third shape. Joining an `identity(ts)`-partitioned table to a `years(ts)`-partitioned one under `v2BucketingAllowCompatibleTransforms` leaves both sides grouped on `(year, bucket)`, so two rows sharing a `ts` in different buckets sit on separate partitions. `SUM(v) OVER (PARTITION BY ts)` then returns 10 and 20 on `master` where the answer is 30 and 30, and the projection this PR inserts is what merges the two partitions. Before #58420 that same query threw `ClassCastException` at planning once the keys were read, so the wrong answer only became observable when that fix landed. ### Does this PR introduce _any_ user-facing change? Yes, it fixes a data correctness issue. With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, a single-child operator whose keys are a subset of the partition keys now produces correct results, namely a window `PARTITION BY` and the single-pass aggregate shapes (`FlatMapGroupsInBatchExec`, `ArrowAggregatePythonExec`, `MapGroupsExec`). A two-phase SQL aggregate was already correct, because its partial `HashAggregate` is a `PartitioningPreservingUnaryExecNode`, so it narrows `KP([id, name])` to `KP([id])` before the final aggregate sees it. A cogroup is unaffected either way, because the projection there is left to the multi-child block, exactly as before. Two further changes are not gated on that config. The `requiredNumPartitions` rule applies whether `allowKeysSubsetOfPartitionKeys` is on or off, since a scan still needs `spark.sql.sources.v2.bucketing.enabled` to report a `KeyedPartitioning` at all, and `master` has no count check on the grouping path. A non-grouped `KeyedPartitioning` with 3 partitions and 2 distinct keys under `ClusteredDistribution([k], requiredNumPartitions = Some(3))` got a `GroupPartitionsExec` with 2 partitions on `master` and now gets a shuffle with 3. I could not find a query where such a distribution meets a `KeyedPartitioning` today, so this is robustness rather than a reachable wrong result. Only `StatefulOperatorPartitioning` and `AQEUtils` ever set the requirement. The `AQEUtils` one fires only over a `HashPartitioning` child. `StatefulOperatorPartitioning` sets it through a plain `ClusteredDistribution` when `spark.sql.streaming.statefulOperator.useStrictDistribution` is off, and a streaming scan never reports a `KeyedPartitioning`, because `MicroBatchScanExec`, `ContinuousScanExec` and `RealTimeStreamScanExec` all leave `keyGroupedPartitioning` at `None`. The initial-state child of `flatMapGroupsWithState` and `transformWithState` is a batch plan though, so it can be a scan that does. That operator co-partitions its two children, so the shape is the multi-child block's to decide either way, and what changes here is only that a candidate which cannot honour the count no longer wins the resolution. The requirement is part of the `ClusteredDistribution` contract though, the two could meet more widely as this area grows, and honouring it costs nothing where nobody asks for a count, so it seemed the wrong thing to keep ignoring. The `MatchError` fix is gated on `v2BucketingAllowSorting` instead, which is also off by default. `explain` gains one label where the new projection happens. A `GroupPartitionsExec` inserted for a single-child operator now carries `joinKeyPositions`, so the node prints `JoinKeyPositions: [...]` where it printed nothing before. The name is historical, and the scaladoc now says the projection is not join-specific. This needs `allowKeysSubsetOfPartitionKeys` on, so no golden file moves. ### How was this patch tested? Added regression tests in `KeyGroupedPartitioningSuite`: - window top-k over `PARTITION BY` a subset of the partition keys, for both `PARTITION BY id` and the duplicated `PARTITION BY id, id` - window top-k over union output partitioning - a plain window over a subset of the partition keys on a non-grouped `KeyedPartitioning`, asserting the inserted node projects to the operation key rather than only coalescing - no `GroupPartitionsExec` and no shuffle when projecting to the operation keys merges nothing - a window over an inner join's two-member `PartitioningCollection`, keyed on the left side's first key column and the right side's remaining two, asserting no node is inserted because the left member needs none, even though the right one covers more operation keys - a window over a join that reduced one side's keys onto the other side's key space, asserting the two rows sharing a `ts` end up on one partition and in `EnsureRequirementsSuite`: - a `FlatMapCoGroupsInPandasExec` over `(n, i)`-partitioned children grouped on `i`, asserting both sides are grouped on `i` and not on `n` - a grouped `KeyedPartitioning` whose count differs from `requiredNumPartitions`, asserting the count is still honoured with a shuffle - an `(n, i)`-partitioned `KeyedPartitioning` whose count matches `requiredNumPartitions` but which needs a projection, asserting it falls back to a shuffle rather than to a node that would break the count it just passed - a projecting `KeyedPartitioning` whose *post-projection* count matches `requiredNumPartitions`, asserting it still groups on the operation key with no shuffle - a non-grouped `KeyedPartitioning` whose *post-grouping* count matches `requiredNumPartitions`, asserting it still groups without a shuffle - the same count rule with `allowKeysSubsetOfPartitionKeys` left at its default, asserting the shuffle - an `(id, years(ts))`-partitioned `KeyedPartitioning` clustered on those same two expressions, with and without `requireAllClusterKeys`, asserting no node is inserted when a cluster key is the partition expression itself - a `(bucket(4, a), b)`-partitioned `KeyedPartitioning` clustered on `a` alone, asserting the node projects to the bucket position. This is the one test where the reference-level match decides a kept position, and it is the shape the single-reference soundness argument is about. `master` inserts no node at all here, so the partitions sharing a bucket stay apart. - a single-partition `KeyedPartitioning` under `OrderedDistribution`, asserting planning no longer throws a `MatchError` - a `KeyedPartitioning` with no partition expressions, asserting it is still kept as it is, since without that guard it reaches the shuffle branch, where `UnspecifiedDistribution.createPartitioning` throws - a `KeyedPartitioning` whose expressions reference no column, asserting the child is shuffled rather than projected to no position at all - a `PartitioningCollection` whose two members cover a different number of operation keys, asserting the wider one supplies the projection - two members whose position sets are nested and whose projections leave the same number of partitions, asserting the containing set still wins, so the projection keeps naming the key the other would have dropped - two members whose position sets are nested, where only the narrower one's projection lands on `requiredNumPartitions`, asserting it is used rather than pruned and lost to the wider one - two members whose position sets are not nested and where the narrower one leaves more partitions, asserting the narrower projection wins over the wider coverage - two members covering one position each whose projections leave different numbers of partitions, asserting the one leaving the most supplies the projection - two members covering one position each whose projections leave the same number of partitions, asserting the one the child reports first wins, in both collection orders and in `ProjectedOrderingAndPartitioningSuite` a grouped and collapsed `KeyedPartitioning`, asserting `keysMaySatisfy` accepts it where `mayGroupToSatisfy` refuses it, because a grouped partitioning has no duplicate keys left for a node to coalesce. Seventeen of the twenty-four fail without the production change in this commit, measured on `master`. Those are the four window tests, the reference-free expressions, the transform position, the `MatchError`, four of the six `requiredNumPartitions` tests, all five multi-member ones and the `keysMaySatisfy` one, which does not even compile there. The other seven pass already and guard this PR's own insertion decision. Removing the condition each one covers makes it fail, with one exception worth naming. `the candidate covering the most operation keys wins` survives the removal of either the containment prune or the ranking, because either mechanism alone picks the same winner, so it earns its place by documenting the shape rather than by pinning a single condition. The join test is one of the seven. The base leaves that plan alone too, and it is here because an earlier revision of this PR inserted a node there. Removing the `isCoPartitioned` guard also fails two pre-existing SPJ tests. `DistributionSuite` and `ShuffleSpecSuite` pass with 29 tests, and `KeyGroupedPartitioningSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite` and `PlannerSuite` pass with 295. The window `PARTITION BY` tests come from #58245. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Co-authored-by: Xiduo You <ulyssesyouapache.org> Closes#58262 from peter-toth/SPARK-58968-collapse-satisfies-classification. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
… for non-join operators ### What changes were proposed in this pull request? This builds on two changes that are now on master. #58351 (SPARK-59057) renamed `KeyedPartitioning.isNarrowed` to `isCollapsed` and split `groupedSatisfies` into `keysSatisfy` and `mayGroupToSatisfy`. Two things here follow from that. The classification asks whether a member can satisfy the distribution once a `GroupPartitionsExec` is allowed, which is `keysSatisfy` for a grouped member and `mayGroupToSatisfy` for a non-grouped one, since only the second may coalesce duplicate keys and only it needs the collapse permission. That composition is a new `KeyedPartitioning.keysMaySatisfy`, which keeps `keysSatisfy` private. And the tests that build a `KeyedPartitioning` state `isCollapsed` explicitly, because that parameter lost its default. #58420 (SPARK-59120) made every reader of a `KeyedPartitioning`'s partition keys take its types from `keyDataTypes`, the schema the keys were written under, rather than from the partition expressions. This PR projects those keys on more paths, so it needs that fix underneath it. Without it, projecting the keys of a partitioning whose keys a reducer rewrote throws `ClassCastException` at planning. This is an alternative to #58245, which fixes the same JIRA by adding the projection to one of the two branches below. `EnsureRequirements` split a child's `KeyedPartitioning`s by `isGrouped` and then had two branches that each had to insert a `GroupPartitionsExec`. This PR classifies by what still has to happen to the data instead. - `splitKeyedPartitionings` now takes the required distribution and answers two questions, in this order. Whether a non-`KeyedPartitioning` member already satisfies it, and if not, how a `KeyedPartitioning` member can. As it is, or after a `GroupPartitionsExec` projecting to the partition expression positions returned with it, with `None` positions when the node only has to coalesce duplicate partition keys. At most one partitioning comes back, because the caller acts on a single one of them. The order is deliberate, since the keyed half is the one that projects partition keys, so it only runs when no plain member is enough. - A new `clusterKeyPositions` helper derives those positions from the required clustering, and a new `KeyedPartitioning.numPartitionsProjectedOn` answers how many partitions a projection onto them would leave. - The four-way match collapses to three cases, because the two arms that each had to insert a `GroupPartitionsExec` become one. It now matches on the distribution and the resolution together, since two of the three arms are decided by the resolution rather than by the distribution. The `OrderedDistribution` arm keeps its own insertion, as on `master`. `clusterKeyPositions` keeps a partition expression when it is one of the operation keys. `keysSatisfy` recognises that at the *reference* level, where a `bucket(4, a)` transform covers the cluster key `a`, and also at the *expression* level, where the cluster key is the partition expression itself. Both are honoured here, which is why the positions are derived from the clustering rather than taken from `KeyedShuffleSpec.keyPositions`. That answers only the first, which is the right question for a storage-partitioned join but would drop a partition expression that is itself an operation key. The expression-level test is what keeps a position whose expression is clustered on while its references are not. No production path builds such a clustering today. `V2ExpressionUtils.toCatalystTransformOpt` maps `IdentityTransform` to the resolved attribute itself, so the reference-level test matches the same position anyway, and `DistributionAndOrderingUtils.prepareQuery` maps `resolveTransformExpression` over a write's clustering, so a `TransformExpression` does not survive into one. The test builds the shape by hand, with a table partitioned by `(id, years(ts))` and clustered on those same two expressions. The check is here because `keysSatisfy` already accepts that shape, so deriving the positions any other way would make the two disagree. A member whose expressions cover no operation key at all is skipped rather than projected to no position, which would put every partition into one. Only a partitioning whose expressions have no references can get there. A DSv2 scan cannot report one, because `supportsExpressions` refuses it, but nothing rejects it at `KeyedPartitioning` construction. A partitioning that satisfies the distribution is kept as it is when nothing is left for a node to do, which holds in two ways. Either the projection drops no position, so `satisfies` is not the over-claim this fix is about. Or a position is dropped but the projection merges nothing, so every operation key already lives on a single partition. Keeping the partitioning is then better than projecting, because `KeyedPartitioning([id, name])` and `KeyedPartitioning([id])` describe the same number of partitions and only the first lets a downstream operator co-partition on `name` too. This is where the collapse gives the single insertion point a new way to be wrong, namely inserting a node that merges nothing, which cannot happen on `master`, where a grouped partitioning got no node at all. That question is asked of every member of the child's partitioning, not just of the one a node would be built from, because a child can report a whole `PartitioningCollection` and its members can disagree about which positions are operation keys. An inner join is where they do. Its `outputPartitioning` is the two sides' partitionings, and unlike `AliasAwareOutputExpression` it does not enumerate the mixed combinations, so a window keyed on one side's first key column and the other side's remaining ones sees one member covering position 0 and one covering the rest. The first needs no node, the second would coalesce for nothing. Among the members that do need a node, the one whose projection leaves the most partitions is used, and that answer is exact rather than a heuristic. Only the position sets not contained in another one have to be projected. Projecting to fewer positions can merge partitions but never split them, so a contained set can never leave more partitions than the set containing it, and can only tie. Dropping it therefore costs no parallelism, and on a tie it settles the choice toward the wider set, which still names the keys the narrower one would have dropped. In the ordinary case one set contains all the others and a single projection settles it, and only sets that genuinely disagree, neither containing the other, each cost one. That matters because the projection is the expensive step here, since `KeyedPartitioning.projectKeys` allocates a row per input partition and `InternalRowComparableWrapper.hashCode` is uncached. So the count is memoized per position set as well, a set covering every position needs no projection at all, being the identity on the key values, and a single surviving candidate with no required count needs none either, which is the shape the default config produces. Keeping one member per position set costs nothing either, because `PartitioningCollection` guarantees its members share the `partitionKeys` reference and their arity, so position `i` addresses the same key column in all of them. `Distribution.requiredNumPartitions` needs care, because a `GroupPartitionsExec` derives its number of partitions from the partition keys it is handed rather than from the operator. The requirement is therefore checked against the count such a node *would* produce, not against the count the partitioning happens to have now, and it filters the candidates rather than vetoing the winner. One that would land on the required number must not lose the ranking to one that cannot honour it and send the whole child to a shuffle instead. As-is satisfaction goes through `satisfies`, which enforces the count on its own. For an operator that co-partitions more than one child no projection is done here, because that belongs to the multi-child block below, which a storage-partitioned join reaches through `checkKeyGroupCompatible` and anything else through `withJoinKeyPositions`. Projecting inline as well would leave that block deriving positions from an already projected partitioning and applying them to the unprojected keys. `KeyedPartitioning.satisfies` is not touched, so nothing outside `EnsureRequirements` changes behaviour. It does still answer `true` for a partitioning that needs a projection first, which means `ValidateRequirements` cannot catch a missing `GroupPartitionsExec`. Giving that check the strict test directly, without changing what `satisfies` answers, is a follow-up we are working on. The `OrderedDistribution` arm also loses a `MatchError`. It tested that the partition keys are sorted with `partitionKeys.sliding(2)`, which yields one short window for a single-key partitioning, and `case Seq(k1, k2)` cannot match it, so planning threw. A v2 table whose rows all share one partition value reports such a partitioning, since `DataSourceV2ScanExecBase` has no single-partition short-circuit, and with `v2BucketingAllowSorting` on, a global sort on the partition key reaches this branch. The test is now a zip of the keys with their successors, which is vacuously true for one key. Pre-existing, but the branch is rewritten here anyway. Two scaladocs are corrected as well. `KeyedPartitioning` taught `isGrouped` as the axis this PR replaces. `GroupPartitionsExec.joinKeyPositions` described its projection as being "for join compatibility", and it now carries the projection for a single-child operator too. ### Why are the changes needed? With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, `KeyedPartitioning.keysSatisfy` only requires that some operation key overlaps the partition attributes. A partitioning grouped on `(id, name)` therefore reports that it satisfies `ClusteredDistribution([id])` while rows sharing an `id` still sit on separate partitions. A storage-partitioned join projects the keys down to the operation keys in `checkKeyGroupCompatible`, and for a non-join operator nothing did. `isGrouped` is the wrong thing to classify on, because it only says the *full* partition keys are unique and says nothing about whether the *projected* keys are. A `GroupPartitionsExec` is needed either to coalesce duplicate partition keys, or to project down to the operation keys, or both, and splitting by provenance put those two reasons in different branches. Both branches returned wrong results. 1. A grouped partitioning over a superset of the operation keys got no node at all, so a top-k window over `PARTITION BY id` on an `(id, name)`-partitioned table surfaced `id=1` twice, once per `(1,'aa')` and `(1,'bb')` partition. 2. A non-grouped partitioning over a superset got a node without a projection, so it coalesced by the full partition keys and left the operation key split. Any source reporting more than one split per partition value hits this. `SUM(price) OVER (PARTITION BY id)` over the same table with two splits for `(1,'aa')` returned 25.0 and 20.0 instead of 45.0. Both reach back to 4.2.0, where `GroupPartitionsExec` and this classification were introduced. A reduced storage-partitioned join reaches the same wrong result through a third shape. Joining an `identity(ts)`-partitioned table to a `years(ts)`-partitioned one under `v2BucketingAllowCompatibleTransforms` leaves both sides grouped on `(year, bucket)`, so two rows sharing a `ts` in different buckets sit on separate partitions. `SUM(v) OVER (PARTITION BY ts)` then returns 10 and 20 on `master` where the answer is 30 and 30, and the projection this PR inserts is what merges the two partitions. Before #58420 that same query threw `ClassCastException` at planning once the keys were read, so the wrong answer only became observable when that fix landed. ### Does this PR introduce _any_ user-facing change? Yes, it fixes a data correctness issue. With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, a single-child operator whose keys are a subset of the partition keys now produces correct results, namely a window `PARTITION BY` and the single-pass aggregate shapes (`FlatMapGroupsInBatchExec`, `ArrowAggregatePythonExec`, `MapGroupsExec`). A two-phase SQL aggregate was already correct, because its partial `HashAggregate` is a `PartitioningPreservingUnaryExecNode`, so it narrows `KP([id, name])` to `KP([id])` before the final aggregate sees it. A cogroup is unaffected either way, because the projection there is left to the multi-child block, exactly as before. Two further changes are not gated on that config. The `requiredNumPartitions` rule applies whether `allowKeysSubsetOfPartitionKeys` is on or off, since a scan still needs `spark.sql.sources.v2.bucketing.enabled` to report a `KeyedPartitioning` at all, and `master` has no count check on the grouping path. A non-grouped `KeyedPartitioning` with 3 partitions and 2 distinct keys under `ClusteredDistribution([k], requiredNumPartitions = Some(3))` got a `GroupPartitionsExec` with 2 partitions on `master` and now gets a shuffle with 3. I could not find a query where such a distribution meets a `KeyedPartitioning` today, so this is robustness rather than a reachable wrong result. Only `StatefulOperatorPartitioning` and `AQEUtils` ever set the requirement. The `AQEUtils` one fires only over a `HashPartitioning` child. `StatefulOperatorPartitioning` sets it through a plain `ClusteredDistribution` when `spark.sql.streaming.statefulOperator.useStrictDistribution` is off, and a streaming scan never reports a `KeyedPartitioning`, because `MicroBatchScanExec`, `ContinuousScanExec` and `RealTimeStreamScanExec` all leave `keyGroupedPartitioning` at `None`. The initial-state child of `flatMapGroupsWithState` and `transformWithState` is a batch plan though, so it can be a scan that does. That operator co-partitions its two children, so the shape is the multi-child block's to decide either way, and what changes here is only that a candidate which cannot honour the count no longer wins the resolution. The requirement is part of the `ClusteredDistribution` contract though, the two could meet more widely as this area grows, and honouring it costs nothing where nobody asks for a count, so it seemed the wrong thing to keep ignoring. The `MatchError` fix is gated on `v2BucketingAllowSorting` instead, which is also off by default. `explain` gains one label where the new projection happens. A `GroupPartitionsExec` inserted for a single-child operator now carries `joinKeyPositions`, so the node prints `JoinKeyPositions: [...]` where it printed nothing before. The name is historical, and the scaladoc now says the projection is not join-specific. This needs `allowKeysSubsetOfPartitionKeys` on, so no golden file moves. ### How was this patch tested? Added regression tests in `KeyGroupedPartitioningSuite`: - window top-k over `PARTITION BY` a subset of the partition keys, for both `PARTITION BY id` and the duplicated `PARTITION BY id, id` - window top-k over union output partitioning - a plain window over a subset of the partition keys on a non-grouped `KeyedPartitioning`, asserting the inserted node projects to the operation key rather than only coalescing - no `GroupPartitionsExec` and no shuffle when projecting to the operation keys merges nothing - a window over an inner join's two-member `PartitioningCollection`, keyed on the left side's first key column and the right side's remaining two, asserting no node is inserted because the left member needs none, even though the right one covers more operation keys - a window over a join that reduced one side's keys onto the other side's key space, asserting the two rows sharing a `ts` end up on one partition and in `EnsureRequirementsSuite`: - a `FlatMapCoGroupsInPandasExec` over `(n, i)`-partitioned children grouped on `i`, asserting both sides are grouped on `i` and not on `n` - a grouped `KeyedPartitioning` whose count differs from `requiredNumPartitions`, asserting the count is still honoured with a shuffle - an `(n, i)`-partitioned `KeyedPartitioning` whose count matches `requiredNumPartitions` but which needs a projection, asserting it falls back to a shuffle rather than to a node that would break the count it just passed - a projecting `KeyedPartitioning` whose *post-projection* count matches `requiredNumPartitions`, asserting it still groups on the operation key with no shuffle - a non-grouped `KeyedPartitioning` whose *post-grouping* count matches `requiredNumPartitions`, asserting it still groups without a shuffle - the same count rule with `allowKeysSubsetOfPartitionKeys` left at its default, asserting the shuffle - an `(id, years(ts))`-partitioned `KeyedPartitioning` clustered on those same two expressions, with and without `requireAllClusterKeys`, asserting no node is inserted when a cluster key is the partition expression itself - a `(bucket(4, a), b)`-partitioned `KeyedPartitioning` clustered on `a` alone, asserting the node projects to the bucket position. This is the one test where the reference-level match decides a kept position, and it is the shape the single-reference soundness argument is about. `master` inserts no node at all here, so the partitions sharing a bucket stay apart. - a single-partition `KeyedPartitioning` under `OrderedDistribution`, asserting planning no longer throws a `MatchError` - a `KeyedPartitioning` with no partition expressions, asserting it is still kept as it is, since without that guard it reaches the shuffle branch, where `UnspecifiedDistribution.createPartitioning` throws - a `KeyedPartitioning` whose expressions reference no column, asserting the child is shuffled rather than projected to no position at all - a `PartitioningCollection` whose two members cover a different number of operation keys, asserting the wider one supplies the projection - two members whose position sets are nested and whose projections leave the same number of partitions, asserting the containing set still wins, so the projection keeps naming the key the other would have dropped - two members whose position sets are nested, where only the narrower one's projection lands on `requiredNumPartitions`, asserting it is used rather than pruned and lost to the wider one - two members whose position sets are not nested and where the narrower one leaves more partitions, asserting the narrower projection wins over the wider coverage - two members covering one position each whose projections leave different numbers of partitions, asserting the one leaving the most supplies the projection - two members covering one position each whose projections leave the same number of partitions, asserting the one the child reports first wins, in both collection orders and in `ProjectedOrderingAndPartitioningSuite` a grouped and collapsed `KeyedPartitioning`, asserting `keysMaySatisfy` accepts it where `mayGroupToSatisfy` refuses it, because a grouped partitioning has no duplicate keys left for a node to coalesce. Seventeen of the twenty-four fail without the production change in this commit, measured on `master`. Those are the four window tests, the reference-free expressions, the transform position, the `MatchError`, four of the six `requiredNumPartitions` tests, all five multi-member ones and the `keysMaySatisfy` one, which does not even compile there. The other seven pass already and guard this PR's own insertion decision. Removing the condition each one covers makes it fail, with one exception worth naming. `the candidate covering the most operation keys wins` survives the removal of either the containment prune or the ranking, because either mechanism alone picks the same winner, so it earns its place by documenting the shape rather than by pinning a single condition. The join test is one of the seven. The base leaves that plan alone too, and it is here because an earlier revision of this PR inserted a node there. Removing the `isCoPartitioned` guard also fails two pre-existing SPJ tests. `DistributionSuite` and `ShuffleSpecSuite` pass with 29 tests, and `KeyGroupedPartitioningSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite` and `PlannerSuite` pass with 295. The window `PARTITION BY` tests come from #58245. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Co-authored-by: Xiduo You <ulyssesyouapache.org> Closes#58262 from peter-toth/SPARK-58968-collapse-satisfies-classification. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 88d7ee1) Signed-off-by: Peter Toth <peter.toth@gmail.com>
… for non-join operators ### What changes were proposed in this pull request? This builds on two changes that are now on master. #58351 (SPARK-59057) renamed `KeyedPartitioning.isNarrowed` to `isCollapsed` and split `groupedSatisfies` into `keysSatisfy` and `mayGroupToSatisfy`. Two things here follow from that. The classification asks whether a member can satisfy the distribution once a `GroupPartitionsExec` is allowed, which is `keysSatisfy` for a grouped member and `mayGroupToSatisfy` for a non-grouped one, since only the second may coalesce duplicate keys and only it needs the collapse permission. That composition is a new `KeyedPartitioning.keysMaySatisfy`, which keeps `keysSatisfy` private. And the tests that build a `KeyedPartitioning` state `isCollapsed` explicitly, because that parameter lost its default. #58420 (SPARK-59120) made every reader of a `KeyedPartitioning`'s partition keys take its types from `keyDataTypes`, the schema the keys were written under, rather than from the partition expressions. This PR projects those keys on more paths, so it needs that fix underneath it. Without it, projecting the keys of a partitioning whose keys a reducer rewrote throws `ClassCastException` at planning. This is an alternative to #58245, which fixes the same JIRA by adding the projection to one of the two branches below. `EnsureRequirements` split a child's `KeyedPartitioning`s by `isGrouped` and then had two branches that each had to insert a `GroupPartitionsExec`. This PR classifies by what still has to happen to the data instead. - `splitKeyedPartitionings` now takes the required distribution and answers two questions, in this order. Whether a non-`KeyedPartitioning` member already satisfies it, and if not, how a `KeyedPartitioning` member can. As it is, or after a `GroupPartitionsExec` projecting to the partition expression positions returned with it, with `None` positions when the node only has to coalesce duplicate partition keys. At most one partitioning comes back, because the caller acts on a single one of them. The order is deliberate, since the keyed half is the one that projects partition keys, so it only runs when no plain member is enough. - A new `clusterKeyPositions` helper derives those positions from the required clustering, and a new `KeyedPartitioning.numPartitionsProjectedOn` answers how many partitions a projection onto them would leave. - The four-way match collapses to three cases, because the two arms that each had to insert a `GroupPartitionsExec` become one. It now matches on the distribution and the resolution together, since two of the three arms are decided by the resolution rather than by the distribution. The `OrderedDistribution` arm keeps its own insertion, as on `master`. `clusterKeyPositions` keeps a partition expression when it is one of the operation keys. `keysSatisfy` recognises that at the *reference* level, where a `bucket(4, a)` transform covers the cluster key `a`, and also at the *expression* level, where the cluster key is the partition expression itself. Both are honoured here, which is why the positions are derived from the clustering rather than taken from `KeyedShuffleSpec.keyPositions`. That answers only the first, which is the right question for a storage-partitioned join but would drop a partition expression that is itself an operation key. The expression-level test is what keeps a position whose expression is clustered on while its references are not. No production path builds such a clustering today. `V2ExpressionUtils.toCatalystTransformOpt` maps `IdentityTransform` to the resolved attribute itself, so the reference-level test matches the same position anyway, and `DistributionAndOrderingUtils.prepareQuery` maps `resolveTransformExpression` over a write's clustering, so a `TransformExpression` does not survive into one. The test builds the shape by hand, with a table partitioned by `(id, years(ts))` and clustered on those same two expressions. The check is here because `keysSatisfy` already accepts that shape, so deriving the positions any other way would make the two disagree. A member whose expressions cover no operation key at all is skipped rather than projected to no position, which would put every partition into one. Only a partitioning whose expressions have no references can get there. A DSv2 scan cannot report one, because `supportsExpressions` refuses it, but nothing rejects it at `KeyedPartitioning` construction. A partitioning that satisfies the distribution is kept as it is when nothing is left for a node to do, which holds in two ways. Either the projection drops no position, so `satisfies` is not the over-claim this fix is about. Or a position is dropped but the projection merges nothing, so every operation key already lives on a single partition. Keeping the partitioning is then better than projecting, because `KeyedPartitioning([id, name])` and `KeyedPartitioning([id])` describe the same number of partitions and only the first lets a downstream operator co-partition on `name` too. This is where the collapse gives the single insertion point a new way to be wrong, namely inserting a node that merges nothing, which cannot happen on `master`, where a grouped partitioning got no node at all. That question is asked of every member of the child's partitioning, not just of the one a node would be built from, because a child can report a whole `PartitioningCollection` and its members can disagree about which positions are operation keys. An inner join is where they do. Its `outputPartitioning` is the two sides' partitionings, and unlike `AliasAwareOutputExpression` it does not enumerate the mixed combinations, so a window keyed on one side's first key column and the other side's remaining ones sees one member covering position 0 and one covering the rest. The first needs no node, the second would coalesce for nothing. Among the members that do need a node, the one whose projection leaves the most partitions is used, and that answer is exact rather than a heuristic. Only the position sets not contained in another one have to be projected. Projecting to fewer positions can merge partitions but never split them, so a contained set can never leave more partitions than the set containing it, and can only tie. Dropping it therefore costs no parallelism, and on a tie it settles the choice toward the wider set, which still names the keys the narrower one would have dropped. In the ordinary case one set contains all the others and a single projection settles it, and only sets that genuinely disagree, neither containing the other, each cost one. That matters because the projection is the expensive step here, since `KeyedPartitioning.projectKeys` allocates a row per input partition and `InternalRowComparableWrapper.hashCode` is uncached. So the count is memoized per position set as well, a set covering every position needs no projection at all, being the identity on the key values, and a single surviving candidate with no required count needs none either, which is the shape the default config produces. Keeping one member per position set costs nothing either, because `PartitioningCollection` guarantees its members share the `partitionKeys` reference and their arity, so position `i` addresses the same key column in all of them. `Distribution.requiredNumPartitions` needs care, because a `GroupPartitionsExec` derives its number of partitions from the partition keys it is handed rather than from the operator. The requirement is therefore checked against the count such a node *would* produce, not against the count the partitioning happens to have now, and it filters the candidates rather than vetoing the winner. One that would land on the required number must not lose the ranking to one that cannot honour it and send the whole child to a shuffle instead. As-is satisfaction goes through `satisfies`, which enforces the count on its own. For an operator that co-partitions more than one child no projection is done here, because that belongs to the multi-child block below, which a storage-partitioned join reaches through `checkKeyGroupCompatible` and anything else through `withJoinKeyPositions`. Projecting inline as well would leave that block deriving positions from an already projected partitioning and applying them to the unprojected keys. `KeyedPartitioning.satisfies` is not touched, so nothing outside `EnsureRequirements` changes behaviour. It does still answer `true` for a partitioning that needs a projection first, which means `ValidateRequirements` cannot catch a missing `GroupPartitionsExec`. Giving that check the strict test directly, without changing what `satisfies` answers, is a follow-up we are working on. The `OrderedDistribution` arm also loses a `MatchError`. It tested that the partition keys are sorted with `partitionKeys.sliding(2)`, which yields one short window for a single-key partitioning, and `case Seq(k1, k2)` cannot match it, so planning threw. A v2 table whose rows all share one partition value reports such a partitioning, since `DataSourceV2ScanExecBase` has no single-partition short-circuit, and with `v2BucketingAllowSorting` on, a global sort on the partition key reaches this branch. The test is now a zip of the keys with their successors, which is vacuously true for one key. Pre-existing, but the branch is rewritten here anyway. Two scaladocs are corrected as well. `KeyedPartitioning` taught `isGrouped` as the axis this PR replaces. `GroupPartitionsExec.joinKeyPositions` described its projection as being "for join compatibility", and it now carries the projection for a single-child operator too. ### Why are the changes needed? With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, `KeyedPartitioning.keysSatisfy` only requires that some operation key overlaps the partition attributes. A partitioning grouped on `(id, name)` therefore reports that it satisfies `ClusteredDistribution([id])` while rows sharing an `id` still sit on separate partitions. A storage-partitioned join projects the keys down to the operation keys in `checkKeyGroupCompatible`, and for a non-join operator nothing did. `isGrouped` is the wrong thing to classify on, because it only says the *full* partition keys are unique and says nothing about whether the *projected* keys are. A `GroupPartitionsExec` is needed either to coalesce duplicate partition keys, or to project down to the operation keys, or both, and splitting by provenance put those two reasons in different branches. Both branches returned wrong results. 1. A grouped partitioning over a superset of the operation keys got no node at all, so a top-k window over `PARTITION BY id` on an `(id, name)`-partitioned table surfaced `id=1` twice, once per `(1,'aa')` and `(1,'bb')` partition. 2. A non-grouped partitioning over a superset got a node without a projection, so it coalesced by the full partition keys and left the operation key split. Any source reporting more than one split per partition value hits this. `SUM(price) OVER (PARTITION BY id)` over the same table with two splits for `(1,'aa')` returned 25.0 and 20.0 instead of 45.0. Both reach back to 4.2.0, where `GroupPartitionsExec` and this classification were introduced. A reduced storage-partitioned join reaches the same wrong result through a third shape. Joining an `identity(ts)`-partitioned table to a `years(ts)`-partitioned one under `v2BucketingAllowCompatibleTransforms` leaves both sides grouped on `(year, bucket)`, so two rows sharing a `ts` in different buckets sit on separate partitions. `SUM(v) OVER (PARTITION BY ts)` then returns 10 and 20 on `master` where the answer is 30 and 30, and the projection this PR inserts is what merges the two partitions. Before #58420 that same query threw `ClassCastException` at planning once the keys were read, so the wrong answer only became observable when that fix landed. ### Does this PR introduce _any_ user-facing change? Yes, it fixes a data correctness issue. With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, a single-child operator whose keys are a subset of the partition keys now produces correct results, namely a window `PARTITION BY` and the single-pass aggregate shapes (`FlatMapGroupsInBatchExec`, `ArrowAggregatePythonExec`, `MapGroupsExec`). A two-phase SQL aggregate was already correct, because its partial `HashAggregate` is a `PartitioningPreservingUnaryExecNode`, so it narrows `KP([id, name])` to `KP([id])` before the final aggregate sees it. A cogroup is unaffected either way, because the projection there is left to the multi-child block, exactly as before. Two further changes are not gated on that config. The `requiredNumPartitions` rule applies whether `allowKeysSubsetOfPartitionKeys` is on or off, since a scan still needs `spark.sql.sources.v2.bucketing.enabled` to report a `KeyedPartitioning` at all, and `master` has no count check on the grouping path. A non-grouped `KeyedPartitioning` with 3 partitions and 2 distinct keys under `ClusteredDistribution([k], requiredNumPartitions = Some(3))` got a `GroupPartitionsExec` with 2 partitions on `master` and now gets a shuffle with 3. I could not find a query where such a distribution meets a `KeyedPartitioning` today, so this is robustness rather than a reachable wrong result. Only `StatefulOperatorPartitioning` and `AQEUtils` ever set the requirement. The `AQEUtils` one fires only over a `HashPartitioning` child. `StatefulOperatorPartitioning` sets it through a plain `ClusteredDistribution` when `spark.sql.streaming.statefulOperator.useStrictDistribution` is off, and a streaming scan never reports a `KeyedPartitioning`, because `MicroBatchScanExec`, `ContinuousScanExec` and `RealTimeStreamScanExec` all leave `keyGroupedPartitioning` at `None`. The initial-state child of `flatMapGroupsWithState` and `transformWithState` is a batch plan though, so it can be a scan that does. That operator co-partitions its two children, so the shape is the multi-child block's to decide either way, and what changes here is only that a candidate which cannot honour the count no longer wins the resolution. The requirement is part of the `ClusteredDistribution` contract though, the two could meet more widely as this area grows, and honouring it costs nothing where nobody asks for a count, so it seemed the wrong thing to keep ignoring. The `MatchError` fix is gated on `v2BucketingAllowSorting` instead, which is also off by default. `explain` gains one label where the new projection happens. A `GroupPartitionsExec` inserted for a single-child operator now carries `joinKeyPositions`, so the node prints `JoinKeyPositions: [...]` where it printed nothing before. The name is historical, and the scaladoc now says the projection is not join-specific. This needs `allowKeysSubsetOfPartitionKeys` on, so no golden file moves. ### How was this patch tested? Added regression tests in `KeyGroupedPartitioningSuite`: - window top-k over `PARTITION BY` a subset of the partition keys, for both `PARTITION BY id` and the duplicated `PARTITION BY id, id` - window top-k over union output partitioning - a plain window over a subset of the partition keys on a non-grouped `KeyedPartitioning`, asserting the inserted node projects to the operation key rather than only coalescing - no `GroupPartitionsExec` and no shuffle when projecting to the operation keys merges nothing - a window over an inner join's two-member `PartitioningCollection`, keyed on the left side's first key column and the right side's remaining two, asserting no node is inserted because the left member needs none, even though the right one covers more operation keys - a window over a join that reduced one side's keys onto the other side's key space, asserting the two rows sharing a `ts` end up on one partition and in `EnsureRequirementsSuite`: - a `FlatMapCoGroupsInPandasExec` over `(n, i)`-partitioned children grouped on `i`, asserting both sides are grouped on `i` and not on `n` - a grouped `KeyedPartitioning` whose count differs from `requiredNumPartitions`, asserting the count is still honoured with a shuffle - an `(n, i)`-partitioned `KeyedPartitioning` whose count matches `requiredNumPartitions` but which needs a projection, asserting it falls back to a shuffle rather than to a node that would break the count it just passed - a projecting `KeyedPartitioning` whose *post-projection* count matches `requiredNumPartitions`, asserting it still groups on the operation key with no shuffle - a non-grouped `KeyedPartitioning` whose *post-grouping* count matches `requiredNumPartitions`, asserting it still groups without a shuffle - the same count rule with `allowKeysSubsetOfPartitionKeys` left at its default, asserting the shuffle - an `(id, years(ts))`-partitioned `KeyedPartitioning` clustered on those same two expressions, with and without `requireAllClusterKeys`, asserting no node is inserted when a cluster key is the partition expression itself - a `(bucket(4, a), b)`-partitioned `KeyedPartitioning` clustered on `a` alone, asserting the node projects to the bucket position. This is the one test where the reference-level match decides a kept position, and it is the shape the single-reference soundness argument is about. `master` inserts no node at all here, so the partitions sharing a bucket stay apart. - a single-partition `KeyedPartitioning` under `OrderedDistribution`, asserting planning no longer throws a `MatchError` - a `KeyedPartitioning` with no partition expressions, asserting it is still kept as it is, since without that guard it reaches the shuffle branch, where `UnspecifiedDistribution.createPartitioning` throws - a `KeyedPartitioning` whose expressions reference no column, asserting the child is shuffled rather than projected to no position at all - a `PartitioningCollection` whose two members cover a different number of operation keys, asserting the wider one supplies the projection - two members whose position sets are nested and whose projections leave the same number of partitions, asserting the containing set still wins, so the projection keeps naming the key the other would have dropped - two members whose position sets are nested, where only the narrower one's projection lands on `requiredNumPartitions`, asserting it is used rather than pruned and lost to the wider one - two members whose position sets are not nested and where the narrower one leaves more partitions, asserting the narrower projection wins over the wider coverage - two members covering one position each whose projections leave different numbers of partitions, asserting the one leaving the most supplies the projection - two members covering one position each whose projections leave the same number of partitions, asserting the one the child reports first wins, in both collection orders and in `ProjectedOrderingAndPartitioningSuite` a grouped and collapsed `KeyedPartitioning`, asserting `keysMaySatisfy` accepts it where `mayGroupToSatisfy` refuses it, because a grouped partitioning has no duplicate keys left for a node to coalesce. Seventeen of the twenty-four fail without the production change in this commit, measured on `master`. Those are the four window tests, the reference-free expressions, the transform position, the `MatchError`, four of the six `requiredNumPartitions` tests, all five multi-member ones and the `keysMaySatisfy` one, which does not even compile there. The other seven pass already and guard this PR's own insertion decision. Removing the condition each one covers makes it fail, with one exception worth naming. `the candidate covering the most operation keys wins` survives the removal of either the containment prune or the ranking, because either mechanism alone picks the same winner, so it earns its place by documenting the shape rather than by pinning a single condition. The join test is one of the seven. The base leaves that plan alone too, and it is here because an earlier revision of this PR inserted a node there. Removing the `isCoPartitioned` guard also fails two pre-existing SPJ tests. `DistributionSuite` and `ShuffleSpecSuite` pass with 29 tests, and `KeyGroupedPartitioningSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite` and `PlannerSuite` pass with 295. The window `PARTITION BY` tests come from #58245. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Co-authored-by: Xiduo You <ulyssesyouapache.org> Closes#58262 from peter-toth/SPARK-58968-collapse-satisfies-classification. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit 88d7ee1) Signed-off-by: Peter Toth <peter.toth@gmail.com>
…tness for non-join operators ### What changes were proposed in this pull request? This builds on two changes that are now on master. #58351 (SPARK-59057) renamed `KeyedPartitioning.isNarrowed` to `isCollapsed` and split `groupedSatisfies` into `keysSatisfy` and `mayGroupToSatisfy`. Two things here follow from that. The classification asks whether a member can satisfy the distribution once a `GroupPartitionsExec` is allowed, which is `keysSatisfy` for a grouped member and `mayGroupToSatisfy` for a non-grouped one, since only the second may coalesce duplicate keys and only it needs the collapse permission. That composition is a new `KeyedPartitioning.keysMaySatisfy`, which keeps `keysSatisfy` private. And the tests that build a `KeyedPartitioning` state `isCollapsed` explicitly, because that parameter lost its default. #58420 (SPARK-59120) made every reader of a `KeyedPartitioning`'s partition keys take its types from `keyDataTypes`, the schema the keys were written under, rather than from the partition expressions. This PR projects those keys on more paths, so it needs that fix underneath it. Without it, projecting the keys of a partitioning whose keys a reducer rewrote throws `ClassCastException` at planning. This is an alternative to #58245, which fixes the same JIRA by adding the projection to one of the two branches below. `EnsureRequirements` split a child's `KeyedPartitioning`s by `isGrouped` and then had two branches that each had to insert a `GroupPartitionsExec`. This PR classifies by what still has to happen to the data instead. - `splitKeyedPartitionings` now takes the required distribution and answers two questions, in this order. Whether a non-`KeyedPartitioning` member already satisfies it, and if not, how a `KeyedPartitioning` member can. As it is, or after a `GroupPartitionsExec` projecting to the partition expression positions returned with it, with `None` positions when the node only has to coalesce duplicate partition keys. At most one partitioning comes back, because the caller acts on a single one of them. The order is deliberate, since the keyed half is the one that projects partition keys, so it only runs when no plain member is enough. - A new `clusterKeyPositions` helper derives those positions from the required clustering, and a new `KeyedPartitioning.numPartitionsProjectedOn` answers how many partitions a projection onto them would leave. - The four-way match collapses to three cases, because the two arms that each had to insert a `GroupPartitionsExec` become one. It now matches on the distribution and the resolution together, since two of the three arms are decided by the resolution rather than by the distribution. The `OrderedDistribution` arm keeps its own insertion, as on `master`. `clusterKeyPositions` keeps a partition expression when it is one of the operation keys. `keysSatisfy` recognises that at the *reference* level, where a `bucket(4, a)` transform covers the cluster key `a`, and also at the *expression* level, where the cluster key is the partition expression itself. Both are honoured here, which is why the positions are derived from the clustering rather than taken from `KeyedShuffleSpec.keyPositions`. That answers only the first, which is the right question for a storage-partitioned join but would drop a partition expression that is itself an operation key. The expression-level test is what keeps a position whose expression is clustered on while its references are not. No production path builds such a clustering today. `V2ExpressionUtils.toCatalystTransformOpt` maps `IdentityTransform` to the resolved attribute itself, so the reference-level test matches the same position anyway, and `DistributionAndOrderingUtils.prepareQuery` maps `resolveTransformExpression` over a write's clustering, so a `TransformExpression` does not survive into one. The test builds the shape by hand, with a table partitioned by `(id, years(ts))` and clustered on those same two expressions. The check is here because `keysSatisfy` already accepts that shape, so deriving the positions any other way would make the two disagree. A member whose expressions cover no operation key at all is skipped rather than projected to no position, which would put every partition into one. Only a partitioning whose expressions have no references can get there. A DSv2 scan cannot report one, because `supportsExpressions` refuses it, but nothing rejects it at `KeyedPartitioning` construction. A partitioning that satisfies the distribution is kept as it is when nothing is left for a node to do, which holds in two ways. Either the projection drops no position, so `satisfies` is not the over-claim this fix is about. Or a position is dropped but the projection merges nothing, so every operation key already lives on a single partition. Keeping the partitioning is then better than projecting, because `KeyedPartitioning([id, name])` and `KeyedPartitioning([id])` describe the same number of partitions and only the first lets a downstream operator co-partition on `name` too. This is where the collapse gives the single insertion point a new way to be wrong, namely inserting a node that merges nothing, which cannot happen on `master`, where a grouped partitioning got no node at all. That question is asked of every member of the child's partitioning, not just of the one a node would be built from, because a child can report a whole `PartitioningCollection` and its members can disagree about which positions are operation keys. An inner join is where they do. Its `outputPartitioning` is the two sides' partitionings, and unlike `AliasAwareOutputExpression` it does not enumerate the mixed combinations, so a window keyed on one side's first key column and the other side's remaining ones sees one member covering position 0 and one covering the rest. The first needs no node, the second would coalesce for nothing. Among the members that do need a node, the one whose projection leaves the most partitions is used, and that answer is exact rather than a heuristic. Only the position sets not contained in another one have to be projected. Projecting to fewer positions can merge partitions but never split them, so a contained set can never leave more partitions than the set containing it, and can only tie. Dropping it therefore costs no parallelism, and on a tie it settles the choice toward the wider set, which still names the keys the narrower one would have dropped. In the ordinary case one set contains all the others and a single projection settles it, and only sets that genuinely disagree, neither containing the other, each cost one. That matters because the projection is the expensive step here, since `KeyedPartitioning.projectKeys` allocates a row per input partition and `InternalRowComparableWrapper.hashCode` is uncached. So the count is memoized per position set as well, a set covering every position needs no projection at all, being the identity on the key values, and a single surviving candidate with no required count needs none either, which is the shape the default config produces. Keeping one member per position set costs nothing either, because `PartitioningCollection` guarantees its members share the `partitionKeys` reference and their arity, so position `i` addresses the same key column in all of them. `Distribution.requiredNumPartitions` needs care, because a `GroupPartitionsExec` derives its number of partitions from the partition keys it is handed rather than from the operator. The requirement is therefore checked against the count such a node *would* produce, not against the count the partitioning happens to have now, and it filters the candidates rather than vetoing the winner. One that would land on the required number must not lose the ranking to one that cannot honour it and send the whole child to a shuffle instead. As-is satisfaction goes through `satisfies`, which enforces the count on its own. For an operator that co-partitions more than one child no projection is done here, because that belongs to the multi-child block below, which a storage-partitioned join reaches through `checkKeyGroupCompatible` and anything else through `withJoinKeyPositions`. Projecting inline as well would leave that block deriving positions from an already projected partitioning and applying them to the unprojected keys. `KeyedPartitioning.satisfies` is not touched, so nothing outside `EnsureRequirements` changes behaviour. It does still answer `true` for a partitioning that needs a projection first, which means `ValidateRequirements` cannot catch a missing `GroupPartitionsExec`. Giving that check the strict test directly, without changing what `satisfies` answers, is a follow-up we are working on. The `OrderedDistribution` arm also loses a `MatchError`. It tested that the partition keys are sorted with `partitionKeys.sliding(2)`, which yields one short window for a single-key partitioning, and `case Seq(k1, k2)` cannot match it, so planning threw. A v2 table whose rows all share one partition value reports such a partitioning, since `DataSourceV2ScanExecBase` has no single-partition short-circuit, and with `v2BucketingAllowSorting` on, a global sort on the partition key reaches this branch. The test is now a zip of the keys with their successors, which is vacuously true for one key. Pre-existing, but the branch is rewritten here anyway. Two scaladocs are corrected as well. `KeyedPartitioning` taught `isGrouped` as the axis this PR replaces. `GroupPartitionsExec.joinKeyPositions` described its projection as being "for join compatibility", and it now carries the projection for a single-child operator too. #### Backport to `branch-4.2` Everything above is #58262's description, unchanged. This is what differs on this branch. **`keysMaySatisfy` is not added, and `EnsureRequirements` asks `groupedSatisfies` directly.** #58351 split `groupedSatisfies` into `keysSatisfy`, the key matching, and `mayGroupToSatisfy`, that matching plus permission to coalesce a collapsed partitioning, and `keysMaySatisfy` composes the two. Neither the split nor the permission is on this branch: `isCollapsed` and its gate arrive in 4.3.0 with SPARK-46367, and this branch's `groupedSatisfies` is exactly what `master` calls `keysSatisfy`. So `keysMaySatisfy`'s two arms coincide here. The class doc describes the two questions this branch has rather than `master`'s four. **Four smaller deviations.** - `nonGroupedSatisfies` becomes private, as on `master`: the rewrite removes its last caller outside the class. - `splitKeyedPartitionings` keeps a local recursion instead of `PartitioningCollection.flatten`, which is not on this branch. - The shared `exprA` .. `exprD` fixtures in `EnsureRequirementsSuite` become attributes, where this branch has `Literal`s, because the new tests match partition expressions against cluster keys. SPARK-57038 made the same change on `master`, and its absence here is also why this branch's planner reads the partition expressions through `collectLeaves()` where `master` reads `references`. - The config is spelled `V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS` here, the `JOIN_` was dropped from the name later. Read `v2BucketingAllowKeysSubsetOfPartitionKeys` above as that. **Two of the 24 tests are not carried over**, because the shapes they cover do not exist on this branch: - `SPARK-58968: keysMaySatisfy asks the collapse gate of a non-grouped partitioning only`, in `ProjectedOrderingAndPartitioningSuite`. It contrasts `keysMaySatisfy` with `mayGroupToSatisfy` under the collapse gate, and none of the three is here, so that suite is untouched. - `SPARK-58968: window top-k over union output partitioning coalesces partitions`. `UnionExec` does not merge `KeyedPartitioning`s on this branch, so the union reports no keyed partitioning for the window to see and no `GroupPartitionsExec` is inserted. The two `SPARK-46367` test tidy-ups do not apply either, since those tests are not on this branch. **The measurements below are `master`'s.** On this branch, 22 tests and **14 of them fail on the base** at `773f49a0456`: the three window tests, the transform position, the `MatchError`, four of the six `requiredNumPartitions` tests, and all five multi-member ones. `SPARK-58968: a partitioning that covers no operation key is shuffled, not projected` passes on this branch's base, where it failed on `master`; it stays as a guard on the skip path. Ran `DistributionSuite` and `ShuffleSpecSuite` with 20 tests, and `KeyGroupedPartitioningSuite`, `EnsureRequirementsSuite`, `ValidateRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite` and `PlannerSuite` with 263. `dev/lint-scala` is clean. **Nothing goes below this branch.** `GroupPartitionsExec.scala` does not exist on `branch-4.1`, so 4.1 and lower are unaffected. ### Why are the changes needed? With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, `KeyedPartitioning.keysSatisfy` only requires that some operation key overlaps the partition attributes. A partitioning grouped on `(id, name)` therefore reports that it satisfies `ClusteredDistribution([id])` while rows sharing an `id` still sit on separate partitions. A storage-partitioned join projects the keys down to the operation keys in `checkKeyGroupCompatible`, and for a non-join operator nothing did. `isGrouped` is the wrong thing to classify on, because it only says the *full* partition keys are unique and says nothing about whether the *projected* keys are. A `GroupPartitionsExec` is needed either to coalesce duplicate partition keys, or to project down to the operation keys, or both, and splitting by provenance put those two reasons in different branches. Both branches returned wrong results. 1. A grouped partitioning over a superset of the operation keys got no node at all, so a top-k window over `PARTITION BY id` on an `(id, name)`-partitioned table surfaced `id=1` twice, once per `(1,'aa')` and `(1,'bb')` partition. 2. A non-grouped partitioning over a superset got a node without a projection, so it coalesced by the full partition keys and left the operation key split. Any source reporting more than one split per partition value hits this. `SUM(price) OVER (PARTITION BY id)` over the same table with two splits for `(1,'aa')` returned 25.0 and 20.0 instead of 45.0. Both reach back to 4.2.0, where `GroupPartitionsExec` and this classification were introduced. A reduced storage-partitioned join reaches the same wrong result through a third shape. Joining an `identity(ts)`-partitioned table to a `years(ts)`-partitioned one under `v2BucketingAllowCompatibleTransforms` leaves both sides grouped on `(year, bucket)`, so two rows sharing a `ts` in different buckets sit on separate partitions. `SUM(v) OVER (PARTITION BY ts)` then returns 10 and 20 on `master` where the answer is 30 and 30, and the projection this PR inserts is what merges the two partitions. Before #58420 that same query threw `ClassCastException` at planning once the keys were read, so the wrong answer only became observable when that fix landed. ### Does this PR introduce _any_ user-facing change? Yes, it fixes a data correctness issue. With `v2BucketingAllowKeysSubsetOfPartitionKeys` enabled, a single-child operator whose keys are a subset of the partition keys now produces correct results, namely a window `PARTITION BY` and the single-pass aggregate shapes (`FlatMapGroupsInBatchExec`, `ArrowAggregatePythonExec`, `MapGroupsExec`). A two-phase SQL aggregate was already correct, because its partial `HashAggregate` is a `PartitioningPreservingUnaryExecNode`, so it narrows `KP([id, name])` to `KP([id])` before the final aggregate sees it. A cogroup is unaffected either way, because the projection there is left to the multi-child block, exactly as before. Two further changes are not gated on that config. The `requiredNumPartitions` rule applies whether `allowKeysSubsetOfPartitionKeys` is on or off, since a scan still needs `spark.sql.sources.v2.bucketing.enabled` to report a `KeyedPartitioning` at all, and `master` has no count check on the grouping path. A non-grouped `KeyedPartitioning` with 3 partitions and 2 distinct keys under `ClusteredDistribution([k], requiredNumPartitions = Some(3))` got a `GroupPartitionsExec` with 2 partitions on `master` and now gets a shuffle with 3. I could not find a query where such a distribution meets a `KeyedPartitioning` today, so this is robustness rather than a reachable wrong result. Only `StatefulOperatorPartitioning` and `AQEUtils` ever set the requirement. The `AQEUtils` one fires only over a `HashPartitioning` child. `StatefulOperatorPartitioning` sets it through a plain `ClusteredDistribution` when `spark.sql.streaming.statefulOperator.useStrictDistribution` is off, and a streaming scan never reports a `KeyedPartitioning`, because `MicroBatchScanExec`, `ContinuousScanExec` and `RealTimeStreamScanExec` all leave `keyGroupedPartitioning` at `None`. The initial-state child of `flatMapGroupsWithState` and `transformWithState` is a batch plan though, so it can be a scan that does. That operator co-partitions its two children, so the shape is the multi-child block's to decide either way, and what changes here is only that a candidate which cannot honour the count no longer wins the resolution. The requirement is part of the `ClusteredDistribution` contract though, the two could meet more widely as this area grows, and honouring it costs nothing where nobody asks for a count, so it seemed the wrong thing to keep ignoring. The `MatchError` fix is gated on `v2BucketingAllowSorting` instead, which is also off by default. `explain` gains one label where the new projection happens. A `GroupPartitionsExec` inserted for a single-child operator now carries `joinKeyPositions`, so the node prints `JoinKeyPositions: [...]` where it printed nothing before. The name is historical, and the scaladoc now says the projection is not join-specific. This needs `allowKeysSubsetOfPartitionKeys` on, so no golden file moves. ### How was this patch tested? Added regression tests in `KeyGroupedPartitioningSuite`: - window top-k over `PARTITION BY` a subset of the partition keys, for both `PARTITION BY id` and the duplicated `PARTITION BY id, id` - window top-k over union output partitioning - a plain window over a subset of the partition keys on a non-grouped `KeyedPartitioning`, asserting the inserted node projects to the operation key rather than only coalescing - no `GroupPartitionsExec` and no shuffle when projecting to the operation keys merges nothing - a window over an inner join's two-member `PartitioningCollection`, keyed on the left side's first key column and the right side's remaining two, asserting no node is inserted because the left member needs none, even though the right one covers more operation keys - a window over a join that reduced one side's keys onto the other side's key space, asserting the two rows sharing a `ts` end up on one partition and in `EnsureRequirementsSuite`: - a `FlatMapCoGroupsInPandasExec` over `(n, i)`-partitioned children grouped on `i`, asserting both sides are grouped on `i` and not on `n` - a grouped `KeyedPartitioning` whose count differs from `requiredNumPartitions`, asserting the count is still honoured with a shuffle - an `(n, i)`-partitioned `KeyedPartitioning` whose count matches `requiredNumPartitions` but which needs a projection, asserting it falls back to a shuffle rather than to a node that would break the count it just passed - a projecting `KeyedPartitioning` whose *post-projection* count matches `requiredNumPartitions`, asserting it still groups on the operation key with no shuffle - a non-grouped `KeyedPartitioning` whose *post-grouping* count matches `requiredNumPartitions`, asserting it still groups without a shuffle - the same count rule with `allowKeysSubsetOfPartitionKeys` left at its default, asserting the shuffle - an `(id, years(ts))`-partitioned `KeyedPartitioning` clustered on those same two expressions, with and without `requireAllClusterKeys`, asserting no node is inserted when a cluster key is the partition expression itself - a `(bucket(4, a), b)`-partitioned `KeyedPartitioning` clustered on `a` alone, asserting the node projects to the bucket position. This is the one test where the reference-level match decides a kept position, and it is the shape the single-reference soundness argument is about. `master` inserts no node at all here, so the partitions sharing a bucket stay apart. - a single-partition `KeyedPartitioning` under `OrderedDistribution`, asserting planning no longer throws a `MatchError` - a `KeyedPartitioning` with no partition expressions, asserting it is still kept as it is, since without that guard it reaches the shuffle branch, where `UnspecifiedDistribution.createPartitioning` throws - a `KeyedPartitioning` whose expressions reference no column, asserting the child is shuffled rather than projected to no position at all - a `PartitioningCollection` whose two members cover a different number of operation keys, asserting the wider one supplies the projection - two members whose position sets are nested and whose projections leave the same number of partitions, asserting the containing set still wins, so the projection keeps naming the key the other would have dropped - two members whose position sets are nested, where only the narrower one's projection lands on `requiredNumPartitions`, asserting it is used rather than pruned and lost to the wider one - two members whose position sets are not nested and where the narrower one leaves more partitions, asserting the narrower projection wins over the wider coverage - two members covering one position each whose projections leave different numbers of partitions, asserting the one leaving the most supplies the projection - two members covering one position each whose projections leave the same number of partitions, asserting the one the child reports first wins, in both collection orders and in `ProjectedOrderingAndPartitioningSuite` a grouped and collapsed `KeyedPartitioning`, asserting `keysMaySatisfy` accepts it where `mayGroupToSatisfy` refuses it, because a grouped partitioning has no duplicate keys left for a node to coalesce. Seventeen of the twenty-four fail without the production change in this commit, measured on `master`. Those are the four window tests, the reference-free expressions, the transform position, the `MatchError`, four of the six `requiredNumPartitions` tests, all five multi-member ones and the `keysMaySatisfy` one, which does not even compile there. The other seven pass already and guard this PR's own insertion decision. Removing the condition each one covers makes it fail, with one exception worth naming. `the candidate covering the most operation keys wins` survives the removal of either the containment prune or the ranking, because either mechanism alone picks the same winner, so it earns its place by documenting the shape rather than by pinning a single condition. The join test is one of the seven. The base leaves that plan alone too, and it is here because an earlier revision of this PR inserted a node there. Removing the `isCoPartitioned` guard also fails two pre-existing SPJ tests. `DistributionSuite` and `ShuffleSpecSuite` pass with 29 tests, and `KeyGroupedPartitioningSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `GroupPartitionsExecSuite` and `PlannerSuite` pass with 295. The window `PARTITION BY` tests come from #58245. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Co-authored-by: Xiduo You <ulyssesyouapache.org> Closes#58469 from peter-toth/SPARK-58968-collapse-satisfies-classification-4.2. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
What changes were proposed in this pull request?
KeyedPartitioningcarries a flag that gates whetherGroupPartitionsExecmay coalesce its duplicate partition keys withoutspark.sql.sources.v2.bucketing.allowKeysSubsetOfPartitionKeys.enabled. Today the flag records provenance, meaning "a projection dropped key positions, or my input already had some dropped". The gate's own comment describes something else, a collapse: "partitions that held distinct keys in the original finer-grained partitioning". The two are not the same, and the gate reads the wrong one.This changes the flag to mean what the gate needs, and renames it from
isNarrowedtoisCollapsed. A collapse is a projection or a reduction mapping keys that were distinct in the input onto the same key, so one partition here stands for several of the original ones. Dropping key positions no longer sets the flag on its own. The projected keys have to actually lose distinctness. It stays sticky, since neither grouping nor a further projection can make a partitioning finer again.The gate still rests on the same two conditions, but each now sits where it belongs.
isCollapsedsays the collapse happened, and it gatesmayGroupToSatisfy, the questionEnsureRequirementsasks before it inserts aGroupPartitionsExec. The second condition is that there is still something left to merge, and that one is the caller.EnsureRequirementsasks the question only of partitionings that are not grouped. Once the keys are unique, grouping merges nothing and there is nothing left to gate, however coarse the partitioning already is, sosatisfiesneeds no gate at all.That splits the old
groupedSatisfiesin two, because its two callers were asking different questions through one method:keysSatisfy(required): whether the partition keys match what the distribution asks for, ignoring duplicate keys.satisfiesis this plusisGrouped.mayGroupToSatisfy(required):keysSatisfyplus a check that coalescing the duplicates is allowed.EnsureRequirementsasks this of non-grouped partitionings.That check is a conjunct rather than a case inside the key matching. SPARK-58974 established it there by hoisting it above the
requireAllClusterKeysbranch, since the risk it guards does not depend on which key sets count as matching.OrderedDistributionstays exempt, becauseGroupPartitionsExecpads that path out to the expected split counts instead of coalescing.Producers:
PartitioningPreservingUnaryExecNodegoes through a newKeyedPartitioning.project, which computesisGroupedandisCollapsedtogether from the projected keys. The flag is inherited, or set when the projection leaves fewer distinct keys than the input had. A projection that keeps every position returns the partitioning unchanged, with no pass over the keys.UnionExec's keyed merge moves toKeyedPartitioning.concat, which joinsapply,projectandtoGroupedas the fourth rule for how the flags travel. The rule itself is unchanged: it ORs the children's flags, so a child that really did collapse its keys marks the union. What changes for a union is upstream of it, since the children's flags are now precise.GroupPartitionsExec,KeyedPartitioning.toGrouped,KeyedPartitioning.createShuffleSpecandKeyedShuffleSpec.createPartitioningall propagate the flag. Each of them used to build a partitioning through the 3-argument constructor, which defaulted the flag tofalse. That default is gone from the parameter, so every producer now has to state it.createShuffleSpecprojects onto the operation keys, so it computes its own collapse withproject.GroupPartitionsExecdecides both answers from the key groups it keeps, by asking whether a group spans more than one of its own child's partition keys. It does not compare against the aligned key list, because that list is the one both join sides agreed on. Such a list can be missing keys this side had (partition filtering) or repeat them (padding), and neither is a collapse.GroupPartitionsExeccomputes the flag once, from one member of the child's partitioning.PartitioningCollectionnormalizes the flag across its members by OR, alongside thepartitionKeysinterning it already did, and its invariant check enforces the result. The flag describes the shared physical layout rather than one member's naming of it, and consumers read it off a single member.satisfies0andEnsureRequirementsaccept when any one member satisfies the distribution, so a member that under-reported the collapse would let the gate through. Uniformity also means a consumer can read one representative instead of every member. The coverage there is structural. Once the flag is uniform, the mixed collection that could slip through cannot be built, so the test asserts the invariant rather than a plan shape.This supersedes #58316 (SPARK-59026), which restores the flag in two of the producers above. Propagating it is that PR's finding, and its unit test is taken here with credit. Its end-to-end test is not taken. Its table has unique ids, so dropping the second key column keeps every key distinct, and that is exactly the scenario this change reclassifies as no collapse. The two cannot both land as written.
The class doc is reorganized around the new meaning. It leads with what a partition key is and what
EnsureRequirementsuses the keys for, and it gains a "Key Collapse" section that tells a collapse apart from a grouping. That section also carries why a collapsed partitioning is still reported rather than dropped toUnknownPartitioning, which was nowhere in the code.Sidenote: both the method split and
projectpoint the same way as item 5 of #58262 (comment), which is that the SPJ decisions inEnsureRequirementsare worth untangling on their own.projectsettles one half of that here. Applying a projection to a partitioning now happens in one place instead of once per producer. The other half is left to the follow-up. Which positions to keep is still asked in two places,KeyedShuffleSpec.keyPositions(wherecreateShuffleSpectakes itsjoinKeyPositionsfrom) and the overlap test inkeysSatisfy. A comment there now records that the two are the same test, and that the answer only becomes true once the projection is applied.Why are the changes needed?
Provenance leaves the gate open in three ways, which is why this is filed as a bug:
toGrouped, increateShuffleSpec's projected partitioning, inKeyedShuffleSpec.createPartitioningand inGroupPartitionsExec, so the gate cannot see the risk even when it exists. This is the defect [SPARK-59026][SQL] Propagate isNarrowed in KeyedPartitioning.toGrouped and KeyedShuffleSpec.createPartitioning #58316 (SPARK-59026) reports, and it is fixed here.allowCompatibleTransforms, abucket(4, id)side reduced onto abucket(2, id)join maps four distinct keys onto two, which is a real collapse. It drops no key position, so provenance misses the whole class of them and the opt-in is bypassed.PartitioningCollectionwhose members disagreed could be entered through the plain one.EnsureRequirementslooks for any single member that aGroupPartitionsExecwould make satisfy the distribution, so a collection holding one collapsed and one plain member reached one regardless. Normalizing the flag across members closes that.Provenance also over-refuses, and it does so on one of the commonest shapes.
!isGroupedhas causes that have nothing to do with dropping key positions:UnionExeccomputesisGroupedover the concatenation of its children's keys, so two children that are individually key-distinct make it false by overlapping with each other.In both cases grouping merges only partitions that already shared a key. That is what
GroupPartitionsExecdoes for any partitioning that never went through a projection, and it needs no opt-in. Today the mere presence of a narrowing projection anywhere upstream turns that into a refusal, so a plan gets a shuffle that protects nothing.Measured on the multi-split shape. A table partitioned by
(id, dept)has two splits for the same(1, 'x')value, is projected down toid, and is joined on it with the opt-in off. Before: noGroupPartitionsExecand 2 shuffles, and the projected partitioning reports the flag set. After: 1GroupPartitionsExec, 0 shuffles, flag clear.Does this PR introduce any user-facing change?
It should reach
branch-4.3andbranch-4.xas well as master, since 4.3.0 is where the flag first ships.Yes, a plan-level change. Storage-partitioned operations now proceed without
allowKeysSubsetOfPartitionKeys.enabledin the cases above, where they previously fell back to a shuffle. Query results are unchanged. No migration guide entry is needed, because the flag and its gate arrived in 4.3.0 (SPARK-46367), which is unreleased, so no released version behaves the old way.Propagating the flag through
createShuffleSpecalso makes the spec's projected partitioning compare equal to the child's in one more case. That case is the opt-in on, every position selected, and the source already collapsed and grouped.EnsureRequirements' "child partitionings not modified" fast path then fires where it previously did not. The partitions are the same, since that path is only reached when both sides' keys already match, but the existingGroupPartitionsExeckeepsexpectedPartitionKeys = None, soexplainloses itsExpectedPartitionKeysline.Reducers are a second case. With
allowCompatibleTransforms, abucket(4, id)side reduced onto abucket(2, id)join really does collapse its keys, so itsGroupPartitionsExecoutput now carries the flag where it did not before, and a coalescing further up the plan needs the opt-in. That direction is a narrowing, not a widening, and it is what the flag is supposed to say.Planning cost was measured on the worst case for the collapse test. The partitioning is projected ten times over, each projection dropping one key position, which is what a plan with stacked
Projects asks for. Every projection drops a position, so the identity fast path never fires, and no projection collapses a key, so the walk never stops early. Over a 50k-split, 25k-distinct-key partitioning with 12-position keys, 20 evaluations of that chain took 345 ms for the provenance formula and 1291 ms for this one, i.e. about 5 ms per projection that drops a position. The obvious two-pass form of the same test, onedistinctover the projected keys and another over the source keys, took 2137 ms in the same harness, which is whyprojectwalks the two key lists in lockstep instead. A projection that keeps every position pays nothing, since it cannot collapse anything. The trade is deliberate: a few milliseconds of planning buys the removal of a runtime shuffle in the cases above, and it also closes the three holes that let the gate through.How was this patch tested?
ProjectedOrderingAndPartitioningSuitefor the multi-split shape. A source with duplicate keys, projected down, is ungrouped but not collapsed, andmayGroupToSatisfyaccepts it with the opt-in off.KeyGroupedPartitioningSuitefor the same shape through a real plan, asserting the grouping happens and the shuffles disappear.PartitioningCollectionmarks every member of it, including through a nested collection, so the outcome does not depend on which join side the collapse came from.GroupPartitionsExecSuitethat the output flag reports what the node's grouping merges. It covers the replicating and the distributing mode, and the case where the join's agreed keys drop the merged one.itemshas unique ids, so droppingnamecollapses nothing, and the final aggregate may group the union's overlapping keys with the opt-in off. That keeps the chain covered while pinning the reclassification.allowCompatibleTransforms, anidentity(item_id)side reduced ontobucket(4, id)maps several ids onto one bucket, and the flag now says so.partitionFilteron, an inner join plans both sides on the intersection of their keys, and the side that lost a key must not report a collapse. Otherwise the sticky flag costs a shuffle above a later union.SPARK-46367: narrowing projection with duplicate keys ..., the scenario whose projected keys stay distinct now asserts the flag is clear.SPARK-46367scenario and the shuffle-template chain. Deciding the flag from a key count taken after the alignment, instead of from the key groups kept, fails the partition-filter test. Dropping the collection normalization fails its unit test. Dropping theGroupPartitionsExecterm fails the reducer test.DistributionSuitecovers the propagation throughtoGroupedandKeyedShuffleSpec.createPartitioning. That test comes from [SPARK-59026][SQL] Propagate isNarrowed in KeyedPartitioning.toGrouped and KeyedShuffleSpec.createPartitioning #58316.DistributionSuite,ShuffleSpecSuite,KeyGroupedPartitioningSuite,ProjectedOrderingAndPartitioningSuite,GroupPartitionsExecSuite,EnsureRequirementsSuite,PlannerSuite,WriteDistributionAndOrderingSuite,DataFrameSetOperationsSuite,AdaptiveQueryExecSuite,CoalesceShufflePartitionsSuiteand the TPC-DS plan stability suites. No golden file changed.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Opus 5)