Skip to content

[SPARK-59026][SQL] Propagate isNarrowed in KeyedPartitioning.toGrouped and KeyedShuffleSpec.createPartitioning - #58316

Open
dongjoon-hyun wants to merge 1 commit into
apache:masterfrom
dongjoon-hyun:SPARK-59026
Open

[SPARK-59026][SQL] Propagate isNarrowed in KeyedPartitioning.toGrouped and KeyedShuffleSpec.createPartitioning#58316
dongjoon-hyun wants to merge 1 commit into
apache:masterfrom
dongjoon-hyun:SPARK-59026

Conversation

@dongjoon-hyun

@dongjoon-hyun dongjoon-hyun commented Aug 26, 2026

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

This PR fixes two spots in partitioning.scala where the KeyedPartitioning.isNarrowed flag
was silently dropped:

  • KeyedShuffleSpec.createPartitioning built the shuffled side's KeyedPartitioning with the
    3-argument constructor, so isNarrowed fell back to the default false even when the
    template partitioning was narrowed.
  • KeyedPartitioning.toGrouped likewise dropped the flag when building the grouped variant.

Both now propagate isNarrowed from the source partitioning.

Why are the changes needed?

isNarrowed marks a KeyedPartitioning that was derived from a finer-grained one by dropping
key positions. AliasAwareOutputExpression documents that this flag must be sticky: a
narrowed, non-grouped partitioning must not satisfy ClusteredDistribution via grouping
unless spark.sql.sources.v2.bucketing.allowJoinKeysSubsetOfPartitionKeys.enabled is on,
because GroupPartitionsExec would merge partitions that held distinct keys in the original
partitioning, carrying the same skew risk.

KeyedShuffleSpec.createPartitioning broke that stickiness. When a narrowed-but-grouped
partitioning is used as the shuffle template for the other join leg (with
spark.sql.sources.v2.bucketing.shuffle.enabled), the shuffled side mirrors the narrowed
side's partition keys but declared isNarrowed=false. If an outer join then exposes only the
shuffled side and UnionExec merges it with an overlapping-key sibling into an ungrouped
KeyedPartitioning, the isNarrowed && !isGrouped guard in groupedSatisfies never fires,
and a downstream operator can coalesce the narrowed keys with GroupPartitionsExec without
the opt-in config — bypassing the skew protection. The new end-to-end test reproduces this
chain and fails without the fix.

The flag was lost in the refactoring of SPARK-55535/SPARK-55092 (a1c62dd).

Does this PR introduce any user-facing change?

Yes, a narrow plan-level fix. In the multi-stage scenario above, Spark previously coalesced
partitions derived from a narrowed partitioning without
allowJoinKeysSubsetOfPartitionKeys.enabled, risking skewed partitions; it now inserts a
shuffle unless the config is enabled. Query results are unchanged.

How was this patch tested?

  • New end-to-end test in KeyGroupedPartitioningSuite reproducing the full chain
    (narrowing projection -> shuffle from the narrowed template -> right outer join -> union
    merge -> aggregate), verified to fail without the fix.
  • New unit test in DistributionSuite pinning flag propagation through toGrouped and
    KeyedShuffleSpec.createPartitioning.
  • Passed KeyGroupedPartitioningSuite (100 tests), DistributionSuite,
    ProjectedOrderingAndPartitioningSuite, and EnsureRequirementsSuite.

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

Generated-by: Claude Fable 5

@dongjoon-hyun

Copy link
Copy Markdown
Member Author

Could you review this PR, @peter-toth ?

@dongjoon-hyun

Copy link
Copy Markdown
Member Author

@peter-toth

Copy link
Copy Markdown
Contributor

This overlaps with my not yet open SPARK-58974 fix. Let me think about how to proceed.

@dongjoon-hyun

Copy link
Copy Markdown
Member Author

Feel free to open your PR independently. Let's see the code difference, @peter-toth .

@peter-toth

Copy link
Copy Markdown
Contributor

@dongjoon-hyun I filed SPARK-58974 for a related but separate defect in the same guard (#58338), and I want to be transparent about how the two relate.

The isNarrowed && !isGrouped check sat in the requireAllClusterKeys = false arm of groupedSatisfies, so with spark.sql.requireAllClusterKeysForDistribution = true it never ran at all. That is what #58338 fixes. The two defects are independent: when I add spark.sql.requireAllClusterKeysForDistribution = true to your new test, it fails on your current head (8c7e66d53bd) - the aggregate coalesces the narrowed keys with allowKeysSubsetOfPartitionKeys off, one shuffle instead of two. Unmodified, your test passes. Your change fixes the value the check reads; mine fixes whether the check runs at all.

I first had both fixes in one PR and then dropped yours from it, so #58338 is only the hoist. The reason is that I do not think this PR is the right fix. toGrouped's result never becomes a node's outputPartitioning - its only caller reads the keys, types and numPartitions to build the physical KeyGroupedPartitioner - so that half is not observable. GroupPartitionsExec.outputPartitioning is the node that actually rebuilds the partitioning after grouping, and it drops the flag too, which this PR does not cover.

The deeper point, which I also wrote into #58338's description: the isNarrowed contract itself is the problem. The flag records provenance ("positions were dropped, or my input was already narrowed"), while the guard's own comment describes collapse ("partitions that held distinct keys in the original finer-grained partitioning"). !isGrouped has causes that have nothing to do with narrowing - a source that reports several splits per partition key, or a union whose children have distinct keys individually and repeat keys across children. So restoring the flag makes the guard refuse in shapes where grouping would merge only same-key partitions, which needs no opt-in. In my measurements that costs an extra shuffle, and in one partially-clustered case also a GroupPartitionsExec above the join.

So I am following up with the contract fix: the flag will mean actual collapse - set only when a projection loses distinct keys, sticky from there on - which is decidable at the one site that projects the keys, since both key lists are in hand there. isNarrowed arrived in 4.3.0 and 4.3 is unreleased, so we can still correct what it means rather than preserve it, and I am aiming to get both the hoist and the contract fix into 4.3. That is the direction I would take instead of this PR: with collapse semantics the flag is false in exactly the shapes where restoring it changes a decision today, so the propagation stops mattering. I will open it under its own JIRA and link it here.

@dongjoon-hyun

Copy link
Copy Markdown
Member Author

No problem at all from my side. Please proceed in your ways ASAP before RC1, @peter-toth .

@peter-toth

Copy link
Copy Markdown
Contributor

The follow-up I promised is open: #58351 (SPARK-59057). It makes the flag mean actual key collapse -- a projection that mapped keys which were distinct in the input onto the same projected key -- instead of "positions were dropped", and renames it to isCollapsed.

Your finding is in it: all five producers that rebuild a KeyedPartitioning now propagate the flag, and your DistributionSuite test is taken with credit. Your end-to-end test is not, and that is worth flagging: its items table has unique ids, so dropping name keeps every key distinct, which under the new contract is not a coarsening at all. That scenario is exactly the one the change reclassifies, so the two PRs cannot both land as written. #58351 says it supersedes this one, but closing this is your call.

peter-toth added a commit to peter-toth/spark that referenced this pull request Aug 28, 2026
…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* -- "a projection dropped key positions, or my input already had some dropped" -- while the gate's own comment describes *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`: the partitioning is coarser than the layout it was derived from, because a projection mapped keys that were distinct in the input onto the same projected key. Dropping key positions no longer sets it 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 keeps its two terms, `isCollapsed && !isGrouped`, and the code now says why: `isCollapsed` states that the coarsening happened, `!isGrouped` states that there is still something left for `GroupPartitionsExec` to merge. Once the keys are unique, grouping merges nothing and there is no further risk to gate, however coarse the partitioning already is.

Producers:

* `PartitioningPreservingUnaryExecNode` computes it as `keySource.isCollapsed || (positions were dropped && projected distinct keys < input distinct keys)`. The two cheap terms come first so the distinct count is skipped for an inherited flag or a pass-through projection.
* `UnionExec`'s keyed merge is unchanged apart from the rename -- it ORs the children's flags. It stops refusing the case where the children's keys merely overlap, because the children's flags are now precise; a child that really is coarsened still marks the union, as before.
* `GroupPartitionsExec`, `KeyedPartitioning.toGrouped`, `KeyedPartitioning.createShuffleSpec` and `KeyedShuffleSpec.createPartitioning` all propagate the flag instead of defaulting it to `false` through the 3-argument constructor. `GroupPartitionsExec` and `createShuffleSpec` project onto the operation keys, so they also compute their own coarsening. `GroupPartitionsExec` compares against the key count of its own side after projection and reduction, not against the aligned key list: that list is the one both join sides agreed on, so it can be missing keys this side had (partition filtering) or repeat them (padding), and neither is a collapse. It 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 that. 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 forcing the distinct key count on every member. Note 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.

`KeyedPartitioning` gains a `distinctKeyCount` lazy val for this: free when the partitioning is grouped, and computed on demand otherwise. It is lazy so that the members of a `PartitioningCollection`, which share one key list, do not each force it when a consumer only needs one.

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: its table has unique ids, so dropping the second key column keeps every key distinct, and that scenario is exactly what this change reclassifies as not coarsened. The two cannot both land as written.

The class doc also gains a section on coarsened partitionings, including why such a partitioning is kept rather than dropped to `UnknownPartitioning` -- that rationale was nowhere in the code.

### Why are the changes needed?

Provenance over-refuses, and it does so on one of the commonest shapes. `!isGrouped` has causes that have nothing to do with narrowing:

* 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, which is what `GroupPartitionsExec` does for any partitioning that never went through a projection, and 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)` with two splits for the same `(1, 'x')` value, projected down to `id` and 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: 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 -- opt-in on, every position selected, source already collapsed and grouped -- so `EnsureRequirements`' "child partitionings not modified" fast path fires where it previously did not. Same plan, one less rebuild.

Reducers are a second case: with `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join really does end up coarser than its source, 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: an ungrouped source (so the distinct count is a real pass), every hop dropping a position (so the cheap term does not short-circuit) and no hop collapsing a key (so the inherited flag does not either). 20 evaluations of a 10-hop chain over a 50k-split, 25k-key partitioning: 1020 ms before, 1738 ms after, i.e. about 3.6 ms per narrowing hop on top of the distinct pass `isGrouped` already needs. A pass-through hop pays nothing, since it cannot coarsen anything.

### 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 `groupedSatisfies` 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 coarsened member anywhere in a `PartitioningCollection` marks every projected partitioning, so the outcome does not depend on which join side the coarsening came from.
* New end-to-end test that reducing keys onto a coarser transform reports the coarsening: with `allowCompatibleTransforms`, an `identity(item_id)` side reduced onto `bucket(4, id)` really is coarser than its source, 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 itself coarsened -- 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 the multi-split unit test, its end-to-end counterpart and the flipped `SPARK-46367` scenario; comparing against the aligned key list instead of this side's own count fails the partition-filter test, where the shuffle count goes from 0 to 1; 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`, `KeyGroupedPartitioningSuite`, `ProjectedOrderingAndPartitioningSuite`, `EnsureRequirementsSuite`, `PlannerSuite`, `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?

Co-authored-by: Dongjoon Hyun <dongjoon@apache.org>
Generated-by: Claude Code (Opus 5)
peter-toth added a commit to peter-toth/spark that referenced this pull request Aug 28, 2026
…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* -- "a projection dropped key positions, or my input already had some dropped" -- while the gate's own comment describes *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`: the partitioning is coarser than the layout it was derived from, because a projection mapped keys that were distinct in the input onto the same projected key. Dropping key positions no longer sets it 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 keeps its two terms, `isCollapsed && !isGrouped`, and the code now says why: `isCollapsed` states that the coarsening happened, `!isGrouped` states that there is still something left for `GroupPartitionsExec` to merge. Once the keys are unique, grouping merges nothing and there is no further risk to gate, however coarse the partitioning already is.

Producers:

* `PartitioningPreservingUnaryExecNode` computes it as `keySource.isCollapsed || (positions were dropped && projected distinct keys < input distinct keys)`. The two cheap terms come first so the distinct count is skipped for an inherited flag or a pass-through projection.
* `UnionExec`'s keyed merge is unchanged apart from the rename -- it ORs the children's flags. It stops refusing the case where the children's keys merely overlap, because the children's flags are now precise; a child that really is coarsened still marks the union, as before.
* `GroupPartitionsExec`, `KeyedPartitioning.toGrouped`, `KeyedPartitioning.createShuffleSpec` and `KeyedShuffleSpec.createPartitioning` all propagate the flag instead of defaulting it to `false` through the 3-argument constructor. `GroupPartitionsExec` and `createShuffleSpec` project onto the operation keys, so they also compute their own coarsening. `GroupPartitionsExec` compares against the key count of its own side after projection and reduction, not against the aligned key list: that list is the one both join sides agreed on, so it can be missing keys this side had (partition filtering) or repeat them (padding), and neither is a collapse. It 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 that. 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 forcing the distinct key count on every member. Note 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.

`KeyedPartitioning` gains a `distinctKeyCount` lazy val for this: free when the partitioning is grouped, and computed on demand otherwise. It is lazy so that the members of a `PartitioningCollection`, which share one key list, do not each force it when a consumer only needs one.

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: its table has unique ids, so dropping the second key column keeps every key distinct, and that scenario is exactly what this change reclassifies as not coarsened. The two cannot both land as written.

The class doc also gains a section on coarsened partitionings, including why such a partitioning is kept rather than dropped to `UnknownPartitioning` -- that rationale was nowhere in the code.

### Why are the changes needed?

Provenance over-refuses, and it does so on one of the commonest shapes. `!isGrouped` has causes that have nothing to do with narrowing:

* 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, which is what `GroupPartitionsExec` does for any partitioning that never went through a projection, and 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)` with two splits for the same `(1, 'x')` value, projected down to `id` and 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: 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 -- opt-in on, every position selected, source already collapsed and grouped -- so `EnsureRequirements`' "child partitionings not modified" fast path fires where it previously did not. Same plan, one less rebuild.

Reducers are a second case: with `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join really does end up coarser than its source, 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: an ungrouped source (so the distinct count is a real pass), every hop dropping a position (so the cheap term does not short-circuit) and no hop collapsing a key (so the inherited flag does not either). 20 evaluations of a 10-hop chain over a 50k-split, 25k-key partitioning: 1020 ms before, 1738 ms after, i.e. about 3.6 ms per narrowing hop on top of the distinct pass `isGrouped` already needs. A pass-through hop pays nothing, since it cannot coarsen anything.

### 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 `groupedSatisfies` 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 coarsened member anywhere in a `PartitioningCollection` marks every projected partitioning, so the outcome does not depend on which join side the coarsening came from.
* New end-to-end test that reducing keys onto a coarser transform reports the coarsening: with `allowCompatibleTransforms`, an `identity(item_id)` side reduced onto `bucket(4, id)` really is coarser than its source, 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 itself coarsened -- 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 the multi-split unit test, its end-to-end counterpart and the flipped `SPARK-46367` scenario; comparing against the aligned key list instead of this side's own count fails the partition-filter test, where the shuffle count goes from 0 to 1; 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`, `KeyGroupedPartitioningSuite`, `ProjectedOrderingAndPartitioningSuite`, `EnsureRequirementsSuite`, `PlannerSuite`, `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?

Co-authored-by: Dongjoon Hyun <dongjoon@apache.org>
Generated-by: Claude Code (Opus 5)
peter-toth added a commit to peter-toth/spark that referenced this pull request Aug 28, 2026
…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* -- "a projection dropped key positions, or my input already had some dropped" -- while the gate's own comment describes *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`: the partitioning is coarser than the layout it was derived from, because a projection mapped keys that were distinct in the input onto the same projected key. Dropping key positions no longer sets it 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 keeps its two terms, `isCollapsed && !isGrouped`, and the code now says why: `isCollapsed` states that the coarsening happened, `!isGrouped` states that there is still something left for `GroupPartitionsExec` to merge. Once the keys are unique, grouping merges nothing and there is no further risk to gate, however coarse the partitioning already is.

Producers:

* `PartitioningPreservingUnaryExecNode` computes it as `keySource.isCollapsed || (positions were dropped && projected distinct keys < input distinct keys)`. The two cheap terms come first so the distinct count is skipped for an inherited flag or a pass-through projection.
* `UnionExec`'s keyed merge is unchanged apart from the rename -- it ORs the children's flags. It stops refusing the case where the children's keys merely overlap, because the children's flags are now precise; a child that really is coarsened still marks the union, as before.
* `GroupPartitionsExec`, `KeyedPartitioning.toGrouped`, `KeyedPartitioning.createShuffleSpec` and `KeyedShuffleSpec.createPartitioning` all propagate the flag instead of defaulting it to `false` through the 3-argument constructor. `GroupPartitionsExec` and `createShuffleSpec` project onto the operation keys, so they also compute their own coarsening. `GroupPartitionsExec` compares against the key count of its own side after projection and reduction, not against the aligned key list: that list is the one both join sides agreed on, so it can be missing keys this side had (partition filtering) or repeat them (padding), and neither is a collapse. It 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 that. 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 forcing the distinct key count on every member. Note 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.

`KeyedPartitioning` gains a `distinctKeyCount` lazy val for this: free when the partitioning is grouped, and computed on demand otherwise. It is lazy so that the members of a `PartitioningCollection`, which share one key list, do not each force it when a consumer only needs one.

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: its table has unique ids, so dropping the second key column keeps every key distinct, and that scenario is exactly what this change reclassifies as not coarsened. The two cannot both land as written.

The class doc also gains a section on coarsened partitionings, including why such a partitioning is kept rather than dropped to `UnknownPartitioning` -- that rationale was nowhere in the code.

### 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** -- `toGrouped`, `createShuffleSpec`'s projected partitioning, `KeyedShuffleSpec.createPartitioning`, `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 -- a real collapse -- but 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` uses `nonGrouped.find(_.groupedSatisfies(distribution))`, which accepts when any single member does, so a collection holding one collapsed and one plain member reached a `GroupPartitionsExec` 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 narrowing:

* 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, which is what `GroupPartitionsExec` does for any partitioning that never went through a projection, and 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)` with two splits for the same `(1, 'x')` value, projected down to `id` and 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: 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 -- opt-in on, every position selected, source already collapsed and grouped -- so `EnsureRequirements`' "child partitionings not modified" fast path fires where it previously did not. Same plan, one less rebuild.

Reducers are a second case: with `allowCompatibleTransforms`, a `bucket(4, id)` side reduced onto a `bucket(2, id)` join really does end up coarser than its source, 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: an ungrouped source (so the distinct count is a real pass), every hop dropping a position (so the cheap term does not short-circuit) and no hop collapsing a key (so the inherited flag does not either). 20 evaluations of a 10-hop chain over a 50k-split, 25k-key partitioning: 1020 ms before, 1738 ms after, i.e. about 3.6 ms per narrowing hop on top of the distinct pass `isGrouped` already needs. A pass-through hop pays nothing, since it cannot coarsen anything.

### 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 `groupedSatisfies` 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 coarsened member anywhere in a `PartitioningCollection` marks every projected partitioning, so the outcome does not depend on which join side the coarsening came from.
* 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 reports the coarsening: with `allowCompatibleTransforms`, an `identity(item_id)` side reduced onto `bucket(4, id)` really is coarser than its source, 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 itself coarsened -- 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 the multi-split unit test, its end-to-end counterpart and the flipped `SPARK-46367` scenario; comparing against the aligned key list instead of this side's own count fails the partition-filter test, where the shuffle count goes from 0 to 1; 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`, `KeyGroupedPartitioningSuite`, `ProjectedOrderingAndPartitioningSuite`, `EnsureRequirementsSuite`, `PlannerSuite`, `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?

Co-authored-by: Dongjoon Hyun <dongjoon@apache.org>
Generated-by: Claude Code (Opus 5)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants