peter-toth opened a new pull request, #58963:
URL: https://github.com/apache/spark/pull/58963

   ### 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.
   
   #### Backport to branch-4.3
   
   The fix itself ports verbatim: `areKeysCompatible`, 
`isExpressionCompatible`, `canReduceKeys`,
   `TransformExpression.hasReducedKeys` and 
`KeyedPartitioning.expressionsDescribeKeys` are all the same
   shape on this branch. Three tailorings, all because `KeyLayout` is 
master-only:
   
   - `isCompatibleWith` keeps this branch's own trailing clause,
     `partitioning.partitionKeys == otherPartitioning.partitionKeys`, in place 
of
     `layout.describesSameKeys`. That carries the type clause too, since
     `InternalRowComparableWrapper.equals` compares the data types before the 
rows.
   - The `ShuffleSpecSuite` control that pins the fixture's premise compares 
`partitionKeys` for the same
     reason.
   - `EnsureRequirements` has no `agreeingPairs` loop here. The lenient caller 
is the single
     `leftSpec.areKeysCompatible(rightSpec)` inside the push branch, and that 
is the one call that names
     `allowReduce = true`.
   
   Nothing was omitted: all three tests came across, and **all three fail on 
this branch without the fix**
   (the end-to-end one returns 0 rows of 2, the spec-level one inverts, the 
cogroup one stops shuffling),
   which is the measurement that says the bug is live here.
   
   `build/sbt 'catalyst/testOnly *ShuffleSpecSuite'` 24 pass.
   `build/sbt 'sql/testOnly *KeyGroupedPartitioningSuite 
*EnsureRequirementsSuite *ValidateRequirementsSuite 
*ProjectedOrderingAndPartitioningSuite *GroupPartitionsExecSuite'` 292 pass. 
`dev/lint-scala` clean.
   
   ### Was this patch authored or co-authored using generative AI tooling?
   
   Generated-by: Claude Code (Opus 5)
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to