dongjoon-hyun commented on code in PR #58335:
URL: https://github.com/apache/spark/pull/58335#discussion_r3876091918


##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -713,17 +714,18 @@ object KeyedPartitioning {
   def reduceKeys(
       keys: Seq[InternalRowComparableWrapper],
       dataTypes: Seq[DataType],
-      reducers: Seq[Option[Reducer[_, _]]]): (Seq[DataType], 
Seq[InternalRowComparableWrapper]) = {
+      reducers: Seq[Option[KeyReducer]]):
+      (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()

Review 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.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -59,7 +58,7 @@ case class GroupPartitionsExec(
     child: SparkPlan,
     @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,

Review 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.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1374,9 +1391,25 @@ case class KeyedShuffleSpec(
    *
    * @param other other key-grouped shuffle spec
    */
-  def reducers(other: KeyedShuffleSpec): Option[Seq[Option[Reducer[_, _]]]] = {
+  def reducers(other: KeyedShuffleSpec): Option[Seq[Option[KeyReducer]]] = {
     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 =>
+          if (e2.reducers(e1).isEmpty) {

Review 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.



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -70,11 +69,29 @@ case class GroupPartitionsExec(
         // 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 are reported 
instead of the original
+        // ones. For the identity-vs-transform and single-side-transform 
reducers their data type
+        // matches the reduced partition keys by construction; for the 
both-sides-reduce shape no
+        // single transform describes the keys (see 
`KeyedShuffleSpec.reducers`).
         val partitionKeys = groupedPartitions.map(_._1)
         p.transform {
           case k: KeyedPartitioning =>
             val projectedExpressions = 
joinKeyPositions.fold(k.expressions)(_.map(k.expressions))
-            KeyedPartitioning(projectedExpressions, partitionKeys, isGrouped = 
isGrouped)
+            val effectiveExpressions = reducers match {
+              case Some(exprs) =>
+                assert(projectedExpressions.length == exprs.length)
+                projectedExpressions.zip(exprs).map {
+                  case (expr, Some(KeyReducer(_, reduced))) =>
+                    // `reduced` was derived from the single spec that 
`createKeyedShuffleSpec`
+                    // 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 }

Review 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.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -567,7 +567,8 @@ case class KeyedPartitioning(
    * Returns the reduced keys and their data types.
    */
   def reduceKeys(
-      reducers: Seq[Option[Reducer[_, _]]]): (Seq[DataType], 
Seq[InternalRowComparableWrapper]) =
+      reducers: Seq[Option[KeyReducer]]):

Review 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.



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1374,9 +1391,25 @@ case class KeyedShuffleSpec(
    *
    * @param other other key-grouped shuffle spec
    */
-  def reducers(other: KeyedShuffleSpec): Option[Seq[Option[Reducer[_, _]]]] = {
+  def reducers(other: KeyedShuffleSpec): Option[Seq[Option[KeyReducer]]] = {
     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 =>
+          if (e2.reducers(e1).isEmpty) {
+            // Only this side reduces. The reducer contract is r(f1(x)) = 
f2(x) where "=" matches
+            // both value and data type, so the reduced keys equal the target 
transform applied to
+            // 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 }
+            KeyReducer(reducer, reducedExpr.asInstanceOf[TransformExpression])
+          } else {
+            // 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)

Review 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`?



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1374,9 +1391,25 @@ case class KeyedShuffleSpec(
    *
    * @param other other key-grouped shuffle spec
    */
-  def reducers(other: KeyedShuffleSpec): Option[Seq[Option[Reducer[_, _]]]] = {
+  def reducers(other: KeyedShuffleSpec): Option[Seq[Option[KeyReducer]]] = {
     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 =>
+          if (e2.reducers(e1).isEmpty) {
+            // Only this side reduces. The reducer contract is r(f1(x)) = 
f2(x) where "=" matches
+            // both value and data type, so the reduced keys equal the target 
transform applied to
+            // 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 }

Review 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.



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

To unsubscribe, e-mail: [email protected]

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


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

Reply via email to