peter-toth commented on code in PR #58335:
URL: https://github.com/apache/spark/pull/58335#discussion_r3871285779


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -70,11 +70,22 @@ 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 (whose data type 
matches the reduced
+        // partition keys) are reported instead of the original ones.
         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((_, reduced))) => reduced

Review 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, 
because `GROUP BY b.id` no longer sees a partitioning on `b.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:
   
   ```scala
   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.
       val attr = expr.references.head
       reduced.transform { case _: AttributeReference => attr }
     case (expr, None) => expr
   }
   ```
   



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1374,9 +1376,11 @@ 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[(Reducer[_, _], 
TransformExpression)]]] = {
     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))

Review 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:
   
   1. **This side is identity, the other is a transform.** The reduced 
expression exists and the branch below builds it. Handled, and correctly.
   2. **Both sides are transforms, only this side reduces.** The reduced 
expression also exists: `r(f1(x)) = f2(x)` is the `ReducibleFunction` contract, 
so it is the other side's transform retargeted at this side's child. This 
branch reports `e1` instead. `SPARK-56046: Reducers with same result types` is 
this shape - `days` reduces onto `years`, `YearsFunction.reducer` returns null 
- and the reported expression stays `days(arrive_time)` where 
`years(arrive_time)` is what the keys hold.
   3. **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`.
   
   ```scala
   val cols = 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")))
   val values = (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"))
   val df = 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.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -542,6 +542,87 @@ class KeyGroupedPartitioningSuite extends 
DistributionAndOrderingSuiteBase with
     }
   }
 
+  test("SPARK-59045: compatible identity and bucket transforms reduce data 
type") {
+    // `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.
+    val cols = Array(
+      Column.create("id", LongType),
+      Column.create("data", StringType))
+    createTable("t1", cols, Array(identity("id")))
+    sql("INSERT INTO testcat.ns.t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')")
+
+    createTable("t2", cols, Array(bucket(4, "id")))
+    sql("INSERT INTO testcat.ns.t2 VALUES (1, 'x'), (2, 'y'), (3, 'z')")
+
+    val df = sql(
+      "SELECT t1.id, t1.data, t2.data FROM testcat.ns.t1 JOIN testcat.ns.t2 ON 
t1.id = t2.id")
+
+    withSQLConf(
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true") {
+      checkAnswer(df, Seq(Row(1, "a", "x"), Row(2, "b", "y"), Row(3, "c", 
"z")))
+      assert(collectShuffles(df.queryExecution.executedPlan).isEmpty,
+        "storage-partitioned join should not shuffle")
+    }
+  }
+
+  test("SPARK-59045: compatible transforms reduce multiple times") {
+    // t1 is partitioned by identity(id) (Long), t2 by bucket(4, id), t3 by 
bucket(2, id). The
+    // first join reduces t1 to bucket(4, id) (data type changes), and the 
second join reduces the
+    // result to bucket(2, id). The reduced expression reported by the first 
join must remain a
+    // ReducibleFunction so the second reduction can be computed.
+    val cols = Array(Column.create("id", LongType), Column.create("data", 
StringType))
+    createTable("t1", cols, Array(identity("id")))
+    createTable("t2", cols, Array(bucket(4, "id")))
+    createTable("t3", cols, Array(bucket(2, "id")))
+    sql("INSERT INTO testcat.ns.t1 VALUES (1, 'a'), (2, 'b'), (3, 'c')")
+    sql("INSERT INTO testcat.ns.t2 VALUES (1, 'x'), (2, 'y'), (3, 'z')")
+    sql("INSERT INTO testcat.ns.t3 VALUES (1, 'p'), (2, 'q'), (3, 'r')")
+
+    val df = sql(
+      "SELECT t1.id, t1.data, t2.data, t3.data FROM testcat.ns.t1 " +
+        "JOIN testcat.ns.t2 ON t1.id = t2.id JOIN testcat.ns.t3 ON t1.id = 
t3.id")
+
+    withSQLConf(
+        SQLConf.V2_BUCKETING_ALLOW_COMPATIBLE_TRANSFORMS.key -> "true",
+        SQLConf.V2_BUCKETING_ALLOW_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true") {

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



##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/plans/physical/partitioning.scala:
##########
@@ -1374,9 +1376,11 @@ 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[(Reducer[_, _], 
TransformExpression)]]] = {

Review 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:
   
   ```scala
   case class KeyReducer(reducer: Reducer[_, _], reducedExpression: 
TransformExpression)
   ```
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/datasources/v2/GroupPartitionsExec.scala:
##########
@@ -70,11 +70,22 @@ 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 (whose data type 
matches the reduced

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



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