Uh oh!
There was an error while loading. Please reload this page.
[SPARK-59045][SQL] Fix SPJ ClassCastException when reducer changes partition key data type - #58335
[SPARK-59045][SQL] Fix SPJ ClassCastException when reducer changes partition key data type#58335ulysses-you wants to merge 7 commits into
Conversation
ulysses-you
commented
Aug 27, 2026
cc @peter-toth@szehon-ho thank you |
peter-toth
left a comment
There was a problem hiding this comment.
Thanks for the PR, @ulysses-you!
The invariant is right, and the identity-vs-transform case is handled exactly: the reducer is the other side's transform applied to this side's attribute, so reporting it is both type-correct and true, and resultType() = reducerExpr.dataType (partitioning.scala:1395) makes the type match by construction. Your three tests do fail on base, I checked that separately.
Two other reducer shapes are not covered, and one of them still throws the same ClassCastException. I put both into a single follow-up item (3) rather than ask you to grow this PR, since the uncovered one needs a design decision, not a patch. We are happy to take these on if you would rather not carry them.
What I would fix here is 2, a shuffle this patch adds in a chained SPJ, and the title, which currently promises the general fix (1). Everything below was measured in a worktree at 96f3cfcb4c9.
Blocking
- 1.Title and description promise more than the patch delivers: The title reads as the general fix, so
SPARK-59045will be resolved on merge while the two-transformClassCastExceptionstill throws (3). Narrowing both to the identity case, and naming what stays open, keeps the ticket honest. The user-facing-change answer also reads as if this patch could make a query fail, where it turns a failure into a success. - 2.The reduced expression is stamped onto every
KeyedPartitioningbelow:reducedcomes from the single spec thatcollectFirstpicked, but thetransformapplies it to all of them, so the other side's key attribute is overwritten and aGROUP BYon it shuffles where base did not. Measured, same rows both ways. [inline:sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:83]
Non-blocking
- 3.Two reducer shapes stay uncovered, one of them still crashes: Where only this side reduces but both sides are transforms, the exact expression exists and is not used; where both sides reduce, no expression can describe the keys and the crash survives. Follow-up material, with a measured crash and a measured wrong result behind it. [inline:
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:1383] - 4.The second trigger is untested: All three new tests enable
allowKeysSubsetOfPartitionKeys, so all three fail on base atcreateShuffleSpec->toGrouped. Dropping that config from this test reaches the other site,reduceKeys, and the test still fails on base. [inline:sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:590]
Minor
- 5.Comment states an invariant the transform branch breaks: The parenthetical claim that the reported data type matches the reduced keys holds for the identity branch only. [inline:
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:73] - 6.A named type for the reducer pair: The raw tuple now appears in six signatures and forces
_1at the use sites. [inline:sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:1380]
| case Some(exprs) => | ||
| assert(projectedExpressions.length == exprs.length) | ||
| projectedExpressions.zip(exprs).map { | ||
| case (expr, Some((_, reduced))) => reduced |
There was a problem hiding this comment.
Finding 2.reduced was derived from one KeyedShuffleSpec, but this transform applies it to every KeyedPartitioning in the child's partitioning, so the others lose their own key attribute.
createKeyedShuffleSpec takes the first KeyedPartitioning that satisfies the distribution (collectFirst, EnsureRequirements.scala:819), and the reducers are computed from that one alone. When this node sits on a join - a chained SPJ - the child reports one KeyedPartitioning per side, and all of them get that single reduced expression, which references only the chosen side's attribute. The assert above has the same root: the length is only guaranteed to match for the spec's own KeyedPartitioning.
Three tables bucketed 16, 8 and 4 on id, ids 0..15, joined on a.id and grouped by b.id:
- base:
KPs = [bucket(16, id#18L), bucket(8, id#20L)], 0 shuffles. - this commit:
KPs = [bucket(16, id#18L), bucket(16, id#18L)], 1 shuffle, becauseGROUP BY b.idno longer sees a partitioning onb.id.
Both return the same rows, so this is an avoidable shuffle rather than a wrong answer. Retargeting the reduced expression at each KeyedPartitioning's own key attribute fixes it, and the single-attribute invariant makes that well defined:
projectedExpressions.zip(exprs).map {
case (expr, Some((_, reduced))) =>// `reduced` was derived from the spec's `KeyedPartitioning`; re-target it at this one's key so// that every `KeyedPartitioning` in a collection keeps its own attribute.valattr= expr.references.head
reduced.transform { case_: AttributeReference=> attr }
case (expr, None) => expr
}There was a problem hiding this comment.
Fixed in 2b0ef18. GroupPartitionsExec.outputPartitioning now re-targets the reduced expression at each KeyedPartitioning's own key attribute before applying it, so a chained SPJ keeps every side's partitioning on its own attribute. Added the 16/8/4 + GROUP BY middle-side regression test; it asserts 0 shuffles and the full 16-row result.
| val results = partitioning.expressions.zip(other.partitioning.expressions).map { | ||
| case (e1: TransformExpression, e2: TransformExpression) => e1.reducers(e2) | ||
| case (e1: TransformExpression, e2: TransformExpression) => | ||
| e1.reducers(e2).map(reducer => (reducer, e1)) |
There was a problem hiding this comment.
Finding 3. This branch covers two different shapes, and keeping e1 is wrong in both. Not asking you to fix them here - the second one needs a design decision - but they should not be left implicit either.
There are three reducer shapes, and the reported expression has to be judged per shape:
- This side is identity, the other is a transform. The reduced expression exists and the branch below builds it. Handled, and correctly.
- Both sides are transforms, only this side reduces. The reduced expression also exists:
r(f1(x)) = f2(x)is theReducibleFunctioncontract, so it is the other side's transform retargeted at this side's child. This branch reportse1instead.SPARK-56046: Reducers with same result typesis this shape -daysreduces ontoyears,YearsFunction.reducerreturns null - and the reported expression staysdays(arrive_time)whereyears(arrive_time)is what the keys hold. - Both sides reduce. The keys are
r1(f1(x)) = r2(f2(x)), a space that neither transform describes, so no substitution can be correct here.
Shape 3 still throws the exception this PR fixes. reduceKeys types the keys with reducer.resultType() while e1.dataType is e1.function.resultType(), and the suite already ships a pair where they differ: DaysFunctionWithToYearsReducerWithLongResult (DateType) and YearsFunctionWithToYearsReducerWithLongResult (IntegerType) both reduce to LongType. Take SPARK-56164: Reducers with different result types to original keys and add V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS -> true:
java.lang.ClassCastException: class java.lang.Long cannot be cast to class java.lang.Integer
at org.apache.spark.sql.catalyst.expressions.GenericInternalRow.getInt(rows.scala:170)
at org.apache.spark.sql.catalyst.expressions.GeneratedClass$SpecificOrdering.compare(Unknown Source)
at org.apache.spark.sql.catalyst.plans.physical.KeyedPartitioning.toGrouped(partitioning.scala:553)
at org.apache.spark.sql.catalyst.plans.physical.KeyedPartitioning.createShuffleSpec(partitioning.scala:629)
at org.apache.spark.sql.execution.exchange.ValidateRequirements$.validateInternal(ValidateRequirements.scala:60)
e2 would not help: years is IntegerType and the keys are LongType.
The other cost of a stale expression is that a second join derives its reducer from a transform the data has already left behind. Three tables bucketed 12, 8 and 6 on id, ids 0..11: join 1 reduces both sides with BucketReducer(4), so the keys become id % 4 while the left side reports bucket(12, id); join 2 compares that stale bucket(12, id) with bucket(6, id), gets BucketReducer(6), and (id % 4) % 6 leaves the keys at id % 4, while the right side has gcd == thisNumBuckets, gets no reducer, and keeps id % 6. The two sides are matched across different key spaces: the query returns 4 of 12 rows, 0 shuffles, no error, and all 12 with allowCompatibleTransforms=false.
valcols=Array(Column.create("id", LongType), Column.create("data", StringType))
createTable("b12", cols, Array(bucket(12, "id")))
createTable("b8", cols, Array(bucket(8, "id")))
createTable("b6", cols, Array(bucket(6, "id")))
valvalues= (0 until 12).map(i =>s"($i, 'v$i')").mkString(", ")
Seq("b12", "b8", "b6").foreach(t => sql(s"INSERT INTO testcat.ns.$t VALUES $values"))
valdf= sql(
"""SELECT /*+ MERGE(a, b, c) */ a.id FROM testcat.ns.b12 a |JOIN testcat.ns.b8 b ON a.id = b.id |JOIN testcat.ns.b6 c ON a.id = c.id""".stripMargin)
withSQLConf(SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key ->"true") {
checkAnswer(df, (0 until 12).map(i =>Row(i.toLong))) // returns only 0, 1, 2, 3
}That one is pre-existing, base reports the same expression, so it is not something this PR broke. It is not master-only either: the same query returns 8 of 12 rows on branch-4.1, where KeyGroupedPartitionedScan.getOutputKeyGroupedPartitioning reports the original expressions next to the reduced common partition values in the same way.
The shape I would suggest, in case it is useful: extend the substitution to shape 2, which is a small step from what you already do for shape 1; and for shape 3 stop trying to express the keys as a transform - carry the reduced key data types on KeyedPartitioning instead. reduceKeys already computes them one frame up and GroupPartitionsExec.groupedPartitionsTuple already holds them as reducedDataTypes, they are just dropped, and the reported partitioning re-derives its types from expressions.map(_.dataType) (partitioning.scala:545). All eight readers go through expressionDataTypes, so they would pick the carried types up for free. Such a partitioning then has to refuse a further reduction, since its expressions no longer describe its keys - which is exactly what the 12/8/6 case above needs.
Happy to pick this up as a follow-up if you would rather not carry it - tell me which way you prefer.
There was a problem hiding this comment.
Thanks for the detailed breakdown. Shape 2 is now handled in 2b0ef18: for a single-side reduce we report the target transform re-targeted at this side's attribute, guarded by e2.reducers(e1).isEmpty, so both-sides-reduce positions keep the original expression bit-for-bit (shape 3 unchanged, still a tracked gap).
There was a problem hiding this comment.
And for shape 3 — please go ahead and take it as a follow-up as you offered; we are happy to leave the both-sides-reduce shape (and the 12/8/6 stale-expression case) out of this PR. Happy to review your follow-up, and to file a JIRA for it if that is useful.
| withSQLConf( | ||
| SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true", | ||
| SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> "true") { |
There was a problem hiding this comment.
Finding 4. All three new tests enable this config, so all three fail on base at the same place, createShuffleSpec -> toGrouped. There is a second, independent trigger that none of them reach.
This test does not need the config, the join uses the whole partition key. With it removed the test still fails on base, but through reduceKeys at the second join, where the reducer is bound to the stale un-reduced type:
java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long
at org.apache.spark.sql.catalyst.plans.physical.KeyedShuffleSpec$$anon$1.reduce(partitioning.scala:1394)
at org.apache.spark.sql.catalyst.plans.physical.KeyedPartitioning$.reduceKeys(partitioning.scala:725)
at org.apache.spark.sql.catalyst.plans.physical.KeyedPartitioning.reduceKeys(partitioning.scala:572)
at org.apache.spark.sql.execution.exchange.EnsureRequirements.checkKeyGroupCompatible(EnsureRequirements.scala:561)
and it passes on this commit. Test 1 and test 3 do need the config, test 3 genuinely joins on a subset.
There was a problem hiding this comment.
Done in 2b0ef18. Dropped V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS from the multi-table test (both joins use the whole partition key); it now reaches the reduceKeys trigger and fails on base at KeyedShuffleSpec.reduce. Added a comment noting the two triggers.
| // There can be multiple `KeyedPartitioning`s in an output partitioning of a join, but they | ||
| // can only differ in `expressions`; their `partitionKeys` reference is shared (enforced by | ||
| // `PartitioningCollection`), so `groupedPartitions` is computed only once. | ||
| // When reducers are applied, the reduced expressions (whose data type matches the reduced |
There was a problem hiding this comment.
Finding 5. The parenthetical holds for the identity branch only. For two transforms reducers carries e1, so the reported data type is the un-reduced one and can differ from the keys (finding 3). Worth naming the branch the claim applies to, since the next reader will lean on it.
There was a problem hiding this comment.
Fixed in 2b0ef18. The comment now names the branches: the data-type match holds for the identity-vs-transform and single-side-transform reducers, and the both-sides-reduce shape has no single transform expression (pointed at KeyedShuffleSpec.reducers).
| */ | ||
| def reducers(other: KeyedShuffleSpec): Option[Seq[Option[Reducer[_, _]]]] = { | ||
| def reducers( | ||
| other: KeyedShuffleSpec): Option[Seq[Option[(Reducer[_, _], TransformExpression)]]] = { |
There was a problem hiding this comment.
Finding 6.Option[Seq[Option[(Reducer[_, _], TransformExpression)]]] now appears in six signatures and forces _1 at the use sites, including the explain string. A small named type would read better and would document which element is which:
caseclassKeyReducer(reducer: Reducer[_, _], reducedExpression: TransformExpression)There was a problem hiding this comment.
Done in 2b0ef18. Added case class KeyReducer(reducer, reducedExpression) next to KeyedShuffleSpec and switched all signatures/use sites (including the explain string) to it.
ulysses-you
commented
Aug 27, 2026
Thanks for the review — all feedback addressed in 2b0ef18. Summary:
Tests: |
peter-toth
left a comment
There was a problem hiding this comment.
LGTM, approving. All six from my round are addressed, and thanks for taking shape 2 as well - the e2.reducers(e1).isEmpty guard with the re-targeted target transform is exactly the shape I had in mind, and it means only the both-sides-reduce case is left.
I confirmed each one against 2b0ef181bc4: the title now names the reducer rather than the transforms, the user-facing answer reads properly, GroupPartitionsExec.outputPartitioning re-targets the reduced expression per KeyedPartitioning so a chained SPJ keeps every side's own attribute, the multi-table test reaches the reduceKeys trigger without the subset config, the data-type comment names the branches it holds for, and KeyReducer gives the pair a name.
One nit, comment only. In the new reduced expression is retargeted per KeyedPartitioning test, the parenthetical reads "(0 shuffles on base, 1 after the fix)", which is inverted - the test asserts no shuffle, and it is the un-retargeted version that produces one. Worth swapping so the next reader is not misled. Also worth knowing: that test passes on master, since the stamping only exists inside this PR, so it guards this change rather than a pre-existing bug.
I will take the both-sides-reduce shape as the follow-up, together with refusing a further reduction while the reported expressions do not describe the keys, and I will file the ticket myself when I open it - no need for you to.
| // Both sides reduce: the reduced keys are r1(f1(x)) = r2(f2(x)), which no single | ||
| // transform describes. Keep reporting the original expression. Known gap, tracked in | ||
| // the follow-up for SPARK-59045. | ||
| KeyReducer(reducer, e1) |
There was a problem hiding this comment.
[correctness] The both-sides-reduce branch keeps the un-reduced e1, so the ClassCastException this PR fixes remains reachable in that shape with no runtime fail-safe. The shape is reachable even with in-tree test functions (BucketFunction's gcd reducer reduces both ways; DaysFunctionWithToYearsReducerWithLongResult / YearsFunctionWithToYearsReducerWithLongResult both reduce to LongType while their transforms report DateType/IntegerType). EnsureRequirements' storagePartitionJoinIncompatibleReducedTypesError check passes here because the LEFT and RIGHT reduced types equal each other, so GroupPartitionsExec reports original-typed expressions over reduced-typed partition keys, and a downstream GROUP BY / second join / shuffle deriving an ordering from expressionDataTypes throws the same CCE.
Since the follow-up is tracked separately, could this branch fail safe until then, e.g. return None (fall back to shuffle) or raise the dedicated error when reducer.resultType() != e1.dataType?
There was a problem hiding this comment.
Thanks for the detailed analysis. We explored both fail-safe options and decided to keep reporting the un-reduced expression until the follow-up, for two reasons:
return Nonedoes not actually fall back to a shuffle. With no reducer on either side, the reduced-types cross-check inEnsureRequirementscompares the two sides' original types (DateTypevsIntegerTypein your example) and throwsstoragePartitionJoinIncompatibleReducedTypesErrorat planning time. We verified this by running the SPARK-56164 query with a per-pairNoneimplementation - it failed with exactly that error. SoNoneand raising the dedicated error are indistinguishable here: the query cannot run either way, and the error case is already covered by the existing cross-check without new code.Noneadditionally opens a silent-wrong-results hole the dedicated error does not have: if both transforms' original types are equal while the reducers' result type differs (constructible by a third-partyReducibleFunctionpair), both sides returnNone, the cross-check passes on the equal original types, and the two sides' un-reduced key spaces get merged without any reduction.
Since the reduction itself produces correct key values in this shape (only the reported expression is mis-typed), we kept it and left the gap to the follow-up, which carries the reduced data types on KeyedPartitioning and fixes this shape properly.
| @transient joinKeyPositions: Option[Seq[Int]] = None, | ||
| @transient expectedPartitionKeys: Option[Seq[(InternalRowComparableWrapper, Int)]] = None, | ||
| @transient reducers: Option[Seq[Option[Reducer[_, _]]]] = None, | ||
| @transient reducers: Option[Seq[Option[KeyReducer]]] = None, |
There was a problem hiding this comment.
[correctness]KeyReducer embeds an exprId-bearing TransformExpression in a non-Expression constructor arg. QueryPlan.doCanonicalize normalizes exprIds only via mapExpressions, which does not recurse into the plain KeyReducer case class (@transient affects serialization only, not equals). Pre-PR, a connector returning value-equal Reducer instances on the ReducibleFunction path (e.g. the test BucketReducer(divisor) case class) allowed two identical SPJ subtrees' GroupPartitionsExec nodes to compare equal after canonicalization; post-PR the side-specific AttributeReference exprIds inside reducedExpression break that equality, so exchange/subquery/stage reuse can silently stop deduplicating those subtrees.
There was a problem hiding this comment.
Fixed in 9438115. GroupPartitionsExec.doCanonicalize now normalizes the exprIds inside reducedExpression via QueryPlan.normalizeExpressions against the child's output (the same pattern BatchScanExec uses for keyGroupedPartitioning), so structurally identical SPJ subtrees with value-equal reducers compare equal after canonicalization again. Added a test that builds two GroupPartitionsExecs with differently-numbered exprIds and value-equal BucketReducer instances and asserts their canonical forms are equal.
There was a problem hiding this comment.
Finding 8 (my numbering) — an addition to this, measured on c01535cc6ed.
The normalization does not fire in the two shapes this PR fixes. Since the third commit stores the raw target transform, reducedExpression is the other join side's expression: t for the identity-vs-transform reducer, e2 for the single-side-transform one. QueryPlan.normalizeExpressions(expr, child.output) rewrites an attribute only when child.output contains its exprId, and this node's child is the reduced side, so the other side's attribute is left untouched. It fires only for the both-sides-reduce shape, where reducedExpression is this side's own e1 — the one shape whose expression is stale anyway.
Measured with two GroupPartitionsExecs over LocalTableScanExec(id#1) and LocalTableScanExec(id#2), a value-equal BucketReducer(2), and reducedExpression built over a third attribute standing in for the other side (oid#11 / oid#12):
PROBE other-side attribute in reducedExpression, canonical equal = false
The same pair with reducedExpression over the node's own attribute — the shape the new test builds — gives true.
There is a second, independent cause. The synthesized identity reducer is new Reducer[Any, Any] {...}, so KeyReducer.equals compares it by reference. Taking the reducer from reducersBothWays for an identity(id) / bucket(2, id) pair, with everything else structurally identical:
PROBE identity-derived reducer, canonical equal = false
That half is pre-existing — the anonymous reducer was already a constructor field of GroupPartitionsExec before this PR — so "with value-equal reducers" in the commit message is accurate. It does mean the identity-vs-transform shape, this PR's headline case, still does not deduplicate.
Fix shape: normalize reducedExpression against its own references instead of the child's output — the scaladoc already says the attribute it carries is not load-bearing, so a positional canonical form is enough — and make the synthesized reducer a named case class over the target transform so it compares by value, normalizing that expression too, or the exprId problem you just fixed reappears inside the reducer. Extending the new test with an identity-derived reducer, and with a reducedExpression over an attribute the child does not output, would pin both.
There was a problem hiding this comment.
Fixed in 265008f along both lines you suggested: doCanonicalize now normalizes reducedExpression against its own references (the stored expression references the other join side's key, which this node's child does not output), and the synthesized identity reducer is a named IdentityReducer case class over the re-targeted transform, whose transform is normalized too. Extended the test with both probes - a reduced expression over an attribute the child does not output, and an identity-derived reducer - plus a negative control (structurally different reducers stay unequal) and a mixed Some/None multi-key case.
| (Seq[DataType], Seq[InternalRowComparableWrapper]) = { | ||
| val reducedDataTypes = dataTypes.zip(reducers).map { | ||
| case (_, Some(reducer: Reducer[Any, Any])) => reducer.resultType() | ||
| case (_, Some(KeyReducer(reducer: Reducer[Any, Any], _))) => reducer.resultType() |
There was a problem hiding this comment.
[design] The reduced key type now has two unvalidated sources of truth: reduceKeys uses reducer.resultType() here, while GroupPartitionsExec.outputPartitioning reports reducedExpression.dataType (the target function's resultType()). Reducer's contract implies they agree, but nothing validates it, so a connector whose Reducer.resultType() disagrees with the target transform's type recreates the exact expressions-vs-keys mismatch this PR fixes, hidden inside the new mechanism. Deriving the reduced types from reducedExpression.dataType, or asserting agreement when constructing KeyReducer, would close the drift by construction.
There was a problem hiding this comment.
Agreed on the drift risk. We first tried asserting agreement when constructing KeyReducer, but the in-tree DaysToYearsReducerWithDateResult intentionally violates it (SPARK-56046: Reducers with different result types depends on such a reducer reaching the cross-check error), so a hard assert would break that test. Instead the agreement is now closed by construction / by the existing check in 9438115: the identity-vs-transform reducer reports the transform itself; the single-side branch reports the target transform, whose type the reduced-types cross-check in EnsureRequirements validates against the other side's target-transform-typed keys; and the both-sides-reduce branch reports the original expression (known gap, see the other thread). Comments in reducersBothWays spell this out.
| // this side's child. Report that expression (re-targeted at this side's attribute) | ||
| // instead of the un-reduced `e1`, whose type can differ from the reduced keys. | ||
| val thisSideChild = e1.references.head | ||
| val reducedExpr = e2.transform { case _: AttributeReference => thisSideChild } |
There was a problem hiding this comment.
[simplification] This creation-time retargeting is dead work: the only structural consumer of reducedExpression (GroupPartitionsExec.outputPartitioning) unconditionally retargets it again at each KeyedPartitioning's own attribute, and all other reads use only .reducer (reduceKeys, displayName in QueryExecutionErrors). Storing raw e2 and doing the single retarget at the use site is equivalent, removes one tree transform + allocation per expression pair, and stops implying that the attribute chosen here (from whichever spec collectFirst picked) is load-bearing.
There was a problem hiding this comment.
Fixed in 9438115. The reducer construction now stores the raw expression, and GroupPartitionsExec.outputPartitioning does the single re-targeting at the use site (via the new TransformExpression.withReference helper).
| case (e1: TransformExpression, e2: TransformExpression) => e1.reducers(e2) | ||
| case (e1: TransformExpression, e2: TransformExpression) => | ||
| e1.reducers(e2).map { reducer => | ||
| if (e2.reducers(e1).isEmpty) { |
There was a problem hiding this comment.
[efficiency]e2.reducers(e1) is invoked per expression pair solely to test .isEmpty, materializing a reverse Reducer via the catalog function's reducer() call. TransformExpression.reducers is an uncached def, and EnsureRequirements already calls spec.reducers in both directions, so each direction's catalog lookup now runs twice per join-key pair (4 instead of 2) on every SPJ planning pass, including AQE re-planning. A slow or allocating third-party ReducibleFunction.reducer() pays this on a discarded probe; computing both directions once per pair would avoid it.
There was a problem hiding this comment.
Fixed in 9438115. The two directions are now computed in one pass, KeyedShuffleSpec.reducersBothWays, so each expression pair's reducers lookup runs once per direction: the reverse lookup a direction used only for the single-side probe is exactly the other direction's reducer. EnsureRequirements now calls it once instead of calling reducers on both specs.
| // picked (`collectFirst`); re-target it at this `KeyedPartitioning`'s own key | ||
| // attribute so that every `KeyedPartitioning` in a collection keeps its own. | ||
| val attr = expr.references.head | ||
| reduced.transform { case _: AttributeReference => attr } |
There was a problem hiding this comment.
[reuse] This is the third copy of the X.transform { case _: AttributeReference => attr } retargeting idiom (also at KeyedShuffleSpec.reducers in both branches), spread across two modules with no shared helper. This copy must stay exactly consistent with how reducedExpression was built in KeyedShuffleSpec.reducers - a future change to the rebind rule (e.g. per-position rebinding for multi-reference or nested-field transforms) can silently miss one copy, making the reported outputPartitioning stop matching the actual reduced keys. A small helper on TransformExpression (returning TransformExpression, which also removes the asInstanceOf casts) would fit this PR.
There was a problem hiding this comment.
Fixed in 9438115. Added TransformExpression.withReference(attr) in catalyst and switched all the copies (the reducer construction in both directions and the use site) to it, which also removes the asInstanceOf casts at the call sites.
| */ | ||
| def reduceKeys( | ||
| reducers: Seq[Option[Reducer[_, _]]]): (Seq[DataType], Seq[InternalRowComparableWrapper]) = | ||
| reducers: Seq[Option[KeyReducer]]): |
There was a problem hiding this comment.
[style] Nit: this signature (and object KeyedPartitioning.reduceKeys below) was re-wrapped with the bare return type on its own continuation line, but the one-line forms fit within the 100-char limit after the type shortened (Seq[Option[KeyReducer]] is shorter than Seq[Option[Reducer[_, _]]]), and this wrapping style is not used elsewhere in the file. Keeping the original one-line form avoids the formatting churn.
9438115 to
c01535cComparepeter-toth
commented
Aug 28, 2026
Let me review this again today and check if the new |
peter-toth
left a comment
There was a problem hiding this comment.
Re-checked through c01535cc6ed — findings 1-6 resolved (title narrowed, per-KeyedPartitioning re-targeting, shape 2 handled, the reduceKeys trigger covered, the comment scoped to its branches, KeyReducer named), nothing regressed. All five SPARK-59045 tests pass here, and KeyGroupedPartitioningSuite, GroupPartitionsExecSuite, EnsureRequirementsSuite, ValidateRequirementsSuite, ShuffleSpecSuite and DistributionSuite are green — 193 tests.
As I said above I would, I checked what the keyDataTypes in #58262 does for this PR: on your merge base with only that PR's key-reading half applied, tests 1 and 4 pass — projectKeys and toGrouped stop reading reduced keys at the expressions' declared types — but test 2 still throws. There the reducer for the second join is derived from the stale expression and applied to values it was not built for, which no type fix can reach. So the two changes are complementary rather than overlapping: mine stops the reads from crashing, yours makes the reported expression true, and only yours fixes the chained reduce.
A heads-up on merge order while I am here: this PR conflicts textually with #58262 (reduceKeys, the reduce block in EnsureRequirements, the base types in GroupPartitionsExec) and with #58351 (GroupPartitionsExec.outputPartitioning and the KeyedPartitioning construction in it). Both are mechanical; I found no semantic overlap, and I will rebase whichever of mine lands second.
Blocking
- 7.Description drift (new): the description describes
KeyedShuffleSpec.reducers, whichc01535cc6edrenamed toreducersBothWays, and it does not mention that commit's canonicalization normalization or the newTransformExpression.withReference. It also says "The tests fail on the base commit and pass after", which holds for three of the five:reduced expression is retargeted per KeyedPartitioningpasses on base (measured — it guards this PR's own re-targeting instead, and ablatingwithReferenceat the use site fails it together with the multi-reduce test), andcanonicalization normalizes the reduced expressionsdoes not compile on base, since it referencesKeyReducer.
Non-blocking
- 8.
doCanonicalizenormalizes nothing in the two shapes this PR fixes (new):reducedExpressionnow holds the other side's expression, whose attribute is not in this node'schild.output, sonormalizeExpressionsleaves it as it is; and the synthesized identity reducer is an anonymous class, soKeyReducerequality fails on the reducer regardless. Both measured — details in my reply on @dongjoon-hyun's thread.
Minor
- 9.
withReferencekeeps the source expression's path above the leaf attribute (new): re-targeting aGetStructField-based transform at a bare attribute reports the source's field ordinal. No producer reaches it today, so this is a scaladoc point. [inline:sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/TransformExpression.scala:106]
| * has a single leaf attribute (`KeyedPartitioning.supportsExpressions`), so this replaces it | ||
| * and the result reports the same transform over `attr`. | ||
| */ | ||
| def withReference(attr: Attribute): TransformExpression = |
There was a problem hiding this comment.
Finding 9. The premise is right — KeyedPartitioning.supportsExpressions allows one leaf attribute per transform — but it also allows a GetStructField above that attribute, and transform replaces only the attribute, so the path comes from the source expression rather than from the target.
GroupPartitionsExec.outputPartitioning calls this with expr.references.head, which is the bare attribute. Re-targeting the other side's bucket(4, GetStructField(other, 0)) at a struct s whose own key expression is bucket(4, GetStructField(s, 1)) therefore reports field 0 — the source's ordinal read on this side's struct.
I could not reach it from a query, so this is a scaladoc point rather than a live defect: a nested partition reference resolves to an Alias over GetStructField, and supportsExpressions' isReference does not unwrap Alias, so a nested-field partition transform produces no KeyedPartitioning at all today (I measured that for identity("s.a"), which reports UnknownPartitioning). Worth saying in the doc that the caller has to pass the reference of the whole key expression, not just an attribute, so that whoever makes nested keys work does not inherit a silent wrong-field report.
There was a problem hiding this comment.
Done in 265008f - the scaladoc now says that any field path above the leaf attribute comes from this expression, and that re-targeting is only faithful when the source and the target key expressions have the same path shape.
c01535c to
265008fCompareulysses-you
commented
Aug 31, 2026
Updated the PR description (pushed as 265008f): |
There was a problem hiding this comment.
Re-checked through 265008fc4e8 — findings 7, 8 and 9 resolved, nothing regressed. The description now matches the code, doCanonicalize normalizes reducedExpression and the identity reducer's transform against their own references, IdentityReducer is a named case class, and withReference's scaladoc states the path-shape condition.
I re-measured rather than carrying the earlier verdicts forward, since the base moved:
- on the current base
af0d5c3abd1the identity-vs-bucket, multi-reduce and subset-key tests all fail withClassCastException: Integer cannot be cast to Long; - the re-targeting test passes on base and fails once
reduced.withReference(expr.references.head)is replaced byreduced; - the canonicalization test fails once
doCanonicalizeis removed, so it now covers both halves it was written for; - 187 tests green across
KeyGroupedPartitioningSuite,GroupPartitionsExecSuite,EnsureRequirementsSuite,ShuffleSpecSuiteandProjectedOrderingAndPartitioningSuite.
Non-blocking
- 10.Affects versions and backports (late catch):SPARK-59045 lists 5.0.0 as the only affected version, but the same code is on the maintenance branches.
KeyedShuffleSpec.reducershas the identity-vs-transform arm andGroupPartitionsExec.outputPartitioningbuildsKeyedPartitioning(projectedExpressions, groupedPartitions.map(_._1), ...)with no reduced expression onbranch-4.2,branch-4.3andbranch-4.x. SPARK-59121, the follow-up for the both-sides shape of the same mechanism, already lists 4.0.0 and 4.2.0. Worth updating the JIRA and saying whether backports are planned. Notev4.3.0-rc1is already cut, so 4.3.0 would need a respin.
Minor
- 11.Test comment reads backwards (new): "(0 shuffles on base, 1 after the fix)" says the fix adds a shuffle. The 1 belongs to the ablation, not to the fix. inline
- 12.Name the follow-up ticket (new): the both-sides-reduce gap is now SPARK-59121. inline
| // Both sides reduce: the reduced keys are r1(f1(x)) = r2(f2(x)), which no single | ||
| // transform describes. Keep reporting the original expression; its type can differ | ||
| // from the reduced keys, which a downstream shuffle or grouping can then fail on with | ||
| // a ClassCastException. Known gap, tracked in the follow-up for SPARK-59045. |
There was a problem hiding this comment.
Finding 12. The follow-up has a ticket now, so it can be named here.
SPARK-59121, "Fix wrong results when a storage-partitioned join reduces the partition keys of both sides". It covers this shape plus the silent wrong-results half of it.
| // a ClassCastException. Known gap, tracked in the follow-up for SPARK-59045. | |
| // a ClassCastException. Known gap, tracked in SPARK-59121. |
There was a problem hiding this comment.
Done in c161a21 - the gap comment (and the PR description) name SPARK-59121 now.
| // reduced expression is derived from the single spec that `createKeyedShuffleSpec` picks | ||
| // (`collectFirst`). Re-targeting it at each `KeyedPartitioning`'s own key attribute keeps the | ||
| // other sides' partitionings intact - otherwise a GROUP BY on the other side's key no longer | ||
| // sees a partitioning on it and the query shuffles (0 shuffles on base, 1 after the fix). |
There was a problem hiding this comment.
Finding 11. The parenthetical is the wrong way round, it reads as if the fix introduces the shuffle.
Measured on 265008fc4e8: base af0d5c3abd1 gives 0 shuffles and the test passes, this head gives 0 shuffles and passes, and only replacing reduced.withReference(expr.references.head) with reduced in GroupPartitionsExec.outputPartitioning makes it 1 shuffle. So the 1 belongs to the ablation.
| // sees a partitioning on it and the query shuffles (0 shuffles on base, 1 after the fix). | |
| // sees a partitioning on it and the query shuffles (0 shuffles on base and here, 1 if the | |
| // use-site re-targeting is dropped). |
There was a problem hiding this comment.
Fixed in c161a21 with your wording - the 1 belongs to the ablation, not to the fix.
peter-toth
commented
Aug 31, 2026
#58420 is merged to
Measured on With those two updated and the three items from my last round answered, this is good to go from my side. |
265008f to
c161a21Compareulysses-you
commented
Aug 31, 2026
Rebased onto 7f12c2d and pushed as c161a21; both SPARK-59120 tests are updated as you predicted - the non-reducing identity side now reduces onto the reported I also re-measured the fail-on-base claims against the new base: only the multi-reduce test still fails there (CCE - the second reduce's reducer is derived from the stale expression, which no type fix reaches), while the identity-vs-bucket and subset-key cases pass on it now that #58420 fixed the read side. The description's Tested section reflects that. For the affected versions (finding 10): SPARK-59045 will be updated to 4.2.0/4.3.0/5.0.0 - the crashes start in 4.2.0 with the |
peter-toth
left a comment
There was a problem hiding this comment.
Re-checked through c161a21d522 — findings 10, 11 and 12 resolved, nothing new.
I re-measured the two rewritten SPARK-59120 tests against the new base 7f12c2da0b4 rather than reading the assertions, since they are pinning a behaviour change rather than a fix. Both fail there and pass here: the second-join test raises STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES on base, and the shuffle-count test plans 2 shuffles instead of 1. Your read of what changed is exactly right, and both new comments match what the base actually does. Of your own five, the multi-reduce test is the one that still fails on base, as you said; identity-vs-bucket and the subset-join-key case now pass there. I also diffed the round-3 and round-4 PR diffs against each other, and the only differences are the four intended ones, so the rebase carried nothing extra.
230 tests green across ShuffleSpecSuite, DistributionSuite, KeyGroupedPartitioningSuite, GroupPartitionsExecSuite, EnsureRequirementsSuite, ValidateRequirementsSuite and ProjectedOrderingAndPartitioningSuite.
One thing left from finding 10, since it is easy to lose before merge: SPARK-59045 still lists 5.0.0 as the only affected version. Your plan for it reads right to me, including leaving branch-4.2 alone — with #58420 backported there, 4.2 falls back cleanly instead of crashing, so the opt-in path is safe without this PR.
Thanks for working through all of these, @ulysses-you — nothing left open from my side.
szehon-ho
left a comment
There was a problem hiding this comment.
lgtm, just some ai comment nits
| * has a single leaf attribute (`KeyedPartitioning.supportsExpressions`), so this replaces that | ||
| * attribute and keeps the rest of the expression: any field path above the leaf comes from this | ||
| * expression, not from the re-targeted key. Re-targeting is therefore only faithful when the | ||
| * source and the target key expressions have the same path shape (all producers today use bare |
There was a problem hiding this comment.
let's remove the comment about 'all producers today'? doesnt seem necessary to understand this function by itself ?
There was a problem hiding this comment.
Removed in a74b22f - the scaladoc keeps just the path-shape rule.
| /** | ||
| * A [[Reducer]] paired with the reduced partition expression it produces. | ||
| * | ||
| * When a key-grouped partitioning is reduced onto another partitioning's key space, the original |
There was a problem hiding this comment.
optional: quick example to understand this?
There was a problem hiding this comment.
Added an example in a74b22f: an identity(id) side joined to a bucket(4, id) side reduces with IdentityReducer(bucket(4, id)), which maps each raw id value to its bucket(4, id) value. The concrete result is up to the connector's bucket algorithm, so the doc does not state one.
| // that join's sides are shuffled. Without the gate the driver dies while assembling that | ||
| // shuffle's key map, where the stored keys are re-wrapped at the expressions' types. | ||
| test("SPARK-59120: another child is shuffled onto the type-correct reduced keys") { | ||
| // This PR reports the reduced side's expression as `years(a.ts)`, which describes the reduced |
There was a problem hiding this comment.
(here and other tests), can we not have comments about 'this pr' and just a brief explanation what this is testing
There was a problem hiding this comment.
Reworded in a74b22f - the comment now describes what the test pins and what the base did, without self-reference.
| // own types is what lets `EnsureRequirements` say so. On `master` the merge cast one to the | ||
| // other and threw `ClassCastException`. | ||
| test("SPARK-59120: a second join with a non-reducing side plans on the reduced keys") { | ||
| // The first join reduces the identity side onto the year key space. This PR reports the |
There was a problem hiding this comment.
same, let's remove 'this pr' and just explain what test does
There was a problem hiding this comment.
Reworded in a74b22f, same as the other test.
szehon-ho
left a comment
There was a problem hiding this comment.
Two inline review findings from the current head.
| // `e1`, whose type can differ from the reduced keys. A connector that violates the | ||
| // contract with a reducer of a different result type fails the reduced-types check in | ||
| // `EnsureRequirements`, since the other side's keys are typed by the target transform. | ||
| KeyReducer(reducer, e2) |
There was a problem hiding this comment.
The new KeyReducer(reducer, e2) behavior is important but not directly pinned. A chained days(ts) JOIN years(ts) JOIN identity(ts) test would expose regressions: reporting stale days(ts) after the first join causes the identity side to reduce to days while the stored keys are years, potentially returning no rows. Could we extend the existing SPARK-56046 test with this third join and assert both the answer and absence of shuffles?
There was a problem hiding this comment.
Done in a74b22f - the SPARK-56046 same-result-types test now also joins a third table partitioned by identity(time), asserting the answer and no shuffles. Verified by ablation: reporting the stale original expression fails it (the identity side then reduces onto the stale expression, and the shape gate refuses the mis-described reduced layout).
| // `identity(id)` reports a Long partition key while `bucket(4, id)` reports an Integer one. | ||
| // The identity->bucket reducer maps the Long keys to Integer; the GroupPartitionsExec output | ||
| // partitioning must report the reduced (Integer) expression, not the original Long identity, | ||
| // or the key ordering derived from the expressions fails with a ClassCastException. |
There was a problem hiding this comment.
After #58420, key ordering reads keyDataTypes, so leaving the original expression here no longer causes the ClassCastException described above; this test passes on the current base. Could this comment instead explain that accurate expressions are required for downstream reduction and layout reuse?
There was a problem hiding this comment.
Reworded in a74b22f - it now explains that another join can reduce onto the reported transform, and a reduced layout can serve as another child's shuffle target, only while the expressions describe the keys.
c161a21 to
a74b22fCompare| // expression `years(a.ts)`, so the second join's identity side reduces onto it as well and | ||
| // the whole query plans without a shuffle. Under `keyDataTypes` alone (#58420) the reduced | ||
| // keys were read at their own types but still reported under the stale `identity(ts)` | ||
| // expression, and this query raised STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES; before |
There was a problem hiding this comment.
I think its still too much code history narrated here for a test comment
There was a problem hiding this comment.
Trimmed in 6ece99a - both SPARK-59120 test comments now just describe what they pin; the history lives in #58420's description and tests.
a74b22f to
6ece99aCompare…ta type transforms GroupPartitionsExec.outputPartitioning reported the original partition expressions with the reduced partition keys when a reducer changed the key data type (e.g. identity(id) -> bucket(id)), which made the key ordering fail with a ClassCastException. KeyedShuffleSpec.reducers now returns the reduced expression (a TransformExpression) alongside the reducer, and outputPartitioning reports it instead so the reported data types match the reduced keys. Co-Authored-By: Claude <noreply@anthropic.com>
…ion fix - Re-target the reduced expression at each `KeyedPartitioning`'s own key attribute in `GroupPartitionsExec.outputPartitioning` instead of stamping the single picked spec's expression on every partitioning of a chained SPJ (avoids an extra shuffle). - For single-side-transform reducers report the target transform re-targeted at this side's attribute; keep the original expression when both sides reduce (known gap). - Introduce `KeyReducer` to name the `(Reducer, reducedExpression)` pair across the reducer signatures and drop the `_1` accessors. - Cover the `reduceKeys` trigger by testing the multi-table reduce without the subset partition-keys config, and add a chained-SPJ regression test asserting no shuffle. Co-Authored-By: Claude <noreply@anthropic.com>
… reduced-expression fix - Store the raw target transform in `KeyReducer` and re-target it once at the use site (`GroupPartitionsExec.outputPartitioning`) - Add `TransformExpression.withReference` to share the re-targeting idiom - Compute both directions' reducers in a single pass (`reducersBothWays`) so each catalog `Reducer` lookup runs once per expression pair - Normalize exprIds in `GroupPartitionsExec.doCanonicalize` so SPJ subtrees with value-equal reducers still deduplicate Co-Authored-By: Claude <noreply@anthropic.com>
…yDataTypes rebase - The reduced side now reports the type-correct target transform, so the two SPARK-59120 tests change behavior: the non-reducing identity side reduces onto the reported `years` expression and the query plans with no shuffle, and the shape gate accepts the reduced layout so only the unpartitioned side is shuffled onto it. Update both to pin the new behavior. - Name SPARK-59121 in the both-sides-reduce gap comment. - Fix the re-targeting test comment that read as if the fix added a shuffle. Co-Authored-By: Claude <noreply@anthropic.com>
…comments and coverage - Pin the single-side reduced-expression reporting: extend the SPARK-56046 same-result-types test with a third join over an identity-partitioned table, which only plans without a shuffle while the reduced keys are reported under the type-correct target transform (verified by ablation) - Reword the reduced-expression comments to explain what accurate reporting enables (downstream reduction, layout reuse) instead of the pre-apache#58420 ClassCastException mechanism, and drop the self-references in the test comments - Give IdentityReducer a quick example and trim the withReference scaladoc Co-Authored-By: Claude <noreply@anthropic.com>
…mments Co-Authored-By: Claude <noreply@anthropic.com>
6ece99a to
b36f195Compare…rtition key data type ### What changes were proposed in this pull request? `KeyedShuffleSpec.reducersBothWays` now pairs each `Reducer` with the reduced partition expression it produces (`KeyReducer`), and `GroupPartitionsExec.outputPartitioning` reports the reduced expression instead of the original partition expressions when reducers are applied. The stored expression is re-targeted at each `KeyedPartitioning`'s own key attribute at the use site via the new `TransformExpression.withReference`, so a chained storage-partitioned join keeps every side's partitioning intact. `GroupPartitionsExec.doCanonicalize` additionally normalizes the exprIds inside `KeyReducer` - plan canonicalization does not reach into the plain case class - and the reducer applied for an identity-vs-transform pair is a named `IdentityReducer` case class, so structurally identical SPJ subtrees with value-equal reducers still compare equal and exchange/subquery reuse keeps deduplicating them. ### Why are the changes needed? In a storage-partitioned join with compatible transforms whose result types differ (e.g. `identity(id)` on one side and `bucket(N, id)` on the other), the reducer maps the partition keys to the other side's value type. `GroupPartitionsExec.outputPartitioning` used to report the original expressions with the reduced keys, so the two had different data types and computing the key ordering threw: ``` java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long ``` This fix covers the reducers where the reduced keys equal a single transform applied to one side (identity-vs-transform and single-side-transform). When both sides of a compatible-transform join reduce their keys, the reduced keys are not expressible as a single transform; that shape is a known gap tracked in SPARK-59121. ### Does this PR introduce _any_ user-facing change? No by default. Under `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, a storage-partitioned join whose reducer changes the partition key data type previously threw `ClassCastException` and now succeeds. ### How was this patch tested? Added regression tests in `KeyGroupedPartitioningSuite`. On the current base (including #58420), the multi-table reduce through the `reduceKeys` trigger fails with `ClassCastException` - the second reduce's reducer is derived from the stale reported expression, which no type fix reaches - and the two updated `SPARK-59120` tests fail; all pass here. The two `SPARK-59120` tests now pin the combined behavior: a second join with a non-reducing side plans with no shuffle onto the reported expression, and the shape gate accepts the type-correct reduced layout so only the unpartitioned side is shuffled onto it. The identity-vs-bucket reducer and the subset-join-key cases failed on the pre-#58420 base and pass on the current one, where #58420 fixed the read side; they pin this PR's reporting. The per-`KeyedPartitioning` retargeting test passes on base and regresses together with the multi-reduce test if the use-site re-targeting is dropped, and the canonicalization test arrived with `KeyReducer` in this PR. The first three also assert the storage-partitioned join introduces no shuffle. Ran `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ShuffleSpecSuite`, `ValidateRequirementsSuite`, and `DistributionSuite`. Closes#58335 from ulysses-you/worktree-spj-reducer. Authored-by: Xiduo You <ulyssesyou18@gmail.com> Signed-off-by: Xiduo You <ulyssesyou@apache.org> (cherry picked from commit a76ef57) Signed-off-by: Xiduo You <ulyssesyou@apache.org>
…rtition key data type ### What changes were proposed in this pull request? `KeyedShuffleSpec.reducersBothWays` now pairs each `Reducer` with the reduced partition expression it produces (`KeyReducer`), and `GroupPartitionsExec.outputPartitioning` reports the reduced expression instead of the original partition expressions when reducers are applied. The stored expression is re-targeted at each `KeyedPartitioning`'s own key attribute at the use site via the new `TransformExpression.withReference`, so a chained storage-partitioned join keeps every side's partitioning intact. `GroupPartitionsExec.doCanonicalize` additionally normalizes the exprIds inside `KeyReducer` - plan canonicalization does not reach into the plain case class - and the reducer applied for an identity-vs-transform pair is a named `IdentityReducer` case class, so structurally identical SPJ subtrees with value-equal reducers still compare equal and exchange/subquery reuse keeps deduplicating them. ### Why are the changes needed? In a storage-partitioned join with compatible transforms whose result types differ (e.g. `identity(id)` on one side and `bucket(N, id)` on the other), the reducer maps the partition keys to the other side's value type. `GroupPartitionsExec.outputPartitioning` used to report the original expressions with the reduced keys, so the two had different data types and computing the key ordering threw: ``` java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long ``` This fix covers the reducers where the reduced keys equal a single transform applied to one side (identity-vs-transform and single-side-transform). When both sides of a compatible-transform join reduce their keys, the reduced keys are not expressible as a single transform; that shape is a known gap tracked in SPARK-59121. ### Does this PR introduce _any_ user-facing change? No by default. Under `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, a storage-partitioned join whose reducer changes the partition key data type previously threw `ClassCastException` and now succeeds. ### How was this patch tested? Added regression tests in `KeyGroupedPartitioningSuite`. On the current base (including #58420), the multi-table reduce through the `reduceKeys` trigger fails with `ClassCastException` - the second reduce's reducer is derived from the stale reported expression, which no type fix reaches - and the two updated `SPARK-59120` tests fail; all pass here. The two `SPARK-59120` tests now pin the combined behavior: a second join with a non-reducing side plans with no shuffle onto the reported expression, and the shape gate accepts the type-correct reduced layout so only the unpartitioned side is shuffled onto it. The identity-vs-bucket reducer and the subset-join-key cases failed on the pre-#58420 base and pass on the current one, where #58420 fixed the read side; they pin this PR's reporting. The per-`KeyedPartitioning` retargeting test passes on base and regresses together with the multi-reduce test if the use-site re-targeting is dropped, and the canonicalization test arrived with `KeyReducer` in this PR. The first three also assert the storage-partitioned join introduces no shuffle. Ran `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ShuffleSpecSuite`, `ValidateRequirementsSuite`, and `DistributionSuite`. Closes#58335 from ulysses-you/worktree-spj-reducer. Authored-by: Xiduo You <ulyssesyou18@gmail.com> Signed-off-by: Xiduo You <ulyssesyou@apache.org> (cherry picked from commit a76ef57) Signed-off-by: Xiduo You <ulyssesyou@apache.org>
ulysses-you
commented
Sep 1, 2026
ulysses-you
commented
Sep 1, 2026
thank you all ! |
peter-toth
commented
Sep 1, 2026
Now that this is in, one question about the branch reach. It went to
Would you like a I ask because SPARK-59121, the follow-up for the shape where both sides reduce, is a change on top of this one, so it can only reach 4.2 after this does. |
ulysses-you
commented
Sep 1, 2026
@peter-toth created #58451 |
Seems like this causes a test failure on This is from https://github.com/apache/spark/actions/runs/33502924519/job/99854844877: |
dongjoon-hyun
commented
Sep 1, 2026
Please feel free to revert it from |
peter-toth
commented
Sep 1, 2026
@ulysses-you, do you think you can fix it in a follow-up tailored for |
ulysses-you
commented
Sep 2, 2026
thank you @peter-toth and @dongjoon-hyun , created followup for 4.3 #58460 |
…es partition key data type ### What changes were proposed in this pull request? Backport of #58335 to `branch-4.2`. `KeyedShuffleSpec.reducersBothWays` now pairs each `Reducer` with the reduced partition expression it produces (`KeyReducer`), and `GroupPartitionsExec.outputPartitioning` reports the reduced expression instead of the original partition expressions when reducers are applied. The stored expression is re-targeted at each `KeyedPartitioning`'s own key attribute at the use site via the new `TransformExpression.withReference`, so a chained storage-partitioned join keeps every side's partitioning intact. `GroupPartitionsExec.doCanonicalize` additionally normalizes the exprIds inside `KeyReducer` - plan canonicalization does not reach into the plain case class - and the reducer applied for an identity-vs-transform pair is a named `IdentityReducer` case class, so structurally identical SPJ subtrees with value-equal reducers still compare equal and exchange/subquery reuse keeps deduplicating them. Tailored for this branch in four places: - `GroupPartitionsExec.outputPartitioning` keeps this branch's `groupedPartitions`/`isGrouped` reporting and its multi-`KeyedPartitioning` partition-keys assertion. The `PartitionGrouping`/`isCollapsed` refactor, which replaced both on `master`, is not on this branch. - The config is spelled `V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS` here, the `JOIN_` was dropped from the name later. - The canonicalization test builds `LocalTableScanExec` with this branch's three-argument constructor. - The subset-join-key test additionally sets `REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION` to false. SPARK-58558, which relaxed `createKeyedShuffleSpec` from an exact key match to a key-coverage check so the subset shape plans under the default, is not on this branch; the branch's other subset tests set the config the same way. One of the three `SPARK-59120` tests from #58335 is not carried over: `SPARK-59120: reduced partition keys are read at the types they were built with` was already omitted from the #58420 backport (#58431), because its failure mode runs through the sort that `createShuffleSpec` applies, which arrived with SPARK-59022 and is not on this branch. The other two `SPARK-59120` tests are carried over verbatim; like on `master`, they replace the two #58431 tests and pin the combined behavior. ### Why are the changes needed? In a storage-partitioned join with compatible transforms whose result types differ (e.g. `identity(id)` on one side and `bucket(N, id)` on the other), the reducer maps the partition keys to the other side's value type. `GroupPartitionsExec.outputPartitioning` used to report the original expressions with the reduced keys, so the two had different data types and computing the key ordering threw: ``` java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long ``` This fix covers the reducers where the reduced keys equal a single transform applied to one side (identity-vs-transform and single-side-transform). When both sides of a compatible-transform join reduce their keys, the reduced keys are not expressible as a single transform; that shape is a known gap tracked in SPARK-59121. ### Does this PR introduce _any_ user-facing change? No by default. Under `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, a storage-partitioned join whose reducer changes the partition key data type previously threw `ClassCastException` and now succeeds. ### How was this patch tested? Added regression tests in `KeyGroupedPartitioningSuite`, mirroring #58335. Measured failing on this branch at `0e37b68e742` (with #58431 on it): - `SPARK-59045: compatible transforms reduce multiple times` fails with `ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long` - the second reduce's reducer is derived from the stale reported expression, which no type fix reaches. - The two carried-over `SPARK-59120` tests fail: the second-join one raises `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES` (the behavior #58431 pinned on this branch), and the another-child one plans two shuffles instead of one. They now pin the combined behavior. - The `shipments` extension of `SPARK-56046: Reducers with same result types` fails when the third table reduces onto the first join's stale reported expression and that reduce evaluates the expression (`SCALAR_FUNCTION_NOT_FULLY_IMPLEMENTED`). Measured passing on that base, as on `master`: the identity-vs-bucket reducer, the subset-join-key, and the per-`KeyedPartitioning` retargeting tests - they pin this change's reporting, and the retargeting test regresses together with the multi-reduce test if the use-site re-targeting is dropped. The canonicalization test arrived with `KeyReducer` in this change. The first three also assert the storage-partitioned join introduces no shuffle. All tests pass here. Ran `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ShuffleSpecSuite`, `ValidateRequirementsSuite`, and `DistributionSuite`. ### Was this patch authored or co-authored using generative AI tooling? Yes. Generated-by: Claude Code. Closes#58451 from ulysses-you/spj-reducer-4.2. Authored-by: Xiduo You <ulyssesyou18@gmail.com> Signed-off-by: Xiduo You <ulyssesyou@apache.org>
### What changes were proposed in this pull request? Follow-up to the `branch-4.3` backport of #58335 (commit 0d253fe). Sets `spark.sql.sources.v2.bucketing.requireAllClusterKeysForCoPartition` to false in the `SPARK-59045: compatible transforms reduce data type with subset join keys` test, which is what every other subset test in this suite already does on this branch. ### Why are the changes needed? The backport broke this test on `branch-4.3`: ``` - SPARK-59045: compatible transforms reduce data type with subset join keys *** FAILED *** List(Exchange hashpartitioning(id#L, 5), ENSURE_REQUIREMENTS, ...) was not empty storage-partitioned join should not shuffle ``` On `master` the test plans without the config because SPARK-58558 relaxed `createKeyedShuffleSpec` from requiring the partition keys to exactly match the join keys to requiring them to cover the join keys. SPARK-58558 is not on this branch, so with the config at its default `createKeyedShuffleSpec` refuses the subset shape, both sides are shuffled, and the no-shuffle assertion fails. ### Does this PR introduce _any_ user-facing change? No. ### How was this patch tested? The failing test now passes; ran `KeyGroupedPartitioningSuite`. ### Was this patch authored or co-authored using generative AI tooling? Yes. Generated-by: Claude Code. Closes#58460 from ulysses-you/spj-reducer-4.3. Authored-by: Xiduo You <ulyssesyou18@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
…reduces the partition keys of both sides ### What changes were proposed in this pull request? This builds on apache#58335, which makes a reduced key-grouped partitioning report a transform that describes its keys for the two reducer shapes where one exists. When both sides of the join reduce there is none. The keys become `r1(f1(x))` = `r2(f2(x))`, a third key space that neither side's transform names. `bucket(12, id)` joined to `bucket(8, id)` is the flagship example. `BucketFunction.reducer` hands both sides `BucketReducer(4)`, the keys become `id % 4`, and both sides keep reporting the transforms they were built from. That is the gap apache#58335 names and leaves open, and this PR closes it by saying so rather than by inventing an expression. `TransformExpression` gains a fourth field, `reducedWith: Option[TransformFunctionId]`, which names the transform this one's keys were reduced together with. `KeyedShuffleSpec.reducersBothWays` is the only producer. In its both-sides-reduce branch it now reports `e1.reducedTogetherWith(e2)` instead of the bare `e1`. The marker holds no `Expression`, only a canonical name and a bucket count, so it is safe in a field canonicalization does not descend into. Putting it on the expression rather than on `KeyedPartitioning` is what keeps the change small. Every site that derives a partitioning already carries the expressions along. `AliasAwareOutputExpression` projects them, `GroupPartitionsExec.outputPartitioning` re-reports them, and `TransformExpression.withReference` re-targets them, so the marker is inherited, and dropped with the position it belongs to, without a line of new plumbing. `GroupPartitionsExec` needed no change at all. Four sites then refuse to reason about such keys, and a fifth refusal falls out of expression equality: - `KeyedShuffleSpec.isExpressionCompatible` does not compare marked keys by transform. Two of them are compatible when the same pair was reduced together, which is the pair the join produced. Anything else has to shuffle. - `KeyedShuffleSpec.canCreatePartitioning` does not shuffle another child onto marked keys, because that evaluates the reported expressions per row. - `KeyedShuffleSpec.reducersBothWays` does not reduce marked keys a second time. - `keysSatisfy` does not let marked keys satisfy an `OrderedDistribution`. An ordering is a claim about the key *values*, and nothing makes a reducer order-preserving. This one is a local guard. A marked position always carries a transform, and a SQL `ORDER BY` cannot name one. - `UnionExec.comparePartitioning` compares the children's expressions with `semanticEquals`, so a marked child no longer merges with an unmarked sibling reporting the same transform. No change was needed there. Clustering is the one thing a marked partitioning still satisfies, and that is sound. The keys remain a function of the same attributes, which is all `ClusteredDistribution` asks. Two things promised in the review of SPARK-59120 (apache#58420) are now delivered, since the marker answers the question they approximated: `KeyedPartitioning.expressionsDescribeKeyShape` and its use in `canCreatePartitioning` are replaced by `expressionsDescribeKeys`, and the three-reader list in the `keyDataTypes` scaladoc is gone. Keeping the type check as a second conjunct was considered and dropped. It is not sufficient, since `bucket(12)` and `bucket(8)` reducing onto `bucket(4)` keep their `IntegerType` and pass it. It is not necessary either. After a one-side reduce the reported expression is the target transform, and `EnsureRequirements` already refuses a reducer whose `resultType()` disagrees with the other side's key types, which are that transform's, with `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES`. So the keys and the expressions agree at every reachable one-side reduce, and a both-sides reduce is marked. The unit test that pinned the type proxy is replaced by one that pins the marker, and it keeps the proxy's struct case. A partitioning whose key row was built at `struct<f>` while it declares `struct<g>`, which is what `createPartitioning` produces, is still accepted. Re-adding any type comparison to the gate fails that assertion. Two smaller things came with it. `TransformExpression.resolveFunctionCall()` refuses a marked expression, so `eval` throws on one instead of computing the un-reduced transform and misrouting the row. That is a local gate rather than a live check, because every consumer of a reduced partitioning refuses it first and the write path never sees one. It also replaces the copy of the rule that prepends the bucket count as a literal argument in `DistributionAndOrderingUtils`, and both copies had to be touched here anyway, since the extractor grew a field. It builds a fresh expression per call, because the result can be stateful. ### Why are the changes needed? Four failures, each one a test here, all measured on this PR's base. All of them need `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, which is off by default, since that is what admits a reducer at all. 1. Two reduces onto different key spaces look co-partitioned. Four tables holding ids 0 until 24, bucketed by 12 and 8 on one side and by 12 and 18 on the other. The first pair reduces onto `id % 4`, the second onto `id % 6`, and both keep reporting `bucket(12, id)`. Joining the two returns **8 of 24 rows** with no shuffle. This is also what the pairing in the marker is for. Marking the keys without recording which pair produced them fixes everything else here and still returns those 8 rows. 2. Reducing keys twice. Three tables bucketed by 12, 8 and 6: the first join reduces onto `id % 4`, then the second derives a `bucket(6)` reducer from the reported `bucket(12, id)` and applies it to keys that are already reduced. `(id % 4) % 6` leaves the left keys alone while the right side moves to `id % 6`, so the query loses rows with no shuffle. 3. Reducing keys twice, the other way. Two `days`/`years` joins that each reduce both sides onto one year space, then joined to each other with different key sets on the two legs. The outer join derives reducers again and planning throws `ClassCastException: Long cannot be cast to Integer`. 4. Merging a reduced partitioning in a union. `(bucket12 JOIN bucket8) UNION ALL bucket12` reports `bucket(12, id)` on both sides of the union, so the two are merged although the join side's keys are `id % 4`. A `GROUP BY id` above it returns each id twice. The fifth refusal, `canCreatePartitioning`, guards a hazard the first one opens up. Once a marked partitioning is no longer compatible with anything but its own pairing, `EnsureRequirements` takes the one-side-shuffle path instead, and there it would happily shuffle the other child onto the reduced keys, which places rows by evaluating `bucket(12, id)` against partitions laid out by `id % 4`. Measured with tables bucketed by 12, 8 and 2 and `v2BucketingShuffleEnabled`, with the clause removed the query returns **8 of 12 rows**. ### Does this PR introduce _any_ user-facing change? Yes, it fixes the wrong results and the crash above. A query that used to reach one of them now shuffles instead, so its plan changes. It also refuses two plans that happen to be correct today, In both cases nothing can tell the two apart from the outside. The first is a second join onto an already reduced space. `bucket(12) JOIN bucket(8)` lands on `id % 4`. Meeting a `bucket(4, id)` or `bucket(2, id)` table, `BucketReducer` would compose correctly, since those counts divide 4. Meeting a `bucket(6, id)` table it would not, and that is failure 2 above. The `Reducer` API cannot say which of the two it is, so both now shuffle: `bucket12 JOIN bucket8 JOIN bucket4` goes from 0 shuffles to 2, with the same rows. This only arises where both sides reduced, i.e. where `gcd(a, b) < min(a, b)`. A chain in which one bucket count divides the other takes the one-side path and is unaffected. The second is two reduces that land on the same space through different pairings, e.g. `bucket(12)` with `bucket(8)` and `bucket(12)` with `bucket(20)`, both of which give `id % 4`. They are treated as different spaces and the join shuffles. Both cost a shuffle, not a wrong answer. A follow-up can give them back by letting a `ReducibleFunction` name the transform it reduces onto, which makes this whole shape disappear rather than be refused. Queries whose keys were not reduced on both sides are unaffected, and a join of two sides that were reduced together keeps its plan. ### How was this patch tested? Ten new tests. Six query tests in `KeyGroupedPartitioningSuite`, and one each in `TransformExpressionSuite`, `ShuffleSpecSuite`, `GroupPartitionsExecSuite` and `ProjectedOrderingAndPartitioningSuite`. One existing test changed, see below. Four of the six query tests fail on the base commit with wrong rows or a crash, listed as failures 1 to 4 above. A fifth, `another side is not shuffled onto reduced keys`, returns the right rows there with no shuffle. This PR costs it one shuffle, and it is what pins the `canCreatePartitioning` clause. Each refusal was ablated, and each ablation fails exactly the tests written for it: - `canCreatePartitioning`'s clause removed: `another side is not shuffled onto reduced keys` returns 8 of 12 rows. - the same-pairing allowance in `isExpressionCompatible` made unconditional, i.e. the refusal taken too far. `two sides reduced onto the same keys still join without a shuffle` fails, and so does the existing `SPARK-56164: Reducers with different result types to original keys`. - the pairing thrown away, so that any two marked keys count as one space. `two reduced partitionings are not compatible by their transforms` returns 8 of 24 rows. That is the ablation the test exists for, and it is why the marker records the pair rather than a bit. - the `reducersBothWays` guard removed. `two sides reduced together are not reduced a second time` throws the `ClassCastException`. No other test in the suite reaches that guard, and instrumenting it to throw on entry showed every one of them misses it. That is why the test is built the way it is, with different key sets on the two legs so that the join computes reducers at all. `SPARK-56164` is the existing test for the both-sides-reduce shape, and it gained one assertion, that the join's own requirements still validate, i.e. that the two sides which were reduced together are still co-partitioned. That is the no-regression half of this change, and it belongs on the test that already describes the shape rather than in a copy of it. The four unit tests cover what a query test states only indirectly. They are the pairing relation itself, the `canCreatePartitioning` refusal, that a node which reduces nothing (or reduces another position) inherits the marker from its child, and that a projection drops the marker with the position it belongs to. The last two hold by construction under this representation, which is exactly why they are worth keeping, since an earlier design of this fix carried the marker on `KeyedPartitioning` and got both wrong. Green: `ShuffleSpecSuite`, `DistributionSuite`, `TransformExpressionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, `PlannerSuite`, `UnionSuite`, `DataSourceV2Suite`, 488 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code
…reduces the partition keys of both sides ### What changes were proposed in this pull request? This builds on #58335, which makes a reduced key-grouped partitioning report a transform that describes its keys for the two reducer shapes where one exists. When both sides of the join reduce there is none. The keys become `r1(f1(x))` = `r2(f2(x))`, a third key space that neither side's transform names. `bucket(12, id)` joined to `bucket(8, id)` is the flagship example. `BucketFunction.reducer` hands both sides `BucketReducer(4)`, the keys become `id % 4`, and both sides keep reporting the transforms they were built from. That is the gap #58335 names and leaves open, and this PR closes it by saying so rather than by inventing an expression. `TransformExpression` gains a fourth field, `reducedWith: Option[TransformFunctionId]`, which names the transform this one's keys were reduced together with. `KeyedShuffleSpec.reducersBothWays` is the only producer. In its both-sides-reduce branch it now reports `e1.reducedTogetherWith(e2)` instead of the bare `e1`. The marker holds no `Expression`, only a canonical name and a bucket count, so it is safe in a field canonicalization does not descend into. Putting it on the expression rather than on `KeyedPartitioning` is what keeps the change small. Every site that derives a partitioning already carries the expressions along. `AliasAwareOutputExpression` projects them, `GroupPartitionsExec.outputPartitioning` re-reports them, and `TransformExpression.withReference` re-targets them, so the marker is inherited, and dropped with the position it belongs to, without a line of new plumbing. `GroupPartitionsExec` needed no change at all. Four sites then refuse to reason about such keys, and a fifth refusal falls out of expression equality: - `KeyedShuffleSpec.isExpressionCompatible` does not compare marked keys by transform. Two of them are compatible when the same pair was reduced together, which is the pair the join produced. Anything else has to shuffle. - `KeyedShuffleSpec.canCreatePartitioning` does not shuffle another child onto marked keys, because that evaluates the reported expressions per row. - `KeyedShuffleSpec.reducersBothWays` does not reduce marked keys a second time. - `keysSatisfy` does not let marked keys satisfy an `OrderedDistribution`. An ordering is a claim about the key *values*, and nothing makes a reducer order-preserving. This one is a local guard. A marked position always carries a transform, and a SQL `ORDER BY` cannot name one. - `UnionExec.comparePartitioning` compares the children's expressions with `semanticEquals`, so a marked child no longer merges with an unmarked sibling reporting the same transform. No change was needed there. Clustering is the one thing a marked partitioning still satisfies, and that is sound. The keys remain a function of the same attributes, which is all `ClusteredDistribution` asks. Two things promised in the review of SPARK-59120 (#58420) are now delivered, since the marker answers the question they approximated: `KeyedPartitioning.expressionsDescribeKeyShape` and its use in `canCreatePartitioning` are replaced by `expressionsDescribeKeys`, and the three-reader list in the `keyDataTypes` scaladoc is gone. Keeping the type check as a second conjunct was considered and dropped. It is not sufficient, since `bucket(12)` and `bucket(8)` reducing onto `bucket(4)` keep their `IntegerType` and pass it. It is not necessary either. After a one-side reduce the reported expression is the target transform, and `EnsureRequirements` already refuses a reducer whose `resultType()` disagrees with the other side's key types, which are that transform's, with `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES`. So the keys and the expressions agree at every reachable one-side reduce, and a both-sides reduce is marked. The unit test that pinned the type proxy is replaced by one that pins the marker, and it keeps the proxy's struct case. A partitioning whose key row was built at `struct<f>` while it declares `struct<g>`, which is what `createPartitioning` produces, is still accepted. Re-adding any type comparison to the gate fails that assertion. Two smaller things came with it. `TransformExpression.resolvedFunction` refuses a marked expression, so `eval` throws on one instead of computing the un-reduced transform and misrouting the row. That is a local gate rather than a live check, because every consumer of a reduced partitioning refuses it first and the write path never sees one. It also replaces the copy of the rule that prepends the bucket count as a literal argument in `DistributionAndOrderingUtils`, and both copies had to be touched here anyway, since the extractor grew a field. The `keyDataTypes` scaladoc also records where its no-key fallback stops being truthful. A marked partitioning can end up with no key, for instance when `v2BucketingPartitionFilterEnabled` intersects two sides that hold disjoint keys, and it then reports the un-reduced transform's type while the other leg of the same pairing reports the reducer's. SPARK-59176 tracks that, with the repro and two ways to fix it. The same query fails on a `ClassCastException` without this marker, so nothing regresses here. ### Why are the changes needed? Four failures, each one a test here, all measured on this PR's base. All of them need `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, which is off by default, since that is what admits a reducer at all. 1. Two reduces onto different key spaces look co-partitioned. Four tables holding ids 0 until 24, bucketed by 12 and 8 on one side and by 12 and 18 on the other. The first pair reduces onto `id % 4`, the second onto `id % 6`, and both keep reporting `bucket(12, id)`. Joining the two returns **8 of 24 rows** with no shuffle. This is also what the pairing in the marker is for. Marking the keys without recording which pair produced them fixes everything else here and still returns those 8 rows. 2. Reducing keys twice. Three tables bucketed by 12, 8 and 6: the first join reduces onto `id % 4`, then the second derives a `bucket(6)` reducer from the reported `bucket(12, id)` and applies it to keys that are already reduced. `(id % 4) % 6` leaves the left keys alone while the right side moves to `id % 6`, so the query loses rows with no shuffle. 3. Reducing keys twice, the other way. Two `days`/`years` joins that each reduce both sides onto one year space, then joined to each other with different key sets on the two legs. The outer join derives reducers again and planning throws `ClassCastException: Long cannot be cast to Integer`. 4. Merging a reduced partitioning in a union. `(bucket12 JOIN bucket8) UNION ALL bucket12` reports `bucket(12, id)` on both sides of the union, so the two are merged although the join side's keys are `id % 4`. A `GROUP BY id` above it returns each id twice. The fifth refusal, `canCreatePartitioning`, guards a hazard the first one opens up. Once a marked partitioning is no longer compatible with anything but its own pairing, `EnsureRequirements` takes the one-side-shuffle path instead, and there it would happily shuffle the other child onto the reduced keys, which places rows by evaluating `bucket(12, id)` against partitions laid out by `id % 4`. Measured with tables bucketed by 12, 8 and 2 and `v2BucketingShuffleEnabled`, with the clause removed the query returns **8 of 12 rows**. ### Does this PR introduce _any_ user-facing change? Yes, it fixes the wrong results and the crash above. A query that used to reach one of them now shuffles instead, so its plan changes. It also refuses two plans that happen to be correct today, In both cases nothing can tell the two apart from the outside. The first is a second join onto an already reduced space. `bucket(12) JOIN bucket(8)` lands on `id % 4`. Meeting a `bucket(4, id)` or `bucket(2, id)` table, `BucketReducer` would compose correctly, since those counts divide 4. Meeting a `bucket(6, id)` table it would not, and that is failure 2 above. The `Reducer` API cannot say which of the two it is, so both now shuffle: `bucket12 JOIN bucket8 JOIN bucket4` goes from 0 shuffles to 2, with the same rows. This only arises where both sides reduced, i.e. where `gcd(a, b) < min(a, b)`. A chain in which one bucket count divides the other takes the one-side path and is unaffected. The second is two reduces that land on the same space through different pairings, e.g. `bucket(12)` with `bucket(8)` and `bucket(12)` with `bucket(20)`, both of which give `id % 4`. They are treated as different spaces and the join shuffles. Both cost a shuffle, not a wrong answer. A follow-up can give them back by letting a `ReducibleFunction` name the transform it reduces onto, which makes this whole shape disappear rather than be refused. Queries whose keys were not reduced on both sides are unaffected, and a join of two sides that were reduced together keeps its plan. ### How was this patch tested? Ten new tests. Six query tests in `KeyGroupedPartitioningSuite`, and one each in `TransformExpressionSuite`, `ShuffleSpecSuite`, `GroupPartitionsExecSuite` and `ProjectedOrderingAndPartitioningSuite`. One existing test changed, see below. Four of the six query tests fail on the base commit with wrong rows or a crash, listed as failures 1 to 4 above. A fifth, `another side is not shuffled onto reduced keys`, returns the right rows there with no shuffle. This PR costs it one shuffle, and it is what pins the `canCreatePartitioning` clause. Each refusal was ablated, and each ablation fails exactly the tests written for it: - `canCreatePartitioning`'s clause removed: `another side is not shuffled onto reduced keys` returns 8 of 12 rows. - the same-pairing allowance in `isExpressionCompatible` made unconditional, i.e. the refusal taken too far. `two sides reduced onto the same keys still join without a shuffle` fails, and so does the existing `SPARK-56164: Reducers with different result types to original keys`. - the pairing thrown away, so that any two marked keys count as one space. `two reduced partitionings are not compatible by their transforms` returns 8 of 24 rows. That is the ablation the test exists for, and it is why the marker records the pair rather than a bit. - the `reducersBothWays` guard removed. `two sides reduced together are not reduced a second time` throws the `ClassCastException`. No other test in the suite reaches that guard, and instrumenting it to throw on entry showed every one of them misses it. That is why the test is built the way it is, with different key sets on the two legs so that the join computes reducers at all. `SPARK-56164` is the existing test for the both-sides-reduce shape, and it gained one assertion, that the join's own requirements still validate, i.e. that the two sides which were reduced together are still co-partitioned. That is the no-regression half of this change, and it belongs on the test that already describes the shape rather than in a copy of it. The four unit tests cover what a query test states only indirectly. They are the pairing relation itself, the `canCreatePartitioning` refusal, that a node which reduces nothing (or reduces another position) inherits the marker from its child, and that a projection drops the marker with the position it belongs to. The last two hold by construction under this representation, which is exactly why they are worth keeping, since an earlier design of this fix carried the marker on `KeyedPartitioning` and got both wrong. Green: `ShuffleSpecSuite`, `DistributionSuite`, `TransformExpressionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, `PlannerSuite`, `UnionSuite`, `DataSourceV2Suite`, 488 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58447 from peter-toth/SPARK-59121-shape3. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
…reduces the partition keys of both sides ### What changes were proposed in this pull request? This builds on #58335, which makes a reduced key-grouped partitioning report a transform that describes its keys for the two reducer shapes where one exists. When both sides of the join reduce there is none. The keys become `r1(f1(x))` = `r2(f2(x))`, a third key space that neither side's transform names. `bucket(12, id)` joined to `bucket(8, id)` is the flagship example. `BucketFunction.reducer` hands both sides `BucketReducer(4)`, the keys become `id % 4`, and both sides keep reporting the transforms they were built from. That is the gap #58335 names and leaves open, and this PR closes it by saying so rather than by inventing an expression. `TransformExpression` gains a fourth field, `reducedWith: Option[TransformFunctionId]`, which names the transform this one's keys were reduced together with. `KeyedShuffleSpec.reducersBothWays` is the only producer. In its both-sides-reduce branch it now reports `e1.reducedTogetherWith(e2)` instead of the bare `e1`. The marker holds no `Expression`, only a canonical name and a bucket count, so it is safe in a field canonicalization does not descend into. Putting it on the expression rather than on `KeyedPartitioning` is what keeps the change small. Every site that derives a partitioning already carries the expressions along. `AliasAwareOutputExpression` projects them, `GroupPartitionsExec.outputPartitioning` re-reports them, and `TransformExpression.withReference` re-targets them, so the marker is inherited, and dropped with the position it belongs to, without a line of new plumbing. `GroupPartitionsExec` needed no change at all. Four sites then refuse to reason about such keys, and a fifth refusal falls out of expression equality: - `KeyedShuffleSpec.isExpressionCompatible` does not compare marked keys by transform. Two of them are compatible when the same pair was reduced together, which is the pair the join produced. Anything else has to shuffle. - `KeyedShuffleSpec.canCreatePartitioning` does not shuffle another child onto marked keys, because that evaluates the reported expressions per row. - `KeyedShuffleSpec.reducersBothWays` does not reduce marked keys a second time. - `keysSatisfy` does not let marked keys satisfy an `OrderedDistribution`. An ordering is a claim about the key *values*, and nothing makes a reducer order-preserving. This one is a local guard. A marked position always carries a transform, and a SQL `ORDER BY` cannot name one. - `UnionExec.comparePartitioning` compares the children's expressions with `semanticEquals`, so a marked child no longer merges with an unmarked sibling reporting the same transform. No change was needed there. Clustering is the one thing a marked partitioning still satisfies, and that is sound. The keys remain a function of the same attributes, which is all `ClusteredDistribution` asks. Two things promised in the review of SPARK-59120 (#58420) are now delivered, since the marker answers the question they approximated: `KeyedPartitioning.expressionsDescribeKeyShape` and its use in `canCreatePartitioning` are replaced by `expressionsDescribeKeys`, and the three-reader list in the `keyDataTypes` scaladoc is gone. Keeping the type check as a second conjunct was considered and dropped. It is not sufficient, since `bucket(12)` and `bucket(8)` reducing onto `bucket(4)` keep their `IntegerType` and pass it. It is not necessary either. After a one-side reduce the reported expression is the target transform, and `EnsureRequirements` already refuses a reducer whose `resultType()` disagrees with the other side's key types, which are that transform's, with `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES`. So the keys and the expressions agree at every reachable one-side reduce, and a both-sides reduce is marked. The unit test that pinned the type proxy is replaced by one that pins the marker, and it keeps the proxy's struct case. A partitioning whose key row was built at `struct<f>` while it declares `struct<g>`, which is what `createPartitioning` produces, is still accepted. Re-adding any type comparison to the gate fails that assertion. Two smaller things came with it. `TransformExpression.resolvedFunction` refuses a marked expression, so `eval` throws on one instead of computing the un-reduced transform and misrouting the row. That is a local gate rather than a live check, because every consumer of a reduced partitioning refuses it first and the write path never sees one. It also replaces the copy of the rule that prepends the bucket count as a literal argument in `DistributionAndOrderingUtils`, and both copies had to be touched here anyway, since the extractor grew a field. The `keyDataTypes` scaladoc also records where its no-key fallback stops being truthful. A marked partitioning can end up with no key, for instance when `v2BucketingPartitionFilterEnabled` intersects two sides that hold disjoint keys, and it then reports the un-reduced transform's type while the other leg of the same pairing reports the reducer's. SPARK-59176 tracks that, with the repro and two ways to fix it. The same query fails on a `ClassCastException` without this marker, so nothing regresses here. ### Why are the changes needed? Four failures, each one a test here, all measured on this PR's base. All of them need `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, which is off by default, since that is what admits a reducer at all. 1. Two reduces onto different key spaces look co-partitioned. Four tables holding ids 0 until 24, bucketed by 12 and 8 on one side and by 12 and 18 on the other. The first pair reduces onto `id % 4`, the second onto `id % 6`, and both keep reporting `bucket(12, id)`. Joining the two returns **8 of 24 rows** with no shuffle. This is also what the pairing in the marker is for. Marking the keys without recording which pair produced them fixes everything else here and still returns those 8 rows. 2. Reducing keys twice. Three tables bucketed by 12, 8 and 6: the first join reduces onto `id % 4`, then the second derives a `bucket(6)` reducer from the reported `bucket(12, id)` and applies it to keys that are already reduced. `(id % 4) % 6` leaves the left keys alone while the right side moves to `id % 6`, so the query loses rows with no shuffle. 3. Reducing keys twice, the other way. Two `days`/`years` joins that each reduce both sides onto one year space, then joined to each other with different key sets on the two legs. The outer join derives reducers again and planning throws `ClassCastException: Long cannot be cast to Integer`. 4. Merging a reduced partitioning in a union. `(bucket12 JOIN bucket8) UNION ALL bucket12` reports `bucket(12, id)` on both sides of the union, so the two are merged although the join side's keys are `id % 4`. A `GROUP BY id` above it returns each id twice. The fifth refusal, `canCreatePartitioning`, guards a hazard the first one opens up. Once a marked partitioning is no longer compatible with anything but its own pairing, `EnsureRequirements` takes the one-side-shuffle path instead, and there it would happily shuffle the other child onto the reduced keys, which places rows by evaluating `bucket(12, id)` against partitions laid out by `id % 4`. Measured with tables bucketed by 12, 8 and 2 and `v2BucketingShuffleEnabled`, with the clause removed the query returns **8 of 12 rows**. ### Does this PR introduce _any_ user-facing change? Yes, it fixes the wrong results and the crash above. A query that used to reach one of them now shuffles instead, so its plan changes. It also refuses two plans that happen to be correct today, In both cases nothing can tell the two apart from the outside. The first is a second join onto an already reduced space. `bucket(12) JOIN bucket(8)` lands on `id % 4`. Meeting a `bucket(4, id)` or `bucket(2, id)` table, `BucketReducer` would compose correctly, since those counts divide 4. Meeting a `bucket(6, id)` table it would not, and that is failure 2 above. The `Reducer` API cannot say which of the two it is, so both now shuffle: `bucket12 JOIN bucket8 JOIN bucket4` goes from 0 shuffles to 2, with the same rows. This only arises where both sides reduced, i.e. where `gcd(a, b) < min(a, b)`. A chain in which one bucket count divides the other takes the one-side path and is unaffected. The second is two reduces that land on the same space through different pairings, e.g. `bucket(12)` with `bucket(8)` and `bucket(12)` with `bucket(20)`, both of which give `id % 4`. They are treated as different spaces and the join shuffles. Both cost a shuffle, not a wrong answer. A follow-up can give them back by letting a `ReducibleFunction` name the transform it reduces onto, which makes this whole shape disappear rather than be refused. Queries whose keys were not reduced on both sides are unaffected, and a join of two sides that were reduced together keeps its plan. ### How was this patch tested? Ten new tests. Six query tests in `KeyGroupedPartitioningSuite`, and one each in `TransformExpressionSuite`, `ShuffleSpecSuite`, `GroupPartitionsExecSuite` and `ProjectedOrderingAndPartitioningSuite`. One existing test changed, see below. Four of the six query tests fail on the base commit with wrong rows or a crash, listed as failures 1 to 4 above. A fifth, `another side is not shuffled onto reduced keys`, returns the right rows there with no shuffle. This PR costs it one shuffle, and it is what pins the `canCreatePartitioning` clause. Each refusal was ablated, and each ablation fails exactly the tests written for it: - `canCreatePartitioning`'s clause removed: `another side is not shuffled onto reduced keys` returns 8 of 12 rows. - the same-pairing allowance in `isExpressionCompatible` made unconditional, i.e. the refusal taken too far. `two sides reduced onto the same keys still join without a shuffle` fails, and so does the existing `SPARK-56164: Reducers with different result types to original keys`. - the pairing thrown away, so that any two marked keys count as one space. `two reduced partitionings are not compatible by their transforms` returns 8 of 24 rows. That is the ablation the test exists for, and it is why the marker records the pair rather than a bit. - the `reducersBothWays` guard removed. `two sides reduced together are not reduced a second time` throws the `ClassCastException`. No other test in the suite reaches that guard, and instrumenting it to throw on entry showed every one of them misses it. That is why the test is built the way it is, with different key sets on the two legs so that the join computes reducers at all. `SPARK-56164` is the existing test for the both-sides-reduce shape, and it gained one assertion, that the join's own requirements still validate, i.e. that the two sides which were reduced together are still co-partitioned. That is the no-regression half of this change, and it belongs on the test that already describes the shape rather than in a copy of it. The four unit tests cover what a query test states only indirectly. They are the pairing relation itself, the `canCreatePartitioning` refusal, that a node which reduces nothing (or reduces another position) inherits the marker from its child, and that a projection drops the marker with the position it belongs to. The last two hold by construction under this representation, which is exactly why they are worth keeping, since an earlier design of this fix carried the marker on `KeyedPartitioning` and got both wrong. Green: `ShuffleSpecSuite`, `DistributionSuite`, `TransformExpressionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, `PlannerSuite`, `UnionSuite`, `DataSourceV2Suite`, 488 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code Closes#58447 from peter-toth/SPARK-59121-shape3. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com> (cherry picked from commit ed06d80) Signed-off-by: Peter Toth <peter.toth@gmail.com>
…join reduces the partition keys of both sides ### What changes were proposed in this pull request? This builds on #58335, which makes a reduced key-grouped partitioning report a transform that describes its keys for the two reducer shapes where one exists. When both sides of the join reduce there is none. The keys become `r1(f1(x))` = `r2(f2(x))`, a third key space that neither side's transform names. `bucket(12, id)` joined to `bucket(8, id)` is the flagship example. `BucketFunction.reducer` hands both sides `BucketReducer(4)`, the keys become `id % 4`, and both sides keep reporting the transforms they were built from. That is the gap #58335 names and leaves open, and this PR closes it by saying so rather than by inventing an expression. `TransformExpression` gains a fourth field, `reducedWith: Option[TransformFunctionId]`, which names the transform this one's keys were reduced together with. `KeyedShuffleSpec.reducersBothWays` is the only producer. In its both-sides-reduce branch it now reports `e1.reducedTogetherWith(e2)` instead of the bare `e1`. The marker holds no `Expression`, only a canonical name and a bucket count, so it is safe in a field canonicalization does not descend into. Putting it on the expression rather than on `KeyedPartitioning` is what keeps the change small. Every site that derives a partitioning already carries the expressions along. `AliasAwareOutputExpression` projects them, `GroupPartitionsExec.outputPartitioning` re-reports them, and `TransformExpression.withReference` re-targets them, so the marker is inherited, and dropped with the position it belongs to, without a line of new plumbing. `GroupPartitionsExec` needed no change at all. Four sites then refuse to reason about such keys, and a fifth refusal falls out of expression equality: - `KeyedShuffleSpec.isExpressionCompatible` does not compare marked keys by transform. Two of them are compatible when the same pair was reduced together, which is the pair the join produced. Anything else has to shuffle. - `KeyedShuffleSpec.canCreatePartitioning` does not shuffle another child onto marked keys, because that evaluates the reported expressions per row. - `KeyedShuffleSpec.reducersBothWays` does not reduce marked keys a second time. - `keysSatisfy` does not let marked keys satisfy an `OrderedDistribution`. An ordering is a claim about the key *values*, and nothing makes a reducer order-preserving. This one is a local guard. A marked position always carries a transform, and a SQL `ORDER BY` cannot name one. - `UnionExec.comparePartitioning` compares the children's expressions with `semanticEquals`, so a marked child no longer merges with an unmarked sibling reporting the same transform. No change was needed there. Clustering is the one thing a marked partitioning still satisfies, and that is sound. The keys remain a function of the same attributes, which is all `ClusteredDistribution` asks. Two things promised in the review of SPARK-59120 (#58420) are now delivered, since the marker answers the question they approximated: `KeyedPartitioning.expressionsDescribeKeyShape` and its use in `canCreatePartitioning` are replaced by `expressionsDescribeKeys`, and the three-reader list in the `keyDataTypes` scaladoc is gone. Keeping the type check as a second conjunct was considered and dropped. It is not sufficient, since `bucket(12)` and `bucket(8)` reducing onto `bucket(4)` keep their `IntegerType` and pass it. It is not necessary either. After a one-side reduce the reported expression is the target transform, and `EnsureRequirements` already refuses a reducer whose `resultType()` disagrees with the other side's key types, which are that transform's, with `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES`. So the keys and the expressions agree at every reachable one-side reduce, and a both-sides reduce is marked. The unit test that pinned the type proxy is replaced by one that pins the marker, and it keeps the proxy's struct case. A partitioning whose key row was built at `struct<f>` while it declares `struct<g>`, which is what `createPartitioning` produces, is still accepted. Re-adding any type comparison to the gate fails that assertion. Two smaller things came with it. `TransformExpression.resolvedFunction` refuses a marked expression, so `eval` throws on one instead of computing the un-reduced transform and misrouting the row. That is a local gate rather than a live check, because every consumer of a reduced partitioning refuses it first and the write path never sees one. It also replaces the copy of the rule that prepends the bucket count as a literal argument in `DistributionAndOrderingUtils`, and both copies had to be touched here anyway, since the extractor grew a field. The `keyDataTypes` scaladoc also records where its no-key fallback stops being truthful. A marked partitioning can end up with no key, for instance when `v2BucketingPartitionFilterEnabled` intersects two sides that hold disjoint keys, and it then reports the un-reduced transform's type while the other leg of the same pairing reports the reducer's. SPARK-59176 tracks that, with the repro and two ways to fix it. The same query fails on a `ClassCastException` without this marker, so nothing regresses here. ### Why are the changes needed? Four failures, each one a test here, all measured on this PR's base. All of them need `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, which is off by default, since that is what admits a reducer at all. 1. Two reduces onto different key spaces look co-partitioned. Four tables holding ids 0 until 24, bucketed by 12 and 8 on one side and by 12 and 18 on the other. The first pair reduces onto `id % 4`, the second onto `id % 6`, and both keep reporting `bucket(12, id)`. Joining the two returns **8 of 24 rows** with no shuffle. This is also what the pairing in the marker is for. Marking the keys without recording which pair produced them fixes everything else here and still returns those 8 rows. 2. Reducing keys twice. Three tables bucketed by 12, 8 and 6: the first join reduces onto `id % 4`, then the second derives a `bucket(6)` reducer from the reported `bucket(12, id)` and applies it to keys that are already reduced. `(id % 4) % 6` leaves the left keys alone while the right side moves to `id % 6`, so the query loses rows with no shuffle. 3. Reducing keys twice, the other way. Two `days`/`years` joins that each reduce both sides onto one year space, then joined to each other with different key sets on the two legs. The outer join derives reducers again and planning throws `ClassCastException: Long cannot be cast to Integer`. 4. Merging a reduced partitioning in a union. `(bucket12 JOIN bucket8) UNION ALL bucket12` reports `bucket(12, id)` on both sides of the union, so the two are merged although the join side's keys are `id % 4`. A `GROUP BY id` above it returns each id twice. The fifth refusal, `canCreatePartitioning`, guards a hazard the first one opens up. Once a marked partitioning is no longer compatible with anything but its own pairing, `EnsureRequirements` takes the one-side-shuffle path instead, and there it would happily shuffle the other child onto the reduced keys, which places rows by evaluating `bucket(12, id)` against partitions laid out by `id % 4`. Measured with tables bucketed by 12, 8 and 2 and `v2BucketingShuffleEnabled`, with the clause removed the query returns **8 of 12 rows**. ### Does this PR introduce _any_ user-facing change? Yes, it fixes the wrong results and the crash above. A query that used to reach one of them now shuffles instead, so its plan changes. It also refuses two plans that happen to be correct today, In both cases nothing can tell the two apart from the outside. The first is a second join onto an already reduced space. `bucket(12) JOIN bucket(8)` lands on `id % 4`. Meeting a `bucket(4, id)` or `bucket(2, id)` table, `BucketReducer` would compose correctly, since those counts divide 4. Meeting a `bucket(6, id)` table it would not, and that is failure 2 above. The `Reducer` API cannot say which of the two it is, so both now shuffle: `bucket12 JOIN bucket8 JOIN bucket4` goes from 0 shuffles to 2, with the same rows. This only arises where both sides reduced, i.e. where `gcd(a, b) < min(a, b)`. A chain in which one bucket count divides the other takes the one-side path and is unaffected. The second is two reduces that land on the same space through different pairings, e.g. `bucket(12)` with `bucket(8)` and `bucket(12)` with `bucket(20)`, both of which give `id % 4`. They are treated as different spaces and the join shuffles. Both cost a shuffle, not a wrong answer. A follow-up can give them back by letting a `ReducibleFunction` name the transform it reduces onto, which makes this whole shape disappear rather than be refused. Queries whose keys were not reduced on both sides are unaffected, and a join of two sides that were reduced together keeps its plan. ### How was this patch tested? Ten new tests. Six query tests in `KeyGroupedPartitioningSuite`, and one each in `TransformExpressionSuite`, `ShuffleSpecSuite`, `GroupPartitionsExecSuite` and `ProjectedOrderingAndPartitioningSuite`. One existing test changed, see below. Four of the six query tests fail on the base commit with wrong rows or a crash, listed as failures 1 to 4 above. A fifth, `another side is not shuffled onto reduced keys`, returns the right rows there with no shuffle. This PR costs it one shuffle, and it is what pins the `canCreatePartitioning` clause. Each refusal was ablated, and each ablation fails exactly the tests written for it: - `canCreatePartitioning`'s clause removed: `another side is not shuffled onto reduced keys` returns 8 of 12 rows. - the same-pairing allowance in `isExpressionCompatible` made unconditional, i.e. the refusal taken too far. `two sides reduced onto the same keys still join without a shuffle` fails, and so does the existing `SPARK-56164: Reducers with different result types to original keys`. - the pairing thrown away, so that any two marked keys count as one space. `two reduced partitionings are not compatible by their transforms` returns 8 of 24 rows. That is the ablation the test exists for, and it is why the marker records the pair rather than a bit. - the `reducersBothWays` guard removed. `two sides reduced together are not reduced a second time` throws the `ClassCastException`. No other test in the suite reaches that guard, and instrumenting it to throw on entry showed every one of them misses it. That is why the test is built the way it is, with different key sets on the two legs so that the join computes reducers at all. `SPARK-56164` is the existing test for the both-sides-reduce shape, and it gained one assertion, that the join's own requirements still validate, i.e. that the two sides which were reduced together are still co-partitioned. That is the no-regression half of this change, and it belongs on the test that already describes the shape rather than in a copy of it. The four unit tests cover what a query test states only indirectly. They are the pairing relation itself, the `canCreatePartitioning` refusal, that a node which reduces nothing (or reduces another position) inherits the marker from its child, and that a projection drops the marker with the position it belongs to. The last two hold by construction under this representation, which is exactly why they are worth keeping, since an earlier design of this fix carried the marker on `KeyedPartitioning` and got both wrong. Green: `ShuffleSpecSuite`, `DistributionSuite`, `TransformExpressionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, `PlannerSuite`, `UnionSuite`, `DataSourceV2Suite`, 488 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code #### Backport to branch-4.3 One tailoring, and it is not a code change. `TransformExpressionSuite` does not exist on this branch, because SPARK-58769 was never backported, so the unit test for `hasSameReducedKeys` is left out. The relation stays covered end to end by `two reduced partitionings are not compatible by their transforms`, which is the query test written for exactly that pairing. The branch is affected in the same way master was. Run against the branch tip, five of the six new query tests fail, the same five as on master. Green here: `ShuffleSpecSuite`, `DistributionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, 372 tests in all. `dev/lint-scala` is clean. Closes#58481 from peter-toth/SPARK-59121-shape3-4.3. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
…join reduces the partition keys of both sides ### What changes were proposed in this pull request? This builds on #58335, which makes a reduced key-grouped partitioning report a transform that describes its keys for the two reducer shapes where one exists. When both sides of the join reduce there is none. The keys become `r1(f1(x))` = `r2(f2(x))`, a third key space that neither side's transform names. `bucket(12, id)` joined to `bucket(8, id)` is the flagship example. `BucketFunction.reducer` hands both sides `BucketReducer(4)`, the keys become `id % 4`, and both sides keep reporting the transforms they were built from. That is the gap #58335 names and leaves open, and this PR closes it by saying so rather than by inventing an expression. `TransformExpression` gains a fourth field, `reducedWith: Option[TransformFunctionId]`, which names the transform this one's keys were reduced together with. `KeyedShuffleSpec.reducersBothWays` is the only producer. In its both-sides-reduce branch it now reports `e1.reducedTogetherWith(e2)` instead of the bare `e1`. The marker holds no `Expression`, only a canonical name and a bucket count, so it is safe in a field canonicalization does not descend into. Putting it on the expression rather than on `KeyedPartitioning` is what keeps the change small. Every site that derives a partitioning already carries the expressions along. `AliasAwareOutputExpression` projects them, `GroupPartitionsExec.outputPartitioning` re-reports them, and `TransformExpression.withReference` re-targets them, so the marker is inherited, and dropped with the position it belongs to, without a line of new plumbing. `GroupPartitionsExec` needed no change at all. Four sites then refuse to reason about such keys, and a fifth refusal falls out of expression equality: - `KeyedShuffleSpec.isExpressionCompatible` does not compare marked keys by transform. Two of them are compatible when the same pair was reduced together, which is the pair the join produced. Anything else has to shuffle. - `KeyedShuffleSpec.canCreatePartitioning` does not shuffle another child onto marked keys, because that evaluates the reported expressions per row. - `KeyedShuffleSpec.reducersBothWays` does not reduce marked keys a second time. - `keysSatisfy` does not let marked keys satisfy an `OrderedDistribution`. An ordering is a claim about the key *values*, and nothing makes a reducer order-preserving. This one is a local guard. A marked position always carries a transform, and a SQL `ORDER BY` cannot name one. - `UnionExec.comparePartitioning` compares the children's expressions with `semanticEquals`, so a marked child no longer merges with an unmarked sibling reporting the same transform. No change was needed there. Clustering is the one thing a marked partitioning still satisfies, and that is sound. The keys remain a function of the same attributes, which is all `ClusteredDistribution` asks. Two things promised in the review of SPARK-59120 (#58420) are now delivered, since the marker answers the question they approximated: `KeyedPartitioning.expressionsDescribeKeyShape` and its use in `canCreatePartitioning` are replaced by `expressionsDescribeKeys`, and the three-reader list in the `keyDataTypes` scaladoc is gone. Keeping the type check as a second conjunct was considered and dropped. It is not sufficient, since `bucket(12)` and `bucket(8)` reducing onto `bucket(4)` keep their `IntegerType` and pass it. It is not necessary either. After a one-side reduce the reported expression is the target transform, and `EnsureRequirements` already refuses a reducer whose `resultType()` disagrees with the other side's key types, which are that transform's, with `STORAGE_PARTITION_JOIN_INCOMPATIBLE_REDUCED_TYPES`. So the keys and the expressions agree at every reachable one-side reduce, and a both-sides reduce is marked. The unit test that pinned the type proxy is replaced by one that pins the marker, and it keeps the proxy's struct case. A partitioning whose key row was built at `struct<f>` while it declares `struct<g>`, which is what `createPartitioning` produces, is still accepted. Re-adding any type comparison to the gate fails that assertion. Two smaller things came with it. `TransformExpression.resolvedFunction` refuses a marked expression, so `eval` throws on one instead of computing the un-reduced transform and misrouting the row. That is a local gate rather than a live check, because every consumer of a reduced partitioning refuses it first and the write path never sees one. It also replaces the copy of the rule that prepends the bucket count as a literal argument in `DistributionAndOrderingUtils`, and both copies had to be touched here anyway, since the extractor grew a field. The `keyDataTypes` scaladoc also records where its no-key fallback stops being truthful. A marked partitioning can end up with no key, for instance when `v2BucketingPartitionFilterEnabled` intersects two sides that hold disjoint keys, and it then reports the un-reduced transform's type while the other leg of the same pairing reports the reducer's. SPARK-59176 tracks that, with the repro and two ways to fix it. The same query fails on a `ClassCastException` without this marker, so nothing regresses here. ### Why are the changes needed? Four failures, each one a test here, all measured on this PR's base. All of them need `spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled`, which is off by default, since that is what admits a reducer at all. 1. Two reduces onto different key spaces look co-partitioned. Four tables holding ids 0 until 24, bucketed by 12 and 8 on one side and by 12 and 18 on the other. The first pair reduces onto `id % 4`, the second onto `id % 6`, and both keep reporting `bucket(12, id)`. Joining the two returns **8 of 24 rows** with no shuffle. This is also what the pairing in the marker is for. Marking the keys without recording which pair produced them fixes everything else here and still returns those 8 rows. 2. Reducing keys twice. Three tables bucketed by 12, 8 and 6: the first join reduces onto `id % 4`, then the second derives a `bucket(6)` reducer from the reported `bucket(12, id)` and applies it to keys that are already reduced. `(id % 4) % 6` leaves the left keys alone while the right side moves to `id % 6`, so the query loses rows with no shuffle. 3. Reducing keys twice, the other way. Two `days`/`years` joins that each reduce both sides onto one year space, then joined to each other with different key sets on the two legs. The outer join derives reducers again and planning throws `ClassCastException: Long cannot be cast to Integer`. 4. Merging a reduced partitioning in a union. `(bucket12 JOIN bucket8) UNION ALL bucket12` reports `bucket(12, id)` on both sides of the union, so the two are merged although the join side's keys are `id % 4`. A `GROUP BY id` above it returns each id twice. The fifth refusal, `canCreatePartitioning`, guards a hazard the first one opens up. Once a marked partitioning is no longer compatible with anything but its own pairing, `EnsureRequirements` takes the one-side-shuffle path instead, and there it would happily shuffle the other child onto the reduced keys, which places rows by evaluating `bucket(12, id)` against partitions laid out by `id % 4`. Measured with tables bucketed by 12, 8 and 2 and `v2BucketingShuffleEnabled`, with the clause removed the query returns **8 of 12 rows**. ### Does this PR introduce _any_ user-facing change? Yes, it fixes the wrong results and the crash above. A query that used to reach one of them now shuffles instead, so its plan changes. It also refuses two plans that happen to be correct today, In both cases nothing can tell the two apart from the outside. The first is a second join onto an already reduced space. `bucket(12) JOIN bucket(8)` lands on `id % 4`. Meeting a `bucket(4, id)` or `bucket(2, id)` table, `BucketReducer` would compose correctly, since those counts divide 4. Meeting a `bucket(6, id)` table it would not, and that is failure 2 above. The `Reducer` API cannot say which of the two it is, so both now shuffle: `bucket12 JOIN bucket8 JOIN bucket4` goes from 0 shuffles to 2, with the same rows. This only arises where both sides reduced, i.e. where `gcd(a, b) < min(a, b)`. A chain in which one bucket count divides the other takes the one-side path and is unaffected. The second is two reduces that land on the same space through different pairings, e.g. `bucket(12)` with `bucket(8)` and `bucket(12)` with `bucket(20)`, both of which give `id % 4`. They are treated as different spaces and the join shuffles. Both cost a shuffle, not a wrong answer. A follow-up can give them back by letting a `ReducibleFunction` name the transform it reduces onto, which makes this whole shape disappear rather than be refused. Queries whose keys were not reduced on both sides are unaffected, and a join of two sides that were reduced together keeps its plan. ### How was this patch tested? Ten new tests. Six query tests in `KeyGroupedPartitioningSuite`, and one each in `TransformExpressionSuite`, `ShuffleSpecSuite`, `GroupPartitionsExecSuite` and `ProjectedOrderingAndPartitioningSuite`. One existing test changed, see below. Four of the six query tests fail on the base commit with wrong rows or a crash, listed as failures 1 to 4 above. A fifth, `another side is not shuffled onto reduced keys`, returns the right rows there with no shuffle. This PR costs it one shuffle, and it is what pins the `canCreatePartitioning` clause. Each refusal was ablated, and each ablation fails exactly the tests written for it: - `canCreatePartitioning`'s clause removed: `another side is not shuffled onto reduced keys` returns 8 of 12 rows. - the same-pairing allowance in `isExpressionCompatible` made unconditional, i.e. the refusal taken too far. `two sides reduced onto the same keys still join without a shuffle` fails, and so does the existing `SPARK-56164: Reducers with different result types to original keys`. - the pairing thrown away, so that any two marked keys count as one space. `two reduced partitionings are not compatible by their transforms` returns 8 of 24 rows. That is the ablation the test exists for, and it is why the marker records the pair rather than a bit. - the `reducersBothWays` guard removed. `two sides reduced together are not reduced a second time` throws the `ClassCastException`. No other test in the suite reaches that guard, and instrumenting it to throw on entry showed every one of them misses it. That is why the test is built the way it is, with different key sets on the two legs so that the join computes reducers at all. `SPARK-56164` is the existing test for the both-sides-reduce shape, and it gained one assertion, that the join's own requirements still validate, i.e. that the two sides which were reduced together are still co-partitioned. That is the no-regression half of this change, and it belongs on the test that already describes the shape rather than in a copy of it. The four unit tests cover what a query test states only indirectly. They are the pairing relation itself, the `canCreatePartitioning` refusal, that a node which reduces nothing (or reduces another position) inherits the marker from its child, and that a projection drops the marker with the position it belongs to. The last two hold by construction under this representation, which is exactly why they are worth keeping, since an earlier design of this fix carried the marker on `KeyedPartitioning` and got both wrong. Green: `ShuffleSpecSuite`, `DistributionSuite`, `TransformExpressionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, `PlannerSuite`, `UnionSuite`, `DataSourceV2Suite`, 488 tests in all. `dev/lint-scala` is clean. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Code #### Backport to branch-4.2 The production change is the same. Four tailorings, all because the surrounding code arrived after this branch: - The `PartitioningCollection` invariant paragraph is left out. `checkKeyedPartitioningInvariant` came with SPARK-59057, so there is no invariant list here to document. - `V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS` is the name the config still has on this branch. - The unit test for `hasSameReducedKeys` is left out. `TransformExpressionSuite` does not exist here, because SPARK-58769 was never backported. The relation stays covered end to end by `two reduced partitionings are not compatible by their transforms`. - The projection test is left out. It needs `DummyLeafExecWithPartitioning`, which came with SPARK-46367. Run against the branch tip, four of the six new query tests fail. `a union does not merge an already reduced partitioning` passes here rather than failing, because this branch's `UnionExec` does not merge key-grouped partitionings at all. The test is kept, so the refusal stays covered if that ever lands here. Green here: `ShuffleSpecSuite`, `DistributionSuite`, `KeyGroupedPartitioningSuite`, `GroupPartitionsExecSuite`, `EnsureRequirementsSuite`, `ProjectedOrderingAndPartitioningSuite`, `ValidateRequirementsSuite`, `WriteDistributionAndOrderingSuite`, 312 tests in all. `dev/lint-scala` is clean. Closes#58482 from peter-toth/SPARK-59121-shape3-4.2. Authored-by: Peter Toth <peter.toth@gmail.com> Signed-off-by: Peter Toth <peter.toth@gmail.com>
What changes were proposed in this pull request?
KeyedShuffleSpec.reducersBothWaysnow pairs eachReducerwith the reduced partition expression it produces (KeyReducer), andGroupPartitionsExec.outputPartitioningreports the reduced expression instead of the original partition expressions when reducers are applied. The stored expression is re-targeted at eachKeyedPartitioning's own key attribute at the use site via the newTransformExpression.withReference, so a chained storage-partitioned join keeps every side's partitioning intact.GroupPartitionsExec.doCanonicalizeadditionally normalizes the exprIds insideKeyReducer- plan canonicalization does not reach into the plain case class - and the reducer applied for an identity-vs-transform pair is a namedIdentityReducercase class, so structurally identical SPJ subtrees with value-equal reducers still compare equal and exchange/subquery reuse keeps deduplicating them.Why are the changes needed?
In a storage-partitioned join with compatible transforms whose result types differ (e.g.
identity(id)on one side andbucket(N, id)on the other), the reducer maps the partition keys to the other side's value type.GroupPartitionsExec.outputPartitioningused to report the original expressions with the reduced keys, so the two had different data types and computing the key ordering threw:This fix covers the reducers where the reduced keys equal a single transform applied to one side (identity-vs-transform and single-side-transform). When both sides of a compatible-transform join reduce their keys, the reduced keys are not expressible as a single transform; that shape is a known gap tracked in SPARK-59121.
Does this PR introduce any user-facing change?
No by default. Under
spark.sql.sources.v2.bucketing.allowCompatibleTransforms.enabled, a storage-partitioned join whose reducer changes the partition key data type previously threwClassCastExceptionand now succeeds.How was this patch tested?
Added regression tests in
KeyGroupedPartitioningSuite. On the current base (including #58420), the multi-table reduce through thereduceKeystrigger fails withClassCastException- the second reduce's reducer is derived from the stale reported expression, which no type fix reaches - and the two updatedSPARK-59120tests fail; all pass here. The twoSPARK-59120tests now pin the combined behavior: a second join with a non-reducing side plans with no shuffle onto the reported expression, and the shape gate accepts the type-correct reduced layout so only the unpartitioned side is shuffled onto it. The identity-vs-bucket reducer and the subset-join-key cases failed on the pre-#58420 base and pass on the current one, where #58420 fixed the read side; they pin this PR's reporting. The per-KeyedPartitioningretargeting test passes on base and regresses together with the multi-reduce test if the use-site re-targeting is dropped, and the canonicalization test arrived withKeyReducerin this PR. The first three also assert the storage-partitioned join introduces no shuffle.Ran
KeyGroupedPartitioningSuite,GroupPartitionsExecSuite,EnsureRequirementsSuite,ProjectedOrderingAndPartitioningSuite,ShuffleSpecSuite,ValidateRequirementsSuite, andDistributionSuite.