Skip to content

[SPARK-59688][SQL] Fix wrong results when a storage-partitioned join skips the reduce on coinciding partition keys - #58943

Closed
peter-toth wants to merge 2 commits into
apache:masterfrom
peter-toth:SPARK-59688-coinciding-partition-keys
Closed

peter-toth wants to merge 2 commits into
apache:masterfrom
peter-toth:SPARK-59688-coinciding-partition-keys

Conversation

@peter-toth

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

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

KeyedShuffleSpec.isCompatibleWith answers whether two key-grouped children are lined up as they stand, so that a join can pair their partitions up index by index with no shuffle and no GroupPartitionsExec. It ends in KeyLayout.describesSameKeys, which compares the two sides' partition key rows and their types. That comparison only says what it looks like it says when both sides' keys are values of the same thing.

The predicate it consults first, areKeysCompatible, is deliberately looser than that. Under spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled its isExpressionCompatible admits an AttributeReference against a TransformExpression, and two different but reducible transforms, because EnsureRequirements reduces such a pair onto one key space before it pairs the partitions up. So isCompatibleWith was reading equal key rows as "lined up" for two sides whose keys are not in one space yet.

This PR gives areKeysCompatible an allowReduce parameter, threaded into isExpressionCompatible, and has isCompatibleWith ask with allowReduce = false:

  • EnsureRequirements keeps the loose question where it selects the member pair to plan on (agreeingPairs), because it is the caller that runs the reduce. The parameter has no default, so every caller says which question it is asking.
  • isCompatibleWith gets the strict one, so a pair that still needs reconciling is not reported as compatible as it stands. compatibleAsIs is then false, and the join takes the push branch that computes the reducers and regroups both sides onto the merged keys.
  • A pair an earlier join reduced together is unaffected: those keys are in one space already, and isExpressionCompatible's reduced-keys arm answers for them through hasSameReducedKeys whatever allowReduce says.
  • The unknown-partition-keys branch inside areKeysCompatible already required this same single-key-space property before comparing key subsets, through a hand-written matcher. It now calls the shared predicate with the reduce disallowed, in the one pass the method already made. That comparison also asks that neither side's keys were reduced, which a marked layout never carries: asked rather than asserted, so that a producer the argument misses costs a shuffle rather than the query.

The strict answer is also transitive, which the loose one is not: bucket(12) reduces onto bucket(4) and bucket(4) onto bucket(8), while bucket(12) and bucket(8) have no reducer between them. ValidateRequirements compares every child against specs.head alone, so a three-child operator could be accepted on a chain that does not hold pairwise. isSameFunction is an equivalence, so that hole closes with this.

Why are the changes needed?

Wrong results. Take a table partitioned by identity(id) and one partitioned by a connector transform that permutes its key space, say flip_low_bit(id) = id ^ 1, with ids 0 and 1 in both:

SELECT t1.id, t1.data, t2.data FROM t1 JOIN t2 ON t1.id = t2.id

Both scans report the partition key list [0, 1] of LongType, so describesSameKeys holds, but the rows behind a key differ: the identity side's key 0 holds id = 0 while the transform side's holds id = 1. The join pairs partition 0 with partition 0 and finds no match in either pair, so it returns 0 rows instead of 2, with no shuffle and no GroupPartitionsExec in the plan. The new end-to-end test measures exactly this on master.

The Reducer contract is what the fast path implicitly assumed more of. It says r(f1(x)) = f2(x), and nothing about r leaving alone the keys it is applied to, so on a one-side reduce key k can belong with key r(k) on the other side. What it takes to turn that into wrong rows is a transform that permutes its key space. A many-to-one transform cannot: the two key lists coincide only where it is the identity on them, which is why neither bucket nor truncate shows it, and why this has stayed latent. Note it takes no connector Reducer at all - the identity-versus-transform arm synthesizes one from the other side's ordinary transform - so a plain ScalarFunction is enough.

The identity-versus-transform arm came in with SPARK-56182, which is on branch-4.2, branch-4.3, branch-4.x and master, so this is not a master-only bug. The blast radius is that arm, or two reducible transforms, plus coinciding key lists, plus allowCompatibleTransforms.enabled, which defaults to false: without it canReduceKeys is false, the two questions below agree at every position and this change is a no-op.

Does this PR introduce any user-facing change?

Yes, it is a bug fix. The query above returns its 2 rows instead of 0, and it still runs without a shuffle: the pair goes through the reduce, so the plan gains a GroupPartitionsExec on each side and the identity side's keys are reduced onto flip_low_bit. No configuration changes, and no public API changes (areKeysCompatible is catalyst-internal, and catalyst is excluded from MiMa).

Four consequences are worth stating, all on pairs whose key lists coincide and which now take the reduce instead of being read as they stand:

  • Where the coincidence was benign, the reducing side reports the target transform instead of its own, so a downstream operator sees the coarser claim, for instance bucket(4, id) where it used to see bucket(8, id).
  • A co-partitioned operator that is not a sort-merge or shuffled-hash join, a cogroup for instance, has no reduce branch at all: pickCoPartitionTarget pairs children on isCompatibleWith alone. Such a pair is now shuffled onto one side instead of being read as it stands. That is the right answer where the transform permutes the key space, and a lost optimization where the coincidence is benign; the Reducer API gives Spark nothing to tell the two apart with. For a cogroup the wrong answer was worse than for a join, since no equi-predicate drops the mispaired rows again, which is what the new EnsureRequirementsSuite test pins.
  • Two planning-time failures become reachable for a query that previously planned, both in place of wrong rows. storagePartitionJoinIncompatibleReducedTypesError, when a connector's Reducer.resultType() disagrees with the target transform's type, since the reduce now runs and compares them. And cannotEvaluateExpressionError for an identity-versus-transform pair whose transform is bound to something that is not a ScalarFunction, since IdentityReducer evaluates that transform and TransformExpression.resolvedFunction is empty there.

How was this patch tested?

Three new tests, each pinning a different layer, and all three fail on a revert of partitioning.scala alone:

  • KeyGroupedPartitioningSuite, end to end and the wrong-rows regression: identity(id) against flip_low_bit(id) with ids 0 and 1 in both tables. On the base it returns 0 of 2 rows; with the fix it returns both, with 0 shuffles, one GroupPartitionsExec per side, exactly one of them carrying a reducer, and ValidateRequirements.validate accepting the join subtree (the subtree, not the whole plan: the validator walks children and a query stage is a leaf, so validating an AQE plan checks nothing).
  • ShuffleSpecSuite, at the spec level, for both shapes the parameter gates: an identity side against that transform, and two bucket counts a reducer reconciles, are each admitted by areKeysCompatible and refused by isCompatibleWith, in both directions. Two positive controls keep it honest, that the two layouts do describe the same keys and that one function over one key list is still compatible with itself, and a fourth case pins that a pair reduced together stays compatible, which is what keeps a chained storage-partitioned join from shuffling.
  • EnsureRequirementsSuite, for the path that has no reduce branch: a keyed cogroup over the same pair now lays the transform side out on the identity side's keys rather than reading the two as they stand, the rule stays idempotent over the result, and a control pins that two sides holding one key space are still read as they stand.

flip_low_bit is a new fixture, and the minimal one for this: the transform has to keep its argument's type and to permute its key space, and no existing fixture does both (bucket, days, years and signed_zeros change the type, so such a pair is refused on the key types alone, while string_self is the identity and truncate is many-to-one). It is a SimpleFunction, so one object is both the unbound and the bound function, and it lives in catalyst's test sources so that InMemoryBaseTable computes its partition keys by calling produceResult rather than repeating the arithmetic, which is how the other four transforms in that file stay in step.

build/sbt 'catalyst/testOnly *ShuffleSpecSuite' 31 pass. build/sbt 'sql/testOnly *KeyGroupedPartitioningSuite *EnsureRequirementsSuite *ValidateRequirementsSuite *ProjectedOrderingAndPartitioningSuite *KeyGroupedPartitioningRuntimeFilterSuite *KeyGroupedPartitioningCatalystRuntimeFilterSuite *GroupPartitionsExecSuite' 348 pass. dev/lint-scala clean.

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

Generated-by: Claude Code (Opus 5)

…skips the reduce on coinciding partition keys

`KeyedShuffleSpec.isCompatibleWith` read equal partition key rows as "the two
sides are lined up" even for a pair whose keys are not in one key space yet, so
the join paired the partitions up index by index and skipped the reduce that
reconciles them. Ask `areKeysCompatible` with `allowReduce = false` there, so
such a pair takes the push branch instead.
@dongjoon-hyun

Copy link
Copy Markdown
Member

Thanks for the fix, @peter-toth. I went through this carefully and I agree with both the diagnosis and the place you landed the fix: isCompatibleWith really is the site that asks "are these lined up as they stand", and areKeysCompatible answers a different question. I also verified the change is monotonically stricter (isSameFunction ⊆ isCompatible, canReduce=false ⊆ true), that canReduceKeys ⇒ v2BucketingPushPartValuesEnabled keeps every reduce-admitted pair on the push branch, and that the rule stays idempotent across an AQE re-run.

One thing worth adding to the description: this also restores transitivity of isCompatibleWith. The old isCompatible-based answer was non-transitive (bucket(12) ~ bucket(4) and bucket(4) ~ bucket(8), but bucket(12) ≁ bucket(8)), while ValidateRequirements compares every child against specs.head only. isSameFunction is an equivalence, so that latent hole closes too.

Below are the things I'd like to see addressed, most important first.


1. The ValidateRequirements.validate assertion is vacuous

KeyGroupedPartitioningSuite.scala:1357

assert(ValidateRequirements.validate(plan), "the plan that leaves must hold up")

spark.sql.adaptive.enabled defaults to true and neither the test nor KeyGroupedPartitioningSuite.sparkConf disables it, so plan is an AdaptiveSparkPlanExec, which extends LeafExecNode. ValidateRequirements.validate is plan.children.forall(validate) && validateInternal(plan), and on a childless node validateInternal short-circuits to satisfied = true with the children.length > 1 co-partitioning branch never entered. Revert allowReduce = false and re-add only this assertion — it still passes.

The sibling assertions survive only because collectShuffles / collectGroupPartitions go through AdaptiveSparkPlanHelper.collect, whose allChildren explicitly descends into AdaptiveSparkPlanExec.executedPlan; ValidateRequirements does not.

This suite already documents the trap and works around it — see KeyGroupedPartitioningSuite.scala:5528-5534 ("a query stage is a leaf, so validating an AQE plan checks nothing"), which validates collect(stripAQEPlan(...)) { case smj: SortMergeJoinExec => smj }.head instead. Since the PR description cites this assertion as evidence, I'd fix it the same way.

2. Two planning-time exceptions become newly reachable

partitioning.scala:2001

Pairs that used to take the compatibleAsIs shortcut now enter the push branch. Two hard failures follow for queries that previously planned:

  • KeyedPartitioning.reduceKeys takes the reduced types from the connector's Reducer.resultType(), so a connector violating the r(f1(x)) = f2(x) type contract now hits storagePartitionJoinIncompatibleReducedTypesError.
  • For the identity-vs-transform arm, IdentityReducer.reduce calls bound.eval(...), and V2ExpressionUtils.loadV2FunctionOpt accepts any BoundFunction with no ScalarFunction check — so a transform bound to a non-ScalarFunction gives resolvedFunction = None and raises cannotEvaluateExpressionError.

Both are better than the wrong rows they replace, but they are user-visible and the "Does this PR introduce any user-facing change?" section doesn't mention either.

3. The unknown-keys branch is loosened, not tightened

partitioning.scala:2062

This is the one place the diff relaxes the predicate. The deleted arm was case (l: TransformExpression, r: TransformExpression) => l.isSameFunction(r); isExpressionCompatible now routes a marked + reduced pair to hasSameReducedKeys. Left bucket(12, a).reducedTogetherWith(bucket8) vs right bucket(8, b).reducedTogetherWith(bucket12) was refused before and is accepted now — and two marked sides route their undeclared rows at hash(key) % numPartitions in their original key spaces.

I traced all four marker producers and your unreachability claim does hold today. But it rests on three cooperating sites in three files (canCreatePartitioning's expressionsDescribeKeys, PartitionGrouping.isIdentity being false whenever a reducer slot exists, and ShuffledJoin.clearUnknownPartitionKeys) with no assertion anywhere. Either keep the explicit refusal or add assert(!hasReducedKeys(l) && !hasReducedKeys(r)).

4. Rewritten comment 3.3 is factually false

partitioning.scala:1994

3.3 each pair of partition expressions at the same index must share the same transform function.

isCompatibleWith does not require that. The reduced-keys arm answers hasSameReducedKeys, which is true for differing functionIds: bucket8.reducedTogetherWith(bucket4) has numBucketsOpt = Some(8) and bucket4.reducedTogetherWith(bucket8) has Some(4), so isSameFunction is false while hasSameReducedKeys is true — and ShuffleSpecSuite.scala:757-759, added by this PR, asserts that pair is compatible. The first arm also admits two plain attributes, which share no transform at all. The replaced wording ("compatible transform functions") was loose but true.

The risk is concrete: someone reading 3.3 as a guarantee and tightening the reduced-keys arm to isSameFunction sends every chained SPJ back to a shuffle — exactly what ShuffleSpecSuite:756-759 exists to protect.

5. The allowReduce scaladoc overstates what triggers the bug

partitioning.scala:2028

it takes a connector reducer that reorders its key space to turn this into wrong rows

For the identity-vs-transform arm the same paragraph names, there is no connector Reducer at all — reducersBothWays synthesises IdentityReducer(t.withReference(a)) from the other side's ordinary transform. Your own FlipLowBitFunction is a plain ScalarFunction[Long], not a ReducibleFunction, and it produces the wrong rows. All it takes is a transform that preserves its argument type and is not order-preserving.

Same overstatement in the fixture comment at transformFunctions.scala:290-294: "result type is its input type, and not the identity on its inputs" does not exclude the existing TruncateFunction (StringType -> StringType, not the identity). What actually makes flip_low_bit the minimal fixture is that it is a permutation of the key space; truncate is many-to-one, so it cannot mispair rows a raw-column join would match. Worth stating that way — it's the sharper reason.

6. allowReduce defaults to the unsafe value

partitioning.scala:2030

There are exactly two production call sites: EnsureRequirements.scala:703 (wants true, and is the caller that runs the reduce) and partitioning.scala:2001 (wants false). Every other consumer asks the as-they-stand question. A future site written as spec.areKeysCompatible(other) — the natural spelling — silently gets the reduce-allowed answer and reintroduces SPARK-59688 with no compile error and no failing test.

Dropping the default and writing allowReduce = true at the one EnsureRequirements call site costs a word; only ShuffleSpecSuite's existing assertions need the extra argument. Note those assertions ride the default too, so flipping it later would silently reverse their meaning rather than fail to compile.


Smaller items

  1. partitioning.scala:2100 — the delegation silently widens the marked-keys gate from (_: AttributeReference, _: AttributeReference) to (_: LeafExpression, _: LeafExpression). Unreachable today, but this is the gate protecting the partitionKeys subset comparison, and reverting this hunk alone leaves every test in the diff green — ShuffleSpecSuite:662 is the only coverage and all its inputs are AttributeReference/TransformExpression. At least say in the comment that it's a widening rather than a restatement.

  2. Cogroup path has no test. checkKeyGroupCompatible returns None for every non-SMJ/SHJ parent, so FlatMapCoGroupsInPandasExec and friends reach pickCoPartitionTarget as their only path. With v2BucketingShuffleEnabled=true, that path had the same bug, and the failure mode is worse than for joins because no equi-predicate filters the mispaired groups — the join returns zero rows, a cogroup hands the user's function rows that don't belong together. This PR fixes it silently; EnsureRequirementsSuite's keyed-cogroup tests (1502, 2042) use no compatible-but-different transform pair.

  3. partitioning.scala:2049 — the unreachability argument names createPartitioning as where marked layouts come from, but PartitioningCollection.fromPartitionings also produces them: it ORs the marker across members (line 1483) and stamps the OR'd flags onto the canonical layout, and its require guards describesSameKeys but not the marker. The operative guard is ShuffledJoin.clearUnknownPartitionKeys. The conclusion holds; a reader checking a future change would check the wrong site.

  4. partitioning.scala:2060 — when allowReduce = false the unknown-keys forall is textually identical to the outer one at 2039-2040, so it is pure duplicate work; and since isExpressionCompatible(l, r, false) ⇒ isExpressionCompatible(l, r, true) on every arm, the outer pass is subsumed in the allowReduce = true case too. Hoisting val unknownKeys = partitioning.mayContainUnknownPartitionKeys || other.partitioning.mayContainUnknownPartitionKeys and calling the outer loop with allowReduce && !unknownKeys collapses both into one pass, and states the intent (the inner loop exists only to downgrade a lenient caller) which isn't visible today.

  5. partitioning.scala:2098val canReduce = allowReduce && canReduceKeys is eager, so the (Leaf, Leaf) arm now evaluates SQLConf.get plus three conf reads where it previously read none. That's the plain identity(id) vs identity(id) shape, hit once per partition expression per candidate pair over the leftCandidates × rightCandidates cross product. lazy val, or a local def, restores the old cost.

  6. InMemoryBaseTable.scala:347value ^ 1L re-implements FlipLowBitFunction.produceResult with a comment as the only sync, the fourth such pair in this file. They already disagree on NULL: the case (value: Long, LongType) type test doesn't match null so the write path throws, while ApplyFunctionExpression.eval has no null guard and getLong(0) on a null slot returns 0, mapping NULL to key 1L. Avoidable — moving FlipLowBitFunction into the existing sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/functions/ package lets this arm call FlipLowBitFunction.produceResult(InternalRow(value)) (InternalRow is already imported), and lets ShuffleSpecSuite use the real function too.

  7. transformFunctions.scala:281UnboundFlipLowBitFunction is UnboundSignedZerosFunction with two strings changed. SimpleFunction (@since 4.2.0) exists for exactly this, with precedent at DataSourceV2FunctionSuite.scala:753; object FlipLowBitFunction extends SimpleFunction with ScalarFunction[Long] drops the wrapper and still works with withFunction. The copied type check is dead anyway — loadV2FunctionOpt swallows the UnsupportedOperationException into None.

  8. ShuffleSpecSuite.scala:715 — the inline flipFn is behaviourally identical to the FakeBucket this PR just hoisted, for everything this suite observes (resultType and "is a TransformExpression"); the function is never evaluated here, so the a ^ 1 semantics the comment explains aren't exercised. The same test builds a FakeBucket 29 lines later. Item 12 would let you use the real function instead.

  9. KeyGroupedPartitioningSuite.scala:1332 — the title has no JIRA ID (172 of 188 tests in this file are SPARK-xxxxx: ...; the 16 that aren't are original feature tests, not bug fixes), so git grep SPARK-59688 won't find the regression test. It also lands between the 4th and 5th of five consecutive SPARK-59045: tests; moving it after the last one (before the SPARK-59121 pair) keeps both the topical adjacency and the ticket grouping.

…up path

dongjoon-hyun's 15 items, then a simplify and a blind pass over the result.

- `allowReduce` loses its default, so both call sites say which question they ask.
- The unknown-keys branch asks the shared predicate rather than its own matcher, and
  asks for un-reduced keys instead of asserting them, so a producer this misses costs
  a shuffle rather than the query.
- The end-to-end test validated an `AdaptiveSparkPlanExec`, which is a leaf, so it
  checked nothing. It validates the join subtree now.
- New `EnsureRequirementsSuite` test for the cogroup path, which has no reduce branch.
- `flip_low_bit` becomes a `SimpleFunction` in catalyst's test sources, so
  `InMemoryBaseTable` computes its keys by calling it and `ShuffleSpecSuite` uses the
  real function.
@peter-toth

Copy link
Copy Markdown
Contributor Author

Thanks for going through it at that depth, @dongjoon-hyun. All 15 taken, and the transitivity point is in the description now. Pushed in 90cf67c.

1. The assertion was validating an AdaptiveSparkPlanExec, so it checked nothing. It validates the join subtree now, the way KeyGroupedPartitioningSuite:5528 does, with the reason in a comment.

2. Both failures are in the user-facing section, named, with what makes them reachable.

3. Kept the delegation, and the invariant is now part of the condition rather than an assert: partitioning.expressionsDescribeKeys && other.partitioning.expressionsDescribeKeys && in front of the declared-keys comparison, which is the only thing it is needed for. That is your "keep the explicit refusal" option, and I picked it over the assert deliberately: the combination is reachable only if both sides carry reduced keys with the same pairing and one is marked, and there the old answer was a shuffle, so a producer the argument misses should cost a shuffle rather than the query. expressionsDescribeKeys is the existing accessor for exactly this.

4. Rewritten: "must describe one key space: two bare references, the same transform function, or the two sides of one reduce".

5. Corrected in both places. The @param no longer claims a connector reducer is needed, and the fixture's scaladoc now names the property that actually makes it minimal, that it permutes its key space, with the reason a many-to-one transform cannot.

6. Default dropped. EnsureRequirements writes allowReduce = true, isCompatibleWith writes false, and the test call sites say so too.

7. Said in one line, with the GetStructField-is-not-a-leaf reason.

8. New EnsureRequirementsSuite test. It pins which side is laid out and onto what, that the rule is idempotent over the result, and a control that two sides holding one key space are still read as they stand. It fails on base. Plain DummySparkPlan children, since DummySparkPlanWithBatchScanChild NPEs once a shuffle reads the dummy scan's table.

9. The comment names ShuffledJoin.clearUnknownPartitionKeys as the operative guard now.

10. Done, one pass: val unknownKeys is hoisted and the loop asks allowReduce && !unknownKeys.

11. canReduceKeys sits behind the short-circuit at both use sites, so the two-references arm reads no configuration. Dropping the local def altogether made that clearer than a comment.

12. The fixture moved to sql/catalyst/src/test/.../catalog/functions/FlipLowBitFunction.scala, and InMemoryBaseTable calls produceResult instead of repeating ^ 1. Its arm keeps the neighbours' typed pattern, so a NULL key fails loudly at insert rather than reaching the function.

13. SimpleFunction, so the Unbound wrapper is gone.

14. ShuffleSpecSuite uses the real function, so the inline twin is gone.

15. Retitled SPARK-59688: and moved after the last SPARK-59045: test.

Two things I did not take, both yours to call.

A simplify pass argued the boolean should be two named methods instead, mirroring this file's keysSatisfy / keysCanSatisfy pair, on the grounds that allowReduce is the only Boolean parameter in partitioning.scala. I kept the parameter: it is the shape you reviewed, and it keeps the change to two call sites per branch, which matters for the backport. Say if you would rather have the names.

The same pass wanted the invariant enforced at the producer, a require(!mayContainUnknownPartitionKeys || expressionsDescribeKeys) in KeyedPartitioning's body, rather than answered at the reader. I left it here, because PartitioningCollection.fromPartitionings ORs the marker onto a canonical layout without looking at the expressions, so a require there would turn a latent nothing into a construction-time throw on a path this PR does not touch. Worth its own change if you think the invariant should be enforced rather than stated.

@dongjoon-hyun dongjoon-hyun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, LGTM (Pending CIs). Thank you, @peter-toth !

dongjoon-hyun pushed a commit that referenced this pull request Sep 21, 2026
…skips the reduce on coinciding partition keys

### What changes were proposed in this pull request?

`KeyedShuffleSpec.isCompatibleWith` answers whether two key-grouped children are lined up as they stand, so that a join can pair their partitions up index by index with no shuffle and no `GroupPartitionsExec`. It ends in `KeyLayout.describesSameKeys`, which compares the two sides' partition key rows and their types. That comparison only says what it looks like it says when both sides' keys are values of the same thing.

The predicate it consults first, `areKeysCompatible`, is deliberately looser than that. Under `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled` its `isExpressionCompatible` admits an `AttributeReference` against a `TransformExpression`, and two different but reducible transforms, because `EnsureRequirements` reduces such a pair onto one key space before it pairs the partitions up. So `isCompatibleWith` was reading equal key rows as "lined up" for two sides whose keys are not in one space yet.

This PR gives `areKeysCompatible` an `allowReduce` parameter, threaded into `isExpressionCompatible`, and has `isCompatibleWith` ask with `allowReduce = false`:

- `EnsureRequirements` keeps the loose question where it selects the member pair to plan on (`agreeingPairs`), because it is the caller that runs the reduce. The parameter has no default, so every caller says which question it is asking.
- `isCompatibleWith` gets the strict one, so a pair that still needs reconciling is not reported as compatible as it stands. `compatibleAsIs` is then false, and the join takes the push branch that computes the reducers and regroups both sides onto the merged keys.
- A pair an earlier join reduced together is unaffected: those keys are in one space already, and `isExpressionCompatible`'s reduced-keys arm answers for them through `hasSameReducedKeys` whatever `allowReduce` says.
- The unknown-partition-keys branch inside `areKeysCompatible` already required this same single-key-space property before comparing key subsets, through a hand-written matcher. It now calls the shared predicate with the reduce disallowed, in the one pass the method already made. That comparison also asks that neither side's keys were reduced, which a marked layout never carries: asked rather than asserted, so that a producer the argument misses costs a shuffle rather than the query.

The strict answer is also **transitive**, which the loose one is not: `bucket(12)` reduces onto `bucket(4)` and `bucket(4)` onto `bucket(8)`, while `bucket(12)` and `bucket(8)` have no reducer between them. `ValidateRequirements` compares every child against `specs.head` alone, so a three-child operator could be accepted on a chain that does not hold pairwise. `isSameFunction` is an equivalence, so that hole closes with this.

### Why are the changes needed?

Wrong results. Take a table partitioned by `identity(id)` and one partitioned by a connector transform that permutes its key space, say `flip_low_bit(id) = id ^ 1`, with ids 0 and 1 in both:

```sql
SELECT t1.id, t1.data, t2.data FROM t1 JOIN t2 ON t1.id = t2.id
```

Both scans report the partition key list `[0, 1]` of `LongType`, so `describesSameKeys` holds, but the rows behind a key differ: the identity side's key 0 holds `id = 0` while the transform side's holds `id = 1`. The join pairs partition 0 with partition 0 and finds no match in either pair, so it returns **0 rows instead of 2**, with no shuffle and no `GroupPartitionsExec` in the plan. The new end-to-end test measures exactly this on `master`.

The `Reducer` contract is what the fast path implicitly assumed more of. It says `r(f1(x)) = f2(x)`, and nothing about `r` leaving alone the keys it is applied to, so on a one-side reduce key `k` can belong with key `r(k)` on the other side. What it takes to turn that into wrong rows is a transform that **permutes** its key space. A many-to-one transform cannot: the two key lists coincide only where it is the identity on them, which is why neither `bucket` nor `truncate` shows it, and why this has stayed latent. Note it takes no connector `Reducer` at all - the identity-versus-transform arm synthesizes one from the other side's ordinary transform - so a plain `ScalarFunction` is enough.

The identity-versus-transform arm came in with SPARK-56182, which is on `branch-4.2`, `branch-4.3`, `branch-4.x` and `master`, so this is not a `master`-only bug. The blast radius is that arm, or two reducible transforms, plus coinciding key lists, plus `allowCompatibleTransforms.enabled`, which defaults to `false`: without it `canReduceKeys` is false, the two questions below agree at every position and this change is a no-op.

### Does this PR introduce _any_ user-facing change?

Yes, it is a bug fix. The query above returns its 2 rows instead of 0, and it still runs without a shuffle: the pair goes through the reduce, so the plan gains a `GroupPartitionsExec` on each side and the identity side's keys are reduced onto `flip_low_bit`. No configuration changes, and no public API changes (`areKeysCompatible` is catalyst-internal, and catalyst is excluded from MiMa).

Four consequences are worth stating, all on pairs whose key lists coincide and which now take the reduce instead of being read as they stand:

- Where the coincidence was benign, the reducing side reports the target transform instead of its own, so a downstream operator sees the coarser claim, for instance `bucket(4, id)` where it used to see `bucket(8, id)`.
- A co-partitioned operator that is not a sort-merge or shuffled-hash join, a cogroup for instance, has no reduce branch at all: `pickCoPartitionTarget` pairs children on `isCompatibleWith` alone. Such a pair is now shuffled onto one side instead of being read as it stands. That is the right answer where the transform permutes the key space, and a lost optimization where the coincidence is benign; the `Reducer` API gives Spark nothing to tell the two apart with. For a cogroup the wrong answer was worse than for a join, since no equi-predicate drops the mispaired rows again, which is what the new `EnsureRequirementsSuite` test pins.
- Two planning-time failures become reachable for a query that previously planned, both in place of wrong rows. `storagePartitionJoinIncompatibleReducedTypesError`, when a connector's `Reducer.resultType()` disagrees with the target transform's type, since the reduce now runs and compares them. And `cannotEvaluateExpressionError` for an identity-versus-transform pair whose transform is bound to something that is not a `ScalarFunction`, since `IdentityReducer` evaluates that transform and `TransformExpression.resolvedFunction` is empty there.

### How was this patch tested?

Three new tests, each pinning a different layer, and all three fail on a revert of `partitioning.scala` alone:

- `KeyGroupedPartitioningSuite`, end to end and the wrong-rows regression: `identity(id)` against `flip_low_bit(id)` with ids 0 and 1 in both tables. On the base it returns 0 of 2 rows; with the fix it returns both, with 0 shuffles, one `GroupPartitionsExec` per side, exactly one of them carrying a reducer, and `ValidateRequirements.validate` accepting the join subtree (the subtree, not the whole plan: the validator walks children and a query stage is a leaf, so validating an AQE plan checks nothing).
- `ShuffleSpecSuite`, at the spec level, for both shapes the parameter gates: an identity side against that transform, and two bucket counts a reducer reconciles, are each admitted by `areKeysCompatible` and refused by `isCompatibleWith`, in both directions. Two positive controls keep it honest, that the two layouts do describe the same keys and that one function over one key list is still compatible with itself, and a fourth case pins that a pair reduced together stays compatible, which is what keeps a chained storage-partitioned join from shuffling.
- `EnsureRequirementsSuite`, for the path that has no reduce branch: a keyed cogroup over the same pair now lays the transform side out on the identity side's keys rather than reading the two as they stand, the rule stays idempotent over the result, and a control pins that two sides holding one key space are still read as they stand.

`flip_low_bit` is a new fixture, and the minimal one for this: the transform has to keep its argument's type and to *permute* its key space, and no existing fixture does both (`bucket`, `days`, `years` and `signed_zeros` change the type, so such a pair is refused on the key types alone, while `string_self` is the identity and `truncate` is many-to-one). It is a `SimpleFunction`, so one object is both the unbound and the bound function, and it lives in catalyst's test sources so that `InMemoryBaseTable` computes its partition keys by calling `produceResult` rather than repeating the arithmetic, which is how the other four transforms in that file stay in step.

`build/sbt 'catalyst/testOnly *ShuffleSpecSuite'` 31 pass. `build/sbt 'sql/testOnly *KeyGroupedPartitioningSuite *EnsureRequirementsSuite *ValidateRequirementsSuite *ProjectedOrderingAndPartitioningSuite *KeyGroupedPartitioningRuntimeFilterSuite *KeyGroupedPartitioningCatalystRuntimeFilterSuite *GroupPartitionsExecSuite'` 348 pass. `dev/lint-scala` clean.

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

Generated-by: Claude Code (Opus 5)

Closes #58943 from peter-toth/SPARK-59688-coinciding-partition-keys.

Authored-by: Peter Toth <peter.toth@gmail.com>
Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
(cherry picked from commit 9daeea7)
Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
@dongjoon-hyun

Copy link
Copy Markdown
Member

Merge Summary:

Posted by merge_spark_pr.py

@dongjoon-hyun

Copy link
Copy Markdown
Member

Could you make a backporting PR to branch-4.3 and branch-4.2, @peter-toth ? There were some conflicts from branch-4.3.

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