Uh oh!
There was an error while loading. Please reload this page.
[SPARK-58968][SQL] Fix SPJ allowKeysSubsetOfPartitionKeys correctness for non-join operators - #58262
Conversation
ulysses-you
commented
Aug 25, 2026
I'm fine with this fix, the main change is same that adding an extra GroupPartitionExec with projected key position for non-join operators. This fix makes change inlines the method |
68dfbec to
8b74639CompareI ran a deep review of this PR (line-by-line scan, removed-behavior audit, cross-file tracing, plus reuse/simplification/efficiency angles, with each candidate adversarially verified against the checked-out tree). Overall the change looks solid: I verified that the old four-way classification maps faithfully onto the new two-bucket one ( 1. Pre-existing crash carried into the rewritten function:
2. The co-partitioned The new comment at L74-L76 accurately states that a co-partitioned child reporting a 3. The compensation ( 4. The projected distinct count is computed twice per candidate partitioning (confirmed)
5. Per-collection-member repetition of O(n) key passes (confirmed)
6. Admission and projection are decided by different code — drift risk (design)
7. The duplicated- The "window top-k over duplicated PARTITION BY key" test is a full copy of the preceding subset test with only Refuted candidates, for the record: the |
peter-toth
commented
Aug 25, 2026
Thanks @dongjoon-hyun for the review, I've iterated on the code multiple times today, but this is still a draft. Will try to wrap it up tomorrow. |
dongjoon-hyun
commented
Aug 25, 2026
Got it~ I'll revisit when the PR becomes out of |
peter-toth
commented
Aug 26, 2026
This is more complex than I initially thought. Let me iterate on it a bit more. |
315d6ef to
bcaa5b6Comparepeter-toth
commented
Aug 27, 2026
@ulysses-you thanks for closing #58245 in favour of this one, and for the three window tests, which I brought over verbatim. Since the two PRs look quite different, here is why I ended up somewhere else rather than iterating on yours. The root cause is one level up from the branch you patched. And the sibling branch was wrong in the same way. For a
On the positions themselves. Taking them from One more difference worth naming, and it is plan quality rather than correctness. Your version decided from a single member, the one The extra look is cheaper than it sounds. A position set contained in another one is dropped without projecting it, since projecting to fewer positions can merge partitions but never split them. In the ordinary case one set contains the rest and it is a single projection, same as yours; it is only more when the sets genuinely disagree. |
@ulysses-you, @dongjoon-hyun updated and out of draft, so this is ready for another look. @dongjoon-hyun thanks for the review, all seven are addressed. The Since the earlier reviews the branch has also been rebased onto master twice, past SPARK-59025 and SPARK-59027, and it picked up a few things from my own re-reads: the ranking of candidate projections is now exact rather than a coverage heuristic, the "needs no node" question is asked of every member of the child's partitioning rather than only of the winner, and |
@ulysses-you@dongjoon-hyun so you both know what I am planning in this area, here is what I have collected while working on this. Each will get its own JIRA as I get to it.
|
ulysses-you
left a comment
There was a problem hiding this comment.
Empirical verification performed
Base = upstream/master HEAD e2cc1d64ab36 (identical to this PR's base). Applied the full patch transiently to compile and run, then reverted:
| Run | Tree | Result |
|---|---|---|
| Full patch applied | compile clean (30s, warm cache) | EnsureRequirementsSuite -z SPARK-58968: 14/14 pass; KeyGroupedPartitioningSuite -z SPARK-58968: 5/5 pass |
| Negative control (test files applied, main code reverted) | same suites | exactly 6 pass / 13 fail on master, matching the description's enumeration precisely (the 6 passing are exactly the insertion-decision guards claimed, incl. the join-collection-member-needs-none test) |
Line-length and non-ASCII checks on the changed files pass (splitKeyedPartitionings has only one caller, private to EnsureRequirements, nothing serializes the new planner-only structures).
I also traced every correctness claim through the peer code rather than trusting comments. The load-bearing facts verified against master sources:
- Candidates-dedup / exec-side consistency.
candidatesis keyed only onBitSet("first member wins"), while runtime projection usesGroupPartitionsExec.groupedPartitionsTuple'scollectFirst { case k: KeyedPartitioning => k }(GroupPartitionsExec.scala:136). These agree only becausePartitioningCollection.checkKeyedPartitioningInvariant(partitioning.scala:848) requires all members to share thepartitionKeysreference and arity, andfromPartitioningsinterns by value-equality (wrapper equality includesdataTypes,InternalRowComparableWrapper.scala:65). Given that invariant, projected key values read at positionsi..jare byte-identical whichever member applies them, so dedup-by-position-set, the(BitSet, Seq[DataType])memo key onnumPartitionsAfter, and the "most partitions" ranking are all internally exact — count equality ⟺ no merges, since a projection can coalesce but never split. - Over-coalescing is safe, under-coalescing is the bug. For single-child operators (window top-k, batch aggregates), merging two tasks together never splits an operation key into separate groups because window/aggregation grouping happens per-row within the task; only leaving rows sharing an op key in different tasks is wrong. So choosing the candidate leaving the most partitions, tie-breaking toward the containing set, is not merely plan-quality — dropping it would be incorrect. This is a real invariant but it is never stated anywhere.
- Assert reachability.
clusterKeyPositions'sassert(positions.nonEmpty ...)was probed against every satisfaction path:nonGroupedSatisfiesis true only via basesatisfies0(Unspecified/Broadcast → returns all indices anyway);groupedSatisfiesunderrequireAllClusterKeysgoes pairwise positionally, under subset-config requires overlap, default branch requires every attribute ∈ clustering — each positionally guarantees ≥1 covered index. Unreachable through current branches. - AQE re-entry fixed point. After insertion, re-running
splitKeyedPartitioningson the projected grouped KP satisfies again withpositions.size == len→satisfiedAsIs→ stable; no double insertion under AQE re-optimization. - @transient
joinKeyPositions. New single-child usage leans on it for correctness, unlike pre-existing SPJ-only uses — but grouping happens entirely driver-side at RDD construction (doExecute→CoalescedRDD), so executor-side nulling is irrelevant. No new hazard. - Copartitioned equivalence. With
isCoPartitioned=trueclusterKeyPositionsreturns all indices, soOption.when(size < len)yieldsNone= master's bareGroupPartitionsExec(child);childrenIndexesrelocation does not changepreferSinglePartitionsemantics (still evaluated on mappedchildrenafter the map).checkKeyGroupCompatible/withJoinKeyPositionsoperate independently of this rewrite. - MatchError fix. Confirmed real:
sliding(2)on a 1-element seq yields one size-1 window andcase Seq(k1, k2)threw; the addedcase _ => trueis correct (single partition trivially sorted).
Findings (non-blocking)
N1. First-member-wins correctness rests on an invariant enforced three classes away
EnsureRequirements.scala (splitKeyedPartitionings) / GroupPartitionsExec.scala:63
- Defect statement: the planner picks positions per
BitSetassuming "the same set projects to the same keys whichever member applies it", but the executor re-derives the member independently viacollectFirst; these coincide only ifPartitioningCollectionkeeps members interned on identicalpartitionKeys— a requirement living inpartitioning.scala:848with a value-equality escape hatch infromPartitionings. - Failure scenario if relaxed: collection
[P1(non-satisfying), P2(satisfying)], P2 selected at{1,2}→ exec projects P1's expressions at{1,2}; projected values stay correct (shared keys) but output metadata namesP1's columns as group keys, poisoning downstream spec creation/ordering claims. Today impossible; one future relaxation ofcheckKeyedPartitioningInvariantmakes this silent op-key mislabeling. - Peer citation: the invariant itself (
partitioning.scala:857-862) predates this PR; this PR multiplies the number of consumers relying on it without adding a probe where the coupling crosses files. - Verdict: CONFIRMED-sound-today (traced end-to-end); report as fragility — suggest one sentence in
clusterKeyPositions' scaladoc pointing atPartitioningCollection.fromPartitioningsas the actual guarantee, or a cheap debugrequire(k.partitionKeys eq firstMember.partitionKeys).
Review verdict overall: no blocking correctness issues found; both behavior changes outside the gated config are either strictly safer than master or unreachable today, and every axis probed (codegen/interpreted, WSCG, AQE re-entry idempotency, ordering metadata, collation-aware key hashing) held up under tracing plus the test runs above.
ulysses-you
left a comment
There was a problem hiding this comment.
Non-blocking (N1 of my review below): first-member-wins here only coincides with what actually happens at runtime.
| // The candidates that would need a node, keyed by the positions the node would project them to. | ||
| // One entry per distinct position set is enough, and the first member wins: the same set | ||
| // projects to the same keys whichever member applies it, because `PartitioningCollection` | ||
| // guarantees its members share the `partitionKeys` reference and their arity, so position `i` | ||
| // addresses the same key column in all of them. | ||
| // | ||
| // Insertion-ordered so that when two sets leave the same number of partitions, the one from the | ||
| // member the child reports first wins. That tie is the only thing the order decides, and either |
There was a problem hiding this comment.
Nit/non-blocking: "first member wins" is sound only because runtime projection re-derives the member independently via GroupPartitionsExec.groupedPartitionsTuple's collectFirst { case k: KeyedPartitioning => k } (GroupPartitionsExec.scala:135), which is guaranteed to agree with whichever member was recorded here only by PartitioningCollection.checkKeyedPartitioningInvariant (partitioning.scala:848, value-equality interning in fromPartitionings) -- an invariant enforced three classes away from this call site.
If that invariant is ever relaxed, e.g. collection [P1(non-satisfying), P2(satisfying)] with P2 selected at {1,2}: the node projects P2's positions, but the executor projects P1's expressions at them; projected key values stay correct (shared keys) while output metadata names P1's columns as group keys -- silent op-key mislabeling poisoning downstream spec creation/ordering claims.
Two cheap hardenings: point this comment at fromPartitionings as the actual guarantee (the coupling crosses files today), or add a debug check like require(recorded.partitionKeys eq firstCollected.partitionKeys).
There was a problem hiding this comment.
Agreed, and thanks for tracing it across the files. I have added a sentence at the candidates declaration naming checkKeyedPartitioningInvariant and the interning in fromPartitionings as the actual guarantee, and saying that relaxing it means changing GroupPartitionsExec's collectFirst at the same time rather than this side alone.
Worth adding that the assumption is already only partly guaranteed: the partitionKeys reference and the arity are enforced, the per-position expressionDataTypes are not. Two members of one collection really can declare different types over the same keys - pushPartValues plus allowCompatibleTransforms, an identity(ts)-partitioned table joined to a years(ts)-partitioned one - which is why the projected-count memo in this method is keyed on (BitSet, Seq[DataType]) rather than on the position set alone.
I would rather not add a require on the reference identity here: PartitioningCollection already asserts it on construction, so a second check would go stale the day that one moves.
peter-toth
commented
Aug 27, 2026
One thing in the verification section I would push back on, because it is the kind of claim that hardens into a constraint if it goes unanswered.
The premise is right and worth having written down. The conclusion does not follow, and it points the other way: if coalescing more is the safe direction, then taking the candidate that leaves the most partitions is the less conservative choice, not the one whose removal would break correctness. Every admitted candidate is correct on its own, whichever the ranking picks. Measured rather than argued: in the So I would keep the description's "plan quality rather than correctness". If the ranking were recorded as load-bearing for correctness, the next person to look at this would not dare simplify it, and there is a simplification worth having later - the exact ranking only matters when a collection's members disagree on which positions are operation keys, which needs a join whose |
bcaa5b6 to
e5333f2Comparedongjoon-hyun
commented
Aug 27, 2026
Is this PR ready, @peter-toth ? |
peter-toth
commented
Aug 27, 2026
Yes it is. |
I did a deep review pass over this change (8 review angles, each candidate finding then adversarially verified against the code). Posting the findings that survived verification, most severe first. The two type-divergence items were established by full static traces but not executed end-to-end. Correctness1. Planning-time
2. The executed node can group with a different member than the one the planner validated (
3. (pre-existing) The multi-child fallback applies the best spec's This PR routes all co-partitioned subset projection to the multi-child block (the 4. (robustness) The With Maintainability / efficiency
For completeness, the review also probed and could not fault: the |
Review feedback on apache#58262: - `KeyedPartitioning.keyDataTypes` reads the key rows' own schema, and `projectKeys` and `GroupPartitionsExec` take their types from it. A reducer that rewrote the keys onto another key space can no longer make the projection compare an `Integer` as a `Long`, which threw at planning, and the projected-count memo needs only the position set. - A member whose expressions cover no operation key at all is skipped rather than tripping an assert, because projecting to no position would put every partition into one. - `splitKeyedPartitionings` returns `(Boolean, Option[Either[...]])` instead of two mutually exclusive `Option`s, and asks the non-keyed question before the keyed analysis touches a partition key.
peter-toth
commented
Aug 27, 2026
Thanks @dongjoon-hyun, this was a good catch on 1. All four correctness items are addressed in a new commit, and 6 and 7 with them. 1. Confirmed, and it is a regression this PR introduces. Your repro plans and returns the right rows though, so it is worth writing down what it takes and where the throw actually comes from, because the mechanism is not the one in the finding.
Two rows in the same year and different buckets do it: The fix is to read the keys at the types they were built with rather than at the types their expressions declare. 2. Closed by the same change. 3. Agreed, and it is on my list as item 2 of the follow-up comment above - pre-existing, measured identical on master, and I will file it with the repro. You are right that the new cogroup test does not cover it: it uses identical layouts on both sides, so the positions agree and the hole stays invisible. A test for it belongs with the fix, since it fails on master today. 4. Fixed by skipping such a member instead of asserting. Relaxing the assert would not do: the empty position set flows on as the projection, and a node projecting to no position at all puts every partition into one - an earlier round of this PR measured exactly that (3 partitions to 1, and the result did not satisfy the distribution it was inserted for). So a member covering no position is not a candidate, and the child is shuffled, which is correct for a partitioning grouped on a reference-free expression. 5. Agreed, and it is the same thing you asked for in your first review (your #6 there, item 5 of my follow-up list): one matcher next to 6. Done, with 7. Done. The walk now only sorts the members into keyed and non-keyed, the non-keyed question is asked immediately after it, and the keyed analysis runs in a second method that is only entered when no plain member satisfies. Nothing touches a partition key before that answer. 8. Agreed on the per-plan cache, and it belongs on 9. The second evaluation is the price of asking
|
ulysses-you
left a comment
There was a problem hiding this comment.
I reviewed head 9fab82b (checked out in a worktree, both commits), read the full surrounding files at head and merge-base, traced every distribution type and plan shape through the old vs. new classification, and ran the suites locally (results below).
| * `expressionDataTypes` only where the question is about the expressions themselves. | ||
| */ | ||
| @transient lazy val keyDataTypes: Seq[DataType] = | ||
| partitionKeys.headOption.map(_.dataTypes).getOrElse(expressionDataTypes) |
There was a problem hiding this comment.
the key-type axis is fixed in projectKeys but not in the ordering consumers — toGrouped, keyRowOrdering, and the OrderedDistribution arm still read at expressionDataTypes
- Where:
partitioning.scala:547-556(keyRowOrdering/keyOrdering/toGrouped), reached
viaKeyedPartitioning.createShuffleSpecsubset branch atpartitioning.scala:620-629; alsoEnsureRequirements.scala:93-94. - Defect:
toGroupedsorts withgroupedKeyRowOrdering(expressionDataTypes)— the declared
expression types — while this PR establishes that keys must be read atkeyDataTypes. For a
reducer-rewritten partitioning (Integer year values under aTimestampType-declaredts
expression), sorting comparesGenericInternalRow.getLongagainst a boxedInteger
(rows.scala:getLong(ordinal) = getAs(ordinal)) ->ClassCastException: java.lang.Integer cannot be cast to java.lang.Long— the exact error this
PR fixes elsewhere. - Concrete failure:
v2BucketingAllowCompatibleTransforms+v2BucketingAllowKeysSubsetOfPartitionKeyson;identity(ts)/years(ts)SPJ join (the PR's
own test shape) -> anyPartitioningPreservingUnaryExecNodeabove it (finalHashAggregate)
preserving the stale-typed KP -> a join above that callscreateShuffleSpec-> subset branch
runsprojectKeys(now fixed) then.toGrouped-> CCE at planning with >=2 distinct projected
keys. Same mismatch in theOrderedDistributionarm (EnsureRequirements.scala:93):RowOrdering.create(o.ordering, attrs)compares reduced keys with an ordering bound to the
stale expression types, underv2BucketingAllowSorting. - Peer code / invariant: the PR's own
keyDataTypesscaladoc (partitioning.scala:550-564)
states "the types each key was built with, and the ones it is hashed and compared under" — the
hash/compare path (InternalRowComparableWrapper.equals/hashCode) uses the wrapper's own types
and is safe;toGrouped's sort does not go through the wrapper's ordering, so it violates the
stated invariant. - Severity honesty:not a regression — on master these plans CCE'd even earlier (in
projectKeys). The PR widens the set of plannable queries past the first crash and lands on
the next instance of the same bug. Recommend either derivingkeyRowOrderingfromkeyDataTypes(one line, consistent with the new scaladoc), or explicitly listing the
remaining consumers in the follow-up JIRA alongside theValidateRequirementsgap. - Verdict: CONFIRMED (line-by-line trace; cast mechanics verified in source). Runnable repro,
works on master without the PR, and would still fail after it — appendable toEnsureRequirementsSuite:
test("SPARK-58968: createShuffleSpec must sort keys at the types they were built with") {
valexprTs=AttributeReference("ts", TimestampType)()
valexprId=AttributeReference("id", IntegerType)()
valfactory=InternalRowComparableWrapper
.getInternalRowComparableWrapperFactory(Seq(IntegerType, IntegerType))
// Reduced keys (year, bucket) under stale expressions (ts: TimestampType), as// GroupPartitionsExec.outputPartitioning reports after applying reducers.valkeys=Seq(InternalRow(2020, 0), InternalRow(2021, 1)).map(factory)
valkp=newKeyedPartitioning(Seq(exprTs, exprId), keys, isGrouped =true)
withSQLConf(SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key ->"true") {
vale= intercept[ClassCastException](kp.createShuffleSpec(ClusteredDistribution(Seq(exprTs))))
assert(e.getMessage.contains("java.lang.Integer cannot be cast to java.lang.Long"))
}
}Run:build/sbt 'sql/testOnly org.apache.spark.sql.execution.exchange.EnsureRequirementsSuite -- -z "createShuffleSpec must sort keys"'
There was a problem hiding this comment.
Confirmed, and fixed by deriving keyRowOrdering from keyDataTypes. Thanks - the trace is right, and it made me walk every reader rather than just this one.
Four places read key rows at the expressions' types and now read them at keyDataTypes: keyRowOrdering (so toGrouped, and PushDownUtils' key sort through keyOrdering), reduceKeys, the base types the reduce path passes on both sides in EnsureRequirements, and PushDownUtils' wrapper factory for the keys a scan reports after runtime filtering - that last one for consistency only, since it sees a scan's own partitioning, where the two sources coincide. The keyRowOrdering one also closes a latent divergence: GroupPartitionsExec.groupAndSortByKeys already sorted the reduced keys at the reduced types, so for exactly these partitionings the two sides of the contract in groupedKeyRowOrdering's scaladoc did not agree.
One place keeps expressionDataTypes, with a comment now saying why: ShuffleExchangeExec wraps lookup keys it evaluates from the expressions per row, and the stored keys it matches them against have to be declared the same way, so the two move together. For a reducer-rewritten partitioning neither choice works - the stored keys are in the reduced key space and the evaluated ones are not - so such a partitioning must not be shuffled onto at all. That gate is KeyedShuffleSpec.canCreatePartitioning, and closing it belongs with the stale-expression follow-up.
The OrderedDistribution arm you also point at is not a type swap. RowOrdering.create(o.ordering, attrs) binds the distribution's sort orders to the partition attributes, so with reduced keys the ordering is over the wrong space rather than merely at the wrong type; rebuilding it over the key types would have to carry the distribution's directions and null orderings, and refusing the partitioning is probably the better answer. A query does reach it, by the way - v2BucketingAllowSorting with a global sort on the partition key over a reduced join - and it throws the same ClassCastException on master, at the same place. On the follow-up list, named explicitly as you suggest.
Test added in EnsureRequirementsSuite, createShuffleSpec sorts the projected keys at the types they were built with - your repro inverted. It throws your ClassCastException before the fix and asserts the sorted projected keys after.
| c.clustering.exists(_.semanticEquals(e)) || | ||
| e.references.exists(ref => c.clustering.exists(_.semanticEquals(ref))) | ||
| }.to(BitSet) | ||
| positions |
…lt with Review feedback on apache#58262: - `keyRowOrdering` (and so `toGrouped`, plus `PushDownUtils`' key sort through `keyOrdering`), `reduceKeys`, and the base types the reduce path passes on both sides in `EnsureRequirements` read the key rows at `keyDataTypes` now. `toGrouped` therefore sorts reduced keys the same way `GroupPartitionsExec.groupAndSortByKeys` does, which is the contract `groupedKeyRowOrdering`'s scaladoc states. `PushDownUtils`' wrapper factory follows for consistency only: it sees a scan's own partitioning, where the two sources coincide. - `ShuffleExchangeExec` keeps `expressionDataTypes`, with a comment saying why: its lookup keys are evaluated from the expressions per row, and the stored keys they are matched against have to be declared the same way. - Two places still read key rows through the expressions, and neither is a type swap. `ShuffleExchangeExec` cannot be made to work for a reducer-rewritten partitioning at all, and the `OrderedDistribution` arm's `RowOrdering.create(o.ordering, attrs)` binds the distribution's sort orders to the partition attributes, so with reduced keys the ordering is over the wrong space rather than merely at the wrong type. A query reaches the second one - `v2BucketingAllowSorting` with a global sort on the partition key over a reduced join - and throws the same `ClassCastException` on `master`. Refusing such a partitioning belongs with the follow-up that makes a partitioning say whether its expressions still describe its keys. - A redundant local val in `clusterKeyPositions`.
dongjoon-hyun
left a comment
There was a problem hiding this comment.
I reviewed this with a multi-angle pass (correctness, removed-behavior, cross-file tracing, efficiency/cleanup), with each candidate finding adversarially verified. The core splitKeyedPartitionings rework and its ranking/memoization logic held up under every scenario checked, and the test coverage is thorough. Leaving 8 inline comments: 2 correctness notes (one is a genuine planning-to-execution failure-mode shift worth a one-line gate, the other is the already-acknowledged deferred OrderedDistribution issue) and 6 minor efficiency/cleanup suggestions.
| // of the comparison have to be the expressions' types. A partitioning whose keys a reducer | ||
| // rewrote cannot be shuffled onto at all -- its stored keys live in the reduced key space | ||
| // while the evaluated ones do not, and no choice of declared types brings the two together. | ||
| val wrapperFactory = InternalRowComparableWrapper |
There was a problem hiding this comment.
[correctness] This comment asserts that a reducer-rewritten partitioning "cannot be shuffled onto at all", but nothing enforces it: KeyedShuffleSpec.canCreatePartitioning checks only isGrouped and the expression shapes, never keyDataTypes == expressionDataTypes.
Moreover, this PR removes the accidental planning-time fail-fast that used to stop this path: pre-PR, createShuffleSpec's subset path read the keys at expressionDataTypes and threw CCE at planning for a reduced partitioning; post-PR it succeeds at keyDataTypes, so under allowCompatibleTransforms + v2BucketingShuffleEnabled + allowKeysSubsetOfPartitionKeys, an SPJ output (TimestampType-declared expressions over reduced IntegerType year keys) can become bestSpec, the other side gets shuffled onto it, and the valueMap below wraps the Integer keys at expressionDataTypes — CCE at execution, or silent misrouting for same-width reductions. The failure moves from planning time to execution time.
A keyDataTypes == expressionDataTypes clause in canCreatePartitioning would make this comment true and restore the planning-time fallback to a shuffle.
There was a problem hiding this comment.
Taken, and it shipped separately as #58420 (SPARK-59120), now on master, 4.2, 4.3 and 4.x. KeyedShuffleSpec.canCreatePartitioning ends with partitioning.expressionsDescribeKeyShape, which is the clause you asked for. It compares shapes rather than plain types, because createPartitioning puts the other child's expressions over these keys, so a struct field name can legitimately differ with no reducer involved.
The whole keyDataTypes half left this PR with it, so the comment you flagged is not in this diff any more.
What the clause still does not catch is recorded next to it. Matching shapes are only a proxy: bucket(12) and bucket(8) reducing onto bucket(4) keep the type, pass the gate, and misroute rows anyway. SPARK-59121 replaces the proxy with the real test.
| // The single-column invariant in KeyedPartitioning.supportsExpressions guarantees | ||
| // one attribute per partition expression. | ||
| val attrs = satisfyingKeyedPartitioning.expressions.flatMap(_.references) | ||
| val keyRowOrdering = RowOrdering.create(o.ordering, attrs) |
There was a problem hiding this comment.
[correctness / acknowledged-deferred] This arm still binds the ordering to the expressions' declared attribute types and evaluates it over partitionKeys rows (keyOrdering.lteq below, and the sortBy in the else branch) — the one key-reading site in this rule not migrated to keyDataTypes. With v2BucketingAllowSorting + allowCompatibleTransforms, a global ORDER BY over a reduced join reaches this and throws CCE at planning (verified identical on master, and the PR description already defers it).
The deferral rationale is sound — reduced keys live in a different key space, so a type fix alone would give silently wrong ordering. One thought: an interim planning-time gate refusing keyDataTypes != expressionDataTypes here would trade part of the crash surface for a shuffle until the follow-up lands, though it would not cover same-typed reductions.
There was a problem hiding this comment.
Still deferred, and now tracked rather than only mentioned in a description. The arm is one of three readers that reduced keys break in a way no choice of types can fix, and the keyDataTypes scaladoc names all three: this arm, the UnionExec key merge, and KeyedShuffleSpec.reducers. SPARK-59121 covers them together.
I did not take the interim gate. It would refuse the differently-typed reductions and let the same-typed ones through, so SPARK-59121 has to solve that class anyway and the gate would come straight back out. The crash is identical on master and needs v2BucketingAllowSorting, which is off by default.
| // count whichever member is asked. Reading the values at the *expressions'* types would not | ||
| // have that property, and would not even be sound: a reducer can rewrite the keys onto another | ||
| // key space while a member keeps reporting the expressions it was built from. | ||
| val projectedNumPartitions = mutable.Map.empty[BitSet, Int] |
There was a problem hiding this comment.
[efficiency, minor] The memo keeps only the count and discards the projected keys, so whenever a count was computed (satisfying-narrowing member, requiredNumPartitions filter, or the maxBy ranking), the inserted GroupPartitionsExec re-runs the identical projectKeys + grouping in the same planning pass as soon as its outputPartitioning is consulted (and tryEnableSortedMerge's copy recomputes once more). The default-config single-candidate shape is unaffected thanks to the ranked.size == 1 fast path.
No drop-in fix — expectedPartitionKeys has different semantics and a cached-keys parameter would fight the documented independent re-derivation — so this is just a noted trade-off / possible follow-up.
There was a problem hiding this comment.
Agreed, and left as it is, for the reason you gave. The comment at the memo now says what it keys on and why, so the trade is on the record rather than implied.
The default shape does not pay it. A single surviving candidate takes the maximal.size == 1 fast path and no count is computed at all.
| // `isGrouped && groupedSatisfies` -- and a grouped partitioning has distinct keys, | ||
| // leaving the node nothing to coalesce. | ||
| if (satisfies && (positions.size == k.expressions.length || | ||
| numPartitionsAfter(k, positions) == k.numPartitions)) { |
There was a problem hiding this comment.
[efficiency, minor] This eagerly pays an O(#partitions) projection for an early satisfying-but-narrowing member even when a later member of the same collection turns out to satisfy with full positions and zero key work (the earlier projection is then discarded). Narrow shape (subset config + asymmetric coverage across collection members) and planning-time only — worth a two-pass restructure only if it stays simple against the candidate-ordering invariants documented above.
There was a problem hiding this comment.
Fixed in 3509a88.
satisfiedAsIs is two passes now instead of one loop over a var. The first find asks every admitted member whether its positions cover all of its expressions, which touches no partition key. Only the orElse pass computes a count. So a narrowing member no longer pays a projection that a later full-coverage member discards, and the scaladoc at the two finds says that is why the order is what it is.
| // `satisfies0` gates that on `isGrouped`; it still needs a node to coalesce duplicate | ||
| // keys. | ||
| val satisfies = k.satisfies(distribution) | ||
| if (satisfies || k.groupedSatisfies(distribution)) { |
There was a problem hiding this comment.
[efficiency, nit]satisfies0 is nonGroupedSatisfies || (isGrouped && groupedSatisfies) and Partitioning.satisfies has no caching, so for a grouped member that fails satisfies, groupedSatisfies runs twice (SQLConf lookup, AttributeSet build, semanticEquals scans). Evaluating nonGroupedSatisfies/groupedSatisfies once as locals and deriving satisfies from them (plus the count gate) would avoid it. Constant-factor only.
There was a problem hiding this comment.
Half of it, by construction. satisfies is asked first and || short-circuits, so every member that is admitted matches once. A member that fails satisfies still matches twice, which is the case you named.
I left that. Composing the two out of locals means putting satisfies0's structure in the caller, and nonGroupedSatisfies and keysSatisfy are both private now, which is what keysMaySatisfy exists to keep. Widening them to save a constant factor at planning time looks like the wrong trade. The comment above admitted records why the strict question is asked first.
| // rather than projected to no position at all, which would collapse every partition into | ||
| // one. Only a partitioning whose expressions have no references gets here, and only | ||
| // because nothing rejects one -- see `clusterKeyPositions`. | ||
| if (positions.nonEmpty || k.expressions.isEmpty) { |
There was a problem hiding this comment.
[simplification, nit] The || k.expressions.isEmpty disjunct keeps alive a zero-expression KeyedPartitioning that no in-tree producer can construct (the scan, AliasAwareOutputExpression, and GroupPartitionsExec all guarantee at least one expression), and the branch is untested — even the reference-free test uses Seq(Literal(1)) and exercises the skip path. Dropping it would let a zero-expression member shuffle uniformly like the reference-free case and simplify the trickiest guard in this function.
There was a problem hiding this comment.
Kept, and there is a test for it now: a KeyedPartitioning with no partition expressions is kept as it is.
Dropping it does not make such a member shuffle uniformly like the reference-free one. It reaches the shuffle branch under UnspecifiedDistribution, whose createPartitioning throws outright, so the guard is what stops a planning failure rather than a bad plan. The test's comment says that, and it also says what you did, that no in-tree producer builds one.
| val nonGroupedKeyedPartitionings = ArrayBuffer.empty[KeyedPartitioning] | ||
| val keyedPartitionings = ArrayBuffer.empty[KeyedPartitioning] | ||
| def split(p: Partitioning): Unit = p match { |
There was a problem hiding this comment.
[reuse, nit] This hand-rolled recursive traversal duplicates the existing PartitioningCollection.flatten; flatten(partitioning) followed by a partition on KeyedPartitioning yields the same two sequences without local recursion or mutable buffers. The recursion predates this PR, but since the function is rewritten wholesale anyway, the cleanup is in scope.
There was a problem hiding this comment.
Fixed in 3509a88.
splitKeyedPartitionings starts with PartitioningCollection.flatten(partitioning) and then splits by type. No local recursion, no mutable buffers.
| * cluster key, so coalescing on the projected keys cannot put rows that share an operation key on | ||
| * different partitions. | ||
| */ | ||
| private def clusterKeyPositions( |
There was a problem hiding this comment.
[altitude, follow-up] This is now a third derivation of "operation-key positions", with deliberately different matching rules from KeyedShuffleSpec.keyPositions and createShuffleSpec's joinKeyPositions (the expression-level semanticEquals branch, tolerance of reference-free expressions) — and the co-partitioned path still derives positions the keyPositions-only way, so the expression-level case the new test pins is honoured on the single-child path only. Latent today (analyzed queries don't put a TransformExpression in a ClusteredDistribution), but consolidating this next to keyPositions on KeyedPartitioning/KeyedShuffleSpec in the follow-up would make the divergence visible in one place.
There was a problem hiding this comment.
Agreed, and it is the follow-up's second piece, one derivation of the operation-key positions. The count half already moved onto the type here, as KeyedPartitioning.numPartitionsProjectedOn, so what is left there is the positions.
The co-partitioned path keeping its own derivation is deliberate for now, and the description says so. Projecting inline there would leave the multi-child block deriving positions from an already projected partitioning and applying them to the unprojected keys.
…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 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. ### 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 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. ### 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 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. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code (Opus 5)
…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)
…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>
…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>
… 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>
631b35d to
3509a88Comparepeter-toth
commented
Sep 1, 2026
@dongjoon-hyun@ulysses-you this is out of draft and ready for review. It is rebased onto master, which now carries both PRs it was stacked on, #58351 (SPARK-59057) and #58420 (SPARK-59120). The Since your round, two of your findings are fixed in the code, one of them shipped as #58420, and the rest are answered in their threads. The description is rewritten around the current shape. One test was added, |
ulysses-you
left a comment
There was a problem hiding this comment.
thank you @peter-toth !
What I verified (the invariant audit)
Equivalence alignment. Both the new count and the actual coalescing share one representation. numPartitionsProjectedOn counts projectKeys(positions)._2.distinct.size; GroupPartitionsExec.grouping groups via reducedKeys.groupMap(_._1) on the sameInternalRowComparableWrapper. equals = RowOrdering.compare==0, routing float/double through SQLOrderingUtil.compareDoubles (if (x==y) 0 else Double.compare) — so -0.0/0.0 compare equal and all NaN bit-patterns collapse. Predicted count and executed grouping cannot diverge. Checked the -0.0/NaN/collation cells; the wrapper hash is isCollationAware=true, predates this PR, and is used consistently — no new mismatch introduced.
Soundness of keeping a position.clusterKeyPositions keeps a position only if the expression or one of its references is a cluster key. The reference-level arm is safe only because admission through the subset branch of keysSatisfy requires expressions.forall(_.references.size == 1) (partitioning.scala:287) — confirmed present, so a kept expression is a function of a single cluster key and coalescing on it cannot separate rows sharing that key. The multi-reference counter-example (keep a+b when only a is clustered) is unreachable through keysMaySatisfy.
Single-child vs co-partitioned split.isCoPartitioned = childrenIndexes.length > 1 is computed from requiredChildDistributions only, so it is identical to master's value despite being hoisted. When true, clusterKeyPositions returns all positions, so the inline path degrades to Left(keep) or Right(_, None)(pure coalesce) and never projects — projection stays owned by the multi-child block (checkKeyGroupCompatible → applyGroupPartitions for joins; withJoinKeyPositions for cogroup). Traced the cogroup case: no double-application of positions. GroupPartitionsExec re-derives its member via collectFirst, but members share the partitionKeys reference and arity (PartitioningCollection.checkKeyedPartitioningInvariant), so projecting by the chosen member's positions lands on the same key columns whichever member executes — data co-location holds; only reported expressions may differ (metadata, not data).
satisfies over-claim is neutralized. A grouped superset satisfies via keysSatisfy but positions.size < expressions.length and the projection merges something, so satisfiedAsIs (both finds) misses and it falls through to a projecting GroupPartitionsExec — the actual fix. Conversely, when the projection merges nothing (numPartitionsAfter == numPartitions), keeping the wider partitioning is genuinely correct and strictly better for downstream co-partition. requiredNumPartitions: as-is satisfaction enforces the count through satisfies (partitioning.scala:229), and grouping satisfaction filters eligible by the post-projection count rather than vetoing the winner — a candidate that can honour the count can't lose to one that can't.
Cross-path / ordering axes. Planner-level; no codegen/interpreted asymmetry (GPE is exchange-like, doExecute only). AQE runs the same rule in queryStagePreparationRules, and the top-k window test asserts the wrong result under default (AQE-on) config. GPE's outputOrdering is untouched and conservative; ordering restored by the subsequent sort pass. OrderedDistribution selection is equivalent to master, and the sliding(2)→zip(drop(1)) change is a genuine MatchError fix (single-key partitioning). StatefulOpClusteredDistribution is a Distribution not a ClusteredDistribution, never matches the subset branch, always shuffles — consistent with its contract. UnspecifiedDistribution/AllTuples/BroadcastDistribution still resolve to keep/shuffle/broadcast.
Verdict: every enumerated axis held under tracing. I did not build/run the suites (a from-scratch sql/core compile is disproportionate where tracing found no candidate bug to reproduce); the author's report that 17/24 new tests fail without the fix matches my read that these pin real behavior rather than tautologies.
… 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>
peter-toth
commented
Sep 1, 2026
peter-toth
commented
Sep 1, 2026
Thank you all for the review! |
…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?
This builds on two changes that are now on master.
#58351 (SPARK-59057) renamed
KeyedPartitioning.isNarrowedtoisCollapsedand splitgroupedSatisfiesintokeysSatisfyandmayGroupToSatisfy. Two things here follow from that. The classification asks whether a member can satisfy the distribution once aGroupPartitionsExecis allowed, which iskeysSatisfyfor a grouped member andmayGroupToSatisfyfor a non-grouped one, since only the second may coalesce duplicate keys and only it needs the collapse permission. That composition is a newKeyedPartitioning.keysMaySatisfy, which keepskeysSatisfyprivate. And the tests that build aKeyedPartitioningstateisCollapsedexplicitly, because that parameter lost its default.#58420 (SPARK-59120) made every reader of a
KeyedPartitioning's partition keys take its types fromkeyDataTypes, 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 throwsClassCastExceptionat planning.This is an alternative to #58245, which fixes the same JIRA by adding the projection to one of the two branches below.
EnsureRequirementssplit a child'sKeyedPartitionings byisGroupedand then had two branches that each had to insert aGroupPartitionsExec. This PR classifies by what still has to happen to the data instead.splitKeyedPartitioningsnow takes the required distribution and answers two questions, in this order. Whether a non-KeyedPartitioningmember already satisfies it, and if not, how aKeyedPartitioningmember can. As it is, or after aGroupPartitionsExecprojecting to the partition expression positions returned with it, withNonepositions 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.clusterKeyPositionshelper derives those positions from the required clustering, and a newKeyedPartitioning.numPartitionsProjectedOnanswers how many partitions a projection onto them would leave.GroupPartitionsExecbecome 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. TheOrderedDistributionarm keeps its own insertion, as onmaster.clusterKeyPositionskeeps a partition expression when it is one of the operation keys.keysSatisfyrecognises that at the reference level, where abucket(4, a)transform covers the cluster keya, 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 fromKeyedShuffleSpec.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.toCatalystTransformOptmapsIdentityTransformto the resolved attribute itself, so the reference-level test matches the same position anyway, andDistributionAndOrderingUtils.prepareQuerymapsresolveTransformExpressionover a write's clustering, so aTransformExpressiondoes 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 becausekeysSatisfyalready 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
supportsExpressionsrefuses it, but nothing rejects it atKeyedPartitioningconstruction.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
satisfiesis 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, becauseKeyedPartitioning([id, name])andKeyedPartitioning([id])describe the same number of partitions and only the first lets a downstream operator co-partition onnametoo. 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 onmaster, 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
PartitioningCollectionand its members can disagree about which positions are operation keys. An inner join is where they do. ItsoutputPartitioningis the two sides' partitionings, and unlikeAliasAwareOutputExpressionit 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.projectKeysallocates a row per input partition andInternalRowComparableWrapper.hashCodeis 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, becausePartitioningCollectionguarantees its members share thepartitionKeysreference and their arity, so positioniaddresses the same key column in all of them.Distribution.requiredNumPartitionsneeds care, because aGroupPartitionsExecderives 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 throughsatisfies, 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
checkKeyGroupCompatibleand anything else throughwithJoinKeyPositions. Projecting inline as well would leave that block deriving positions from an already projected partitioning and applying them to the unprojected keys.KeyedPartitioning.satisfiesis not touched, so nothing outsideEnsureRequirementschanges behaviour. It does still answertruefor a partitioning that needs a projection first, which meansValidateRequirementscannot catch a missingGroupPartitionsExec. Giving that check the strict test directly, without changing whatsatisfiesanswers, is a follow-up we are working on.The
OrderedDistributionarm also loses aMatchError. It tested that the partition keys are sorted withpartitionKeys.sliding(2), which yields one short window for a single-key partitioning, andcase Seq(k1, k2)cannot match it, so planning threw. A v2 table whose rows all share one partition value reports such a partitioning, sinceDataSourceV2ScanExecBasehas no single-partition short-circuit, and withv2BucketingAllowSortingon, 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.
KeyedPartitioningtaughtisGroupedas the axis this PR replaces.GroupPartitionsExec.joinKeyPositionsdescribed 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
v2BucketingAllowKeysSubsetOfPartitionKeysenabled,KeyedPartitioning.keysSatisfyonly requires that some operation key overlaps the partition attributes. A partitioning grouped on(id, name)therefore reports that it satisfiesClusteredDistribution([id])while rows sharing anidstill sit on separate partitions. A storage-partitioned join projects the keys down to the operation keys incheckKeyGroupCompatible, and for a non-join operator nothing did.isGroupedis 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. AGroupPartitionsExecis 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.PARTITION BY idon an(id, name)-partitioned table surfacedid=1twice, once per(1,'aa')and(1,'bb')partition.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
GroupPartitionsExecand 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 ayears(ts)-partitioned one underv2BucketingAllowCompatibleTransformsleaves both sides grouped on(year, bucket), so two rows sharing atsin different buckets sit on separate partitions.SUM(v) OVER (PARTITION BY ts)then returns 10 and 20 onmasterwhere the answer is 30 and 30, and the projection this PR inserts is what merges the two partitions. Before #58420 that same query threwClassCastExceptionat 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
v2BucketingAllowKeysSubsetOfPartitionKeysenabled, a single-child operator whose keys are a subset of the partition keys now produces correct results, namely a windowPARTITION BYand the single-pass aggregate shapes (FlatMapGroupsInBatchExec,ArrowAggregatePythonExec,MapGroupsExec). A two-phase SQL aggregate was already correct, because its partialHashAggregateis aPartitioningPreservingUnaryExecNode, so it narrowsKP([id, name])toKP([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
requiredNumPartitionsrule applies whetherallowKeysSubsetOfPartitionKeysis on or off, since a scan still needsspark.sql.sources.v2.bucketing.enabledto report aKeyedPartitioningat all, andmasterhas no count check on the grouping path. A non-groupedKeyedPartitioningwith 3 partitions and 2 distinct keys underClusteredDistribution([k], requiredNumPartitions = Some(3))got aGroupPartitionsExecwith 2 partitions onmasterand now gets a shuffle with 3. I could not find a query where such a distribution meets aKeyedPartitioningtoday, so this is robustness rather than a reachable wrong result. OnlyStatefulOperatorPartitioningandAQEUtilsever set the requirement. TheAQEUtilsone fires only over aHashPartitioningchild.StatefulOperatorPartitioningsets it through a plainClusteredDistributionwhenspark.sql.streaming.statefulOperator.useStrictDistributionis off, and a streaming scan never reports aKeyedPartitioning, becauseMicroBatchScanExec,ContinuousScanExecandRealTimeStreamScanExecall leavekeyGroupedPartitioningatNone. The initial-state child offlatMapGroupsWithStateandtransformWithStateis 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 theClusteredDistributioncontract 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
MatchErrorfix is gated onv2BucketingAllowSortinginstead, which is also off by default.explaingains one label where the new projection happens. AGroupPartitionsExecinserted for a single-child operator now carriesjoinKeyPositions, so the node printsJoinKeyPositions: [...]where it printed nothing before. The name is historical, and the scaladoc now says the projection is not join-specific. This needsallowKeysSubsetOfPartitionKeyson, so no golden file moves.How was this patch tested?
Added regression tests in
KeyGroupedPartitioningSuite:PARTITION BYa subset of the partition keys, for bothPARTITION BY idand the duplicatedPARTITION BY id, idKeyedPartitioning, asserting the inserted node projects to the operation key rather than only coalescingGroupPartitionsExecand no shuffle when projecting to the operation keys merges nothingPartitioningCollection, 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 keystsend up on one partitionand in
EnsureRequirementsSuite:FlatMapCoGroupsInPandasExecover(n, i)-partitioned children grouped oni, asserting both sides are grouped oniand not onnKeyedPartitioningwhose count differs fromrequiredNumPartitions, asserting the count is still honoured with a shuffle(n, i)-partitionedKeyedPartitioningwhose count matchesrequiredNumPartitionsbut which needs a projection, asserting it falls back to a shuffle rather than to a node that would break the count it just passedKeyedPartitioningwhose post-projection count matchesrequiredNumPartitions, asserting it still groups on the operation key with no shuffleKeyedPartitioningwhose post-grouping count matchesrequiredNumPartitions, asserting it still groups without a shuffleallowKeysSubsetOfPartitionKeysleft at its default, asserting the shuffle(id, years(ts))-partitionedKeyedPartitioningclustered on those same two expressions, with and withoutrequireAllClusterKeys, asserting no node is inserted when a cluster key is the partition expression itself(bucket(4, a), b)-partitionedKeyedPartitioningclustered onaalone, 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.masterinserts no node at all here, so the partitions sharing a bucket stay apart.KeyedPartitioningunderOrderedDistribution, asserting planning no longer throws aMatchErrorKeyedPartitioningwith no partition expressions, asserting it is still kept as it is, since without that guard it reaches the shuffle branch, whereUnspecifiedDistribution.createPartitioningthrowsKeyedPartitioningwhose expressions reference no column, asserting the child is shuffled rather than projected to no position at allPartitioningCollectionwhose two members cover a different number of operation keys, asserting the wider one supplies the projectionrequiredNumPartitions, asserting it is used rather than pruned and lost to the wider oneand in
ProjectedOrderingAndPartitioningSuitea grouped and collapsedKeyedPartitioning, assertingkeysMaySatisfyaccepts it wheremayGroupToSatisfyrefuses 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, theMatchError, four of the sixrequiredNumPartitionstests, all five multi-member ones and thekeysMaySatisfyone, 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 winssurvives 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 theisCoPartitionedguard also fails two pre-existing SPJ tests.DistributionSuiteandShuffleSpecSuitepass with 29 tests, andKeyGroupedPartitioningSuite,EnsureRequirementsSuite,ProjectedOrderingAndPartitioningSuite,GroupPartitionsExecSuiteandPlannerSuitepass with 295.The window
PARTITION BYtests come from #58245.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code
Co-authored-by: Xiduo You ulyssesyou@apache.org