[SPARK-58968][SQL] Fix data correctness issue when SPJ allowKeysSubsetOfPartitionKeys - #58245
[SPARK-58968][SQL] Fix data correctness issue when SPJ allowKeysSubsetOfPartitionKeys#58245ulysses-you wants to merge 1 commit into
Conversation
…tOfPartitionKeys Insert a GroupPartitionsExec projecting to the operation keys for a non-join operator when the operation keys are a strict subset of the partition keys, so partitions sharing the same operation key are coalesced. Co-Authored-By: Claude <noreply@anthropic.com>
|
cc @peter-toth @cloud-fan thank you |
|
Let me think about this issue tomorrow. |
gengliangwang
left a comment
There was a problem hiding this comment.
0 blocking, 3 non-blocking, 0 nits.
The fix is correctly scoped and well gated behind its config, with one design question about how the new guard picks the operators it applies to.
Design / architecture (1)
- sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:67:
isJoinkeys on the ShuffledJoin marker trait rather than on whether the operator enters the two-child co-partitioning block, so the guarded and handled sets do not match in either direction. -- see inline
Suggestions (2)
- sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:4210: Add an aggregate (GROUP BY a subset) test and a two-clustered-child (cogroup) test; the three new tests are all top-k windows. -- see inline
- sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:124: Only
joinKeyPositionsis read here, butcreateShuffleSpecalso builds a projected KeyedPartitioning over every partition key that this call site discards. -- see inline
Verification
CI is fully green on this head (31 check runs, no failures or pending), so my pass focused on the parts a green build does not settle.
Four things I checked and found sound. The unchecked asInstanceOf[KeyedShuffleSpec] cannot fail, because KeyedPartitioning.createShuffleSpec constructs KeyedShuffleSpec(this, distribution) and returns either it or a copy of it on both branches. The strict-subset comparison against expressions.indices is exact rather than incidentally correct: joinKeyPositions is built as keyPositions.map(_.nonEmpty).zipWithIndex.filter(_._1).map(_._2), always ascending and always a subsequence, so no permutation of clustering keys can make a full key set look like a subset. Stateful streaming is structurally excluded, since StatefulOpClusteredDistribution is not a ClusteredDistribution. And the sibling nonGroupedSatisfiesAsIs branch cannot hide the same unfixed bug, because nonGroupedSatisfies delegates to the base Partitioning.satisfies0, which answers only UnspecifiedDistribution and AllTuples and returns false for every ClusteredDistribution.
One thing I checked that is safe but not for the stated reason. SortMergeAsOfJoinExec is a third ShuffledJoin, so isJoin excludes it, yet checkKeyGroupCompatible returns None for it - the guard's justification does not cover it. It is nonetheless correct today: with v2BucketingShuffleEnabled at its default false, KeyedShuffleSpec.canCreatePartitioning is false, bestSpecOpt is empty, and each clustered child falls through to a plain ShuffleExchangeExec, which establishes the distribution outright. An as-of join gets correct results, just not storage-partitioned execution - the same as before this PR.
What I could not settle is the CoGroupExec interaction in the finding below; it needs a run with two non-default configs, which is why it is posted as a question rather than an assertion.
PR metadata suggestions
- Document: the change also affects non-join operators that have two clustered children (
CoGroupExecand the Python cogroups), not just single-child operators like the window in the example - that is where the new inline grouping meets the existing two-child co-partitioning block. - Name aggregates explicitly alongside the window example:
GROUP BYa subset of the partition keys hits the identical branch and duplicates groups without the fix, and it is the shape most users will recognize.
| // A storage-partitioned join handles its co-partitioning (and the projected join-key | ||
| // GroupPartitionsExec) separately in checkKeyGroupCompatible, so the projected-key grouping | ||
| // below must not run for it. For a non-join operator, do it inline here. | ||
| val isJoin = parent.exists(_.isInstanceOf[ShuffledJoin]) |
There was a problem hiding this comment.
This guard states a different condition from the one the comment appeals to, and the two sets come apart in both directions.
The comment says joins are excluded because checkKeyGroupCompatible handles their projection. But that helper matches only SortMergeJoinExec and ShuffledHashJoinExec, while the block that calls it is entered for any operator with two clustered children (parent.isDefined && children.length == 2 && childrenIndexes.length == 2).
One direction is currently harmless: SortMergeAsOfJoinExec is also a ShuffledJoin, so it is excluded here yet unhandled there. It still gets correct results, but by falling through to a plain shuffle rather than by anything this comment describes.
The other direction concerns me, and I could not finish confirming it - hence a question. CoGroupExec requires ClusteredDistribution on both children (objects.scala:638) and is not a ShuffledJoin, so this branch does run for it. children is then reassigned with the wrapped child, and specs a few lines down are computed from children(i).outputPartitioning - the projected partitioning. checkKeyGroupCompatible returns None, so areChildrenCompatible is false and each clustered child reaches withJoinKeyPositions(child, joinKeyPositions), which rewrites the node in place via g.copy(joinKeyPositions = Some(positions)). Those positions index the projected expression list, but GroupPartitionsExec applies them to its child's unprojected partitioning. For tables partitioned by (name, id) cogrouped on id: this branch computes [1] and inserts a node projecting to id; the block recomputes [0] against the now one-element list and overwrites, so the node projects position 0 of (name, id) and coalesces by name.
I did not verify the observable result. Reaching the overwrite also needs v2BucketingShuffleEnabled on, since it gates KeyedShuffleSpec.canCreatePartitioning - with the default false, bestSpecOpt is empty and the fallback shuffles and unwraps the node instead. Could you confirm whether a cogroup over two storage-partitioned tables with both configs on and a non-leading grouping key produces wrong groups?
Either way I'd gate on the structural fact rather than the trait: run this branch only when the operator will not enter that block, i.e. childrenIndexes.length <= 1. That is what the comment is really appealing to, it covers cogroups and as-of joins without enumerating join classes, and it keeps one owner of the projection per operator so a future ShuffledJoin or a new parent case in checkKeyGroupCompatible cannot move the boundary silently.
| } | ||
| } | ||
|
|
||
| test("window top-k over PARTITION BY subset of partition keys coalesces partitions") { |
There was a problem hiding this comment.
Consider adding an aggregate variant next to these. HashAggregateExec requires ClusteredDistribution(groupingExpressions), so SELECT id, sum(price) FROM items GROUP BY id on an (id, name)-partitioned table reaches this exact branch, and without the fix it emits one row per (id, name) partition instead of per id - a duplicated group, which is arguably a more familiar symptom than a ranking artifact. All three new tests go through ROW_NUMBER() OVER (PARTITION BY ...), so a reader could reasonably think the fix is window-specific.
A cogroup test would be worth more still, because that case is one the change newly affects rather than fixes: df1.groupByKey(...).cogroup(df2.groupByKey(...)) over two storage-partitioned tables is a two-clustered-child operator that is not a ShuffledJoin, so it takes the new branch and then also goes through the co-partitioning block. That is the interaction I asked about on the isJoin line.
| val kp = groupedSatisfies.get | ||
| distribution match { | ||
| case c: ClusteredDistribution if !isJoin => | ||
| val spec = kp.createShuffleSpec(c).asInstanceOf[KeyedShuffleSpec] |
There was a problem hiding this comment.
Only spec.joinKeyPositions is used, but with the config enabled createShuffleSpec also runs projectKeys(joinKeyPositions)._2 across every partition key and then .distinct on the result, to build a projectedPartitioning that this call site drops - and GroupPartitionsExec recomputes the same projection later from child.outputPartitioning. That is two O(number of partitions) passes per qualifying operator at planning time, on a path that exists precisely for tables partitioned finely enough to need coalescing.
KeyedShuffleSpec(kp, c).keyPositions gives the same information without the discarded projection, and reads more directly as "which partition expressions does this operation actually key on" than routing through a shuffle-spec factory. The cast also goes away with it.
|
@ulysses-you I ended up trying a different shape for this one - classifying a |
What changes were proposed in this pull request?
For a non-join operator, when a grouped
KeyedPartitioningsatisfies aClusteredDistributionwhose clustering keys are a strict subset of the partition keys (viav2BucketingAllowKeysSubsetOfPartitionKeys),EnsureRequirementsnow inserts aGroupPartitionsExecthat projects the partition keys to the operation keys, so partitions sharing the same operation key are coalesced.Why are the changes needed?
When
v2BucketingAllowKeysSubsetOfPartitionKeysis enabled and a storage-partitioned source is partitioned by more keys than the operation requires (e.g. a table partitioned by(id, name)with a windowPARTITION BY id), the groupedKeyedPartitioningsatisfies theClusteredDistributionthrough the subset-key relaxation, but the partitions are still grouped by the full partition keys rather than by the operation keys. Without coalescing them, the downstream operator ranks/aggregates each partition independently, producing incorrect results.For example, a top-k window over
PARTITION BY idon a(id, name)-partitioned table would surfaceid=1twice (once per(1,'aa')/(1,'bb')partition) instead of once.Does this PR introduce any user-facing change?
Yes, it fixes a data correctness issue: storage-partitioned windows (and other non-join operators) with
PARTITION BYa subset of the partition keys now produce correct results whenv2BucketingAllowKeysSubsetOfPartitionKeysis enabled.How was this patch tested?
Added regression tests in
KeyGroupedPartitioningSuitecovering:PARTITION BYa subset of the partition keysPARTITION BYkeyVerified
org.apache.spark.sql.connector.KeyGroupedPartitioningSuiteandorg.apache.spark.sql.execution.exchange.EnsureRequirementsSuitepass.Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code