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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -698,7 +716,129 @@ case class EnsureRequirements(
         joinType == LeftAnti || joinType == LeftOuter
   }
 
-  // Populate the common partition information down to the scan nodes
+  /**
+   * Compose join-key positions from an outer SPJ with positions already 
pushed by an inner SPJ.
+   * Outer positions index the layout the outer join sees; inner positions 
index the scan's raw
+   * partition expressions, so they are composed rather than replaced.
+   */
+  private def composeJoinKeyPositions(
+      existing: Option[Seq[Int]],
+      newPositions: Option[Seq[Int]]): Option[Seq[Int]] = {
+    (existing, newPositions) match {
+      case (Some(old), Some(newPos)) => Some(newPos.map(old))
+      case (_, None) => existing
+      case (None, Some(newPos)) => Some(newPos)
+    }
+  }
+
+  /**
+   * Project common partition values onto `projectionPositions` of the layout 
`expressions`
+   * describes and deduplicate. Values that collapse to the same key are read 
as one group, so
+   * they become one partition, unless the scan is partially clustered, where 
each split of the
+   * group still gets its own partition and the split counts add up.
+   *
+   * The keys keep the order in which they first appear, as 
`KeyGroupedPartitioning.apply` does.
+   * A one-side shuffle routes the other child into that order, so partition i 
of both sides must
+   * hold the same key.
+   */
+  private def projectAndDedupeCommonPartitionValues(
+      values: Seq[(InternalRow, Int)],
+      expressions: Seq[Expression],
+      projectionPositions: Seq[Int],
+      partiallyClustered: Boolean): Seq[(InternalRow, Int)] = {
+    val projectedExpressions = projectionPositions.map(expressions(_))
+    val projected = values.map { case (partValue, numSplits) =>
+      val row = KeyGroupedPartitioning.project(expressions, 
projectionPositions, partValue)
+      (InternalRowComparableWrapper(row, projectedExpressions), numSplits)
+    }
+    val splitsPerKey = projected.groupMapReduce(_._1)(_._2)(_ + _)
+    projected.map(_._1).distinct.map { wrapper =>
+      (wrapper.row, if (partiallyClustered) splitsPerKey(wrapper) else 1)
+    }
+  }
+
+  /**
+   * The partition expressions a scan currently reports, i.e. its own 
partition expressions
+   * projected onto the join-key positions pushed so far.
+   */
+  private def reportedLayout(params: StoragePartitionJoinParams): 
Option[Seq[Expression]] =
+    params.keyGroupedPartitioning.map { expressions =>
+      params.joinKeyPositions.map(_.map(expressions)).getOrElse(expressions)
+    }
+
+  /**
+   * Common partition values for a scan after a push. Values pushed by this 
join are the merge with
+   * the other side and already sit in the layout `newPositions` select, so 
they win. Otherwise the
+   * values an inner join pushed sit in the scan's reported layout, and are 
projected onto
+   * `newPositions`, which index that layout.
+   */
+  private def finalizeCommonPartitionValues(
+      existing: StoragePartitionJoinParams,
+      pushedValues: Option[Seq[(InternalRow, Int)]],
+      newPositions: Option[Seq[Int]]): Option[Seq[(InternalRow, Int)]] = {
+    (pushedValues, existing.commonPartitionValues, newPositions, 
reportedLayout(existing)) match {
+      case (Some(values), _, _, _) => Some(values)
+      case (None, Some(values), Some(positions), Some(layout))
+          if values.nonEmpty && values.head._1.numFields == layout.length &&
+            positions != layout.indices =>
+        Some(projectAndDedupeCommonPartitionValues(
+          values, layout, positions, existing.applyPartialClustering))
+      case (None, values, _, _) => values
+    }
+  }
+
+  /**
+   * Reducers for a scan after a push. The reducers an inner join pushed index 
the scan's reported
+   * layout, so they are first projected onto `newPositions`. A reducer this 
join pushes for a
+   * position applies to values the inner reducer already produced, so the two 
are chained.
+   */
+  private def composeReducers(
+      existing: Option[Seq[Option[Reducer[_, _]]]],
+      newPositions: Option[Seq[Int]],
+      pushed: Option[Seq[Option[Reducer[_, _]]]]): 
Option[Seq[Option[Reducer[_, _]]]] = {
+    val projectedExisting = (existing, newPositions) match {
+      case (Some(rs), Some(positions)) => Some(positions.map(rs))
+      case _ => existing
+    }
+    (projectedExisting, pushed) match {
+      case (Some(inner), Some(outer)) =>
+        assert(inner.length == outer.length,
+          "pushed reducers must index the same layout as the composed join-key 
positions")
+        Some(inner.zip(outer).map {
+          case (Some(i), Some(o)) => Some(ChainedReducer(i, o))
+          case (i, o) => o.orElse(i)
+        })
+      case (inner, outer) => outer.orElse(inner)
+    }
+  }
+
+  /**
+   * Whether `spec` uses a partition expression of a scan below `plan` whose 
splits an inner join
+   * already reduced at that position. Such a scan reports its own partition 
transform there but
+   * values of the reduced transform, so a spec built on that position 
describes a layout the scan
+   * does not have. Positions without a reducer still hold the scan's own 
values and are sound.
+   */
+  private def isBuiltFromReducedScan(plan: SparkPlan, spec: 
KeyGroupedShuffleSpec): Boolean = {
+    val specExpressions = spec.partitioning.expressions
+    // Only a nested join can hand a reduced scan's layout to a later join. 
Looking below a join
+    // also keeps a re-run of this rule over an already planned join from 
matching its own scans.
+    plan.exists {
+      case join @ (_: SortMergeJoinExec | _: ShuffledHashJoinExec) => 
join.exists {
+        case scan: BatchScanExec =>
+          (reportedLayout(scan.spjParams), scan.spjParams.reducers) match {
+            case (Some(layout), Some(reducers)) =>
+              layout.zip(reducers).exists { case (e, r) =>
+                r.isDefined && specExpressions.exists(_.semanticEquals(e))

Review Comment:
   **Finding 19.** This matches the spec's partition expressions to the scan's 
own with `semanticEquals`, so an alias between the two joins hides the 
reduction. `ProjectExec` rewrites the nested join's `KeyGroupedPartitioning` 
through the alias, to `bucket(12, id2)`, which is not `semanticEquals` to the 
scan's `bucket(12, id)`.
   
   With `l12`, `l4` and `l3`, bucketed 12, 4 and 3 over `BIGINT` ids 0 to 23, 
and `allowCompatibleTransforms` on:
   
   ```sql
   SELECT /*+ MERGE(x, l3) */ x.id2, x.id4, l3.id
   FROM (SELECT /*+ MERGE(l12, l4) */ l12.id AS id2, l4.id AS id4
         FROM l12 JOIN l4 ON l12.id = l4.id) x
   JOIN l3 ON x.id2 = l3.id
   ```
   
   This returns 8 rows instead of 24, AQE on and off, with no shuffle, the same 
as on `branch-4.1`. Without the alias it now returns all 24 rows with two 
shuffles.
   
   Only a transform position can hold a reducer, so comparing the transform 
rather than its column closes it. With this, the query returns 24 rows with two 
shuffles, and the suite passes:
   
   ```suggestion
                   r.isDefined && specExpressions.exists {
                     case s: TransformExpression => e match {
                       case t: TransformExpression => s.isSameFunction(t)
                       case _ => false
                     }
                     case s => s.semanticEquals(e)
                   }
   ```
   
   It can also shuffle a side that is fine, when another column of a scan below 
carries the same transform. This is the stale-transform problem 
[SPARK-59121](https://issues.apache.org/jira/browse/SPARK-59121) fixes on 4.2.1 
and later: a second join derives its reducer from the transform a reduced scan 
still reports.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -229,7 +239,8 @@ case class EnsureRequirements(
               // partitioned side's BatchScanExec is grouped by join keys to 
match,
               // and we do that by pushing down the join keys
               case Some(KeyGroupedShuffleSpec(_, _, Some(joinKeyPositions))) =>
-                populateJoinKeyPositions(child, Some(joinKeyPositions))
+                populateSpjParams(

Review Comment:
   **Finding 20.** The reduced-scan check covers the best spec (L148-159) and 
`checkKeyGroupCompatible` (L516), but not the children `matchedIndexes` keeps 
(L212-214). When `checkKeyGroupCompatible` refuses the reduced member, the best 
spec can come from the other side. A nested join whose reduced member 
`isCompatibleWith` that spec is then kept unshuffled, and this push only 
narrows its positions.
   
   With `nb4` and `nb12` bucketed 4 and 12 over `BIGINT` ids 0 to 23, and `nc6` 
bucketed 6 and holding ids 0, 1, 2, 3 and 6, with `shuffle.enabled` and 
`allowCompatibleTransforms` on:
   
   ```sql
   SELECT /*+ MERGE(nb4, nb12, nc6) */ nb4.id, nb12.id, nc6.id
   FROM nb4 JOIN nb12 ON nb4.id = nb12.id
   JOIN nc6 ON nb12.id = nc6.id
   ```
   
   This returns 4 rows without id 6, AQE on and off, with no shuffle, the same 
as on `branch-4.1`. Id 6 sits in partition 2 of the reduced side but in 
partition 0 of `nc6`.
   
   Leaving such a child out of `matchedIndexes` shuffles it onto `nc6`'s 
layout. With this, the query returns 5 rows with one shuffle, and the suite 
passes:
   
   ```scala
         lazy val matchedIndexes = bestSpecOpt.toSeq.flatMap { best =>
           childrenIndexes.filter(i => best.isCompatibleWith(specs(i)) &&
             !flattenSpec(specs(i)).exists {
               case k: KeyGroupedShuffleSpec => 
isBuiltFromReducedScan(children(i), k)
               case _ => false
             })
         }
   ```
   
   This is the same 
[SPARK-59121](https://issues.apache.org/jira/browse/SPARK-59121) problem: a 
reduced member is found compatible on the transform it still reports.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -698,7 +716,129 @@ case class EnsureRequirements(
         joinType == LeftAnti || joinType == LeftOuter
   }
 
-  // Populate the common partition information down to the scan nodes
+  /**
+   * Compose join-key positions from an outer SPJ with positions already 
pushed by an inner SPJ.
+   * Outer positions index the layout the outer join sees; inner positions 
index the scan's raw
+   * partition expressions, so they are composed rather than replaced.
+   */
+  private def composeJoinKeyPositions(
+      existing: Option[Seq[Int]],
+      newPositions: Option[Seq[Int]]): Option[Seq[Int]] = {
+    (existing, newPositions) match {
+      case (Some(old), Some(newPos)) => Some(newPos.map(old))
+      case (_, None) => existing
+      case (None, Some(newPos)) => Some(newPos)
+    }
+  }
+
+  /**
+   * Project common partition values onto `projectionPositions` of the layout 
`expressions`
+   * describes and deduplicate. Values that collapse to the same key are read 
as one group, so
+   * they become one partition, unless the scan is partially clustered, where 
each split of the
+   * group still gets its own partition and the split counts add up.
+   *
+   * The keys keep the order in which they first appear, as 
`KeyGroupedPartitioning.apply` does.
+   * A one-side shuffle routes the other child into that order, so partition i 
of both sides must
+   * hold the same key.
+   */
+  private def projectAndDedupeCommonPartitionValues(
+      values: Seq[(InternalRow, Int)],
+      expressions: Seq[Expression],
+      projectionPositions: Seq[Int],
+      partiallyClustered: Boolean): Seq[(InternalRow, Int)] = {
+    val projectedExpressions = projectionPositions.map(expressions(_))
+    val projected = values.map { case (partValue, numSplits) =>
+      val row = KeyGroupedPartitioning.project(expressions, 
projectionPositions, partValue)
+      (InternalRowComparableWrapper(row, projectedExpressions), numSplits)
+    }
+    val splitsPerKey = projected.groupMapReduce(_._1)(_._2)(_ + _)
+    projected.map(_._1).distinct.map { wrapper =>
+      (wrapper.row, if (partiallyClustered) splitsPerKey(wrapper) else 1)
+    }
+  }
+
+  /**
+   * The partition expressions a scan currently reports, i.e. its own 
partition expressions
+   * projected onto the join-key positions pushed so far.
+   */
+  private def reportedLayout(params: StoragePartitionJoinParams): 
Option[Seq[Expression]] =
+    params.keyGroupedPartitioning.map { expressions =>
+      params.joinKeyPositions.map(_.map(expressions)).getOrElse(expressions)
+    }
+
+  /**
+   * Common partition values for a scan after a push. Values pushed by this 
join are the merge with
+   * the other side and already sit in the layout `newPositions` select, so 
they win. Otherwise the
+   * values an inner join pushed sit in the scan's reported layout, and are 
projected onto
+   * `newPositions`, which index that layout.
+   */
+  private def finalizeCommonPartitionValues(
+      existing: StoragePartitionJoinParams,
+      pushedValues: Option[Seq[(InternalRow, Int)]],
+      newPositions: Option[Seq[Int]]): Option[Seq[(InternalRow, Int)]] = {
+    (pushedValues, existing.commonPartitionValues, newPositions, 
reportedLayout(existing)) match {
+      case (Some(values), _, _, _) => Some(values)
+      case (None, Some(values), Some(positions), Some(layout))
+          if values.nonEmpty && values.head._1.numFields == layout.length &&
+            positions != layout.indices =>
+        Some(projectAndDedupeCommonPartitionValues(
+          values, layout, positions, existing.applyPartialClustering))
+      case (None, values, _, _) => values
+    }
+  }
+
+  /**
+   * Reducers for a scan after a push. The reducers an inner join pushed index 
the scan's reported
+   * layout, so they are first projected onto `newPositions`. A reducer this 
join pushes for a
+   * position applies to values the inner reducer already produced, so the two 
are chained.
+   */
+  private def composeReducers(
+      existing: Option[Seq[Option[Reducer[_, _]]]],
+      newPositions: Option[Seq[Int]],
+      pushed: Option[Seq[Option[Reducer[_, _]]]]): 
Option[Seq[Option[Reducer[_, _]]]] = {
+    val projectedExisting = (existing, newPositions) match {
+      case (Some(rs), Some(positions)) => Some(positions.map(rs))
+      case _ => existing
+    }
+    (projectedExisting, pushed) match {
+      case (Some(inner), Some(outer)) =>
+        assert(inner.length == outer.length,
+          "pushed reducers must index the same layout as the composed join-key 
positions")
+        Some(inner.zip(outer).map {
+          case (Some(i), Some(o)) => Some(ChainedReducer(i, o))
+          case (i, o) => o.orElse(i)
+        })
+      case (inner, outer) => outer.orElse(inner)
+    }
+  }
+
+  /**
+   * Whether `spec` uses a partition expression of a scan below `plan` whose 
splits an inner join
+   * already reduced at that position. Such a scan reports its own partition 
transform there but
+   * values of the reduced transform, so a spec built on that position 
describes a layout the scan
+   * does not have. Positions without a reducer still hold the scan's own 
values and are sound.
+   */
+  private def isBuiltFromReducedScan(plan: SparkPlan, spec: 
KeyGroupedShuffleSpec): Boolean = {
+    val specExpressions = spec.partitioning.expressions
+    // Only a nested join can hand a reduced scan's layout to a later join. 
Looking below a join
+    // also keeps a re-run of this rule over an already planned join from 
matching its own scans.
+    plan.exists {
+      case join @ (_: SortMergeJoinExec | _: ShuffledHashJoinExec) => 
join.exists {
+        case scan: BatchScanExec =>
+          (reportedLayout(scan.spjParams), scan.spjParams.reducers) match {
+            case (Some(layout), Some(reducers)) =>
+              layout.zip(reducers).exists { case (e, r) =>
+                r.isDefined && specExpressions.exists(_.semanticEquals(e))
+              }
+            case _ => false
+          }
+        case _ => false
+      }
+      case _ => false
+    }
+  }
+
+  // Populate the common partition information down to the scan nodes, 
overwriting SPJ params.

Review Comment:
   **Finding 18.** `ensureOrdering` (L361) is the only caller left, and the 
reset is wrong there whenever the plan below already carries a join's SPJ 
params. A LEFT OUTER join reports its left side's `KeyGroupedPartitioning`, so 
an `ORDER BY` on the join key reaches this path. The reset then drops the key 
positions and reducers the join pushed, and the scans are regrouped on the full 
partition keys with values of the join keys.
   
   What I ran, AQE on and off, with `bucketing.sorting.enabled`, 
`allowJoinKeysSubsetOfPartitionKeys` and 
`requireAllClusterKeysForCoPartition=false`:
   
   ```sql
   -- oa3 PARTITIONED BY (a, b): (1, 10), (2, 20), (3, 10), (4, 30)
   -- ob3 PARTITIONED BY (b):    (0, 10), (0, 20)
   SELECT /*+ MERGE(oa3, ob3) */ oa3.b, oa3.a, ob3.a
   FROM oa3 LEFT JOIN ob3 ON oa3.b = ob3.b
   ORDER BY oa3.b
   ```
   
   - At this head and on `branch-4.1`: no rows with AQE off, and 
`ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1` with AQE 
on.
   - With the change below: the 4 expected rows in both modes, and 
`KeyGroupedPartitioningSuite` still passes.
   
   Finding 8 asked to keep this reset, which was the wrong call. Pushing the 
sorted values with `populateSpjParams` keeps what the join pushed, and 
`populateCommonPartitionInfo` then has no caller left:
   
   ```scala
           val sorted = partitionValues.sorted(partitionOrdering)
           populateSpjParams(plan, commonPartitionValues = Some(sorted.map((_, 
1))),
             joinKeyPositions = None)
   ```
   
   No ticket covers this one. If you'd rather not fix it here, please file a 
ticket for 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