ammarchalifah commented on code in PR #59599:
URL: https://github.com/apache/spark/pull/59599#discussion_r4377520152


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -698,42 +709,131 @@ case class EnsureRequirements(
         joinType == LeftAnti || joinType == LeftOuter
   }
 
-  // Populate the common partition information down to the scan nodes
-  private def populateCommonPartitionInfo(
-      plan: SparkPlan,
-      values: Seq[(InternalRow, Int)],
+  /**
+   * 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)
+    }
+  }
+
+  /** Controls which SPJ fields are updated on a scan during pushdown. */
+  private case class SpjPushFlags(
+      pushJoinKeyPositions: Boolean = true,
+      pushCommonValues: Boolean = true)
+
+  /**
+   * Compute side-aware SPJ push flags for nested shuffle joins. With no 
join-key positions,
+   * push common values to both inner sides. With subset join keys, compose 
join-key positions
+   * on the matching side only and retain inner common-partition metadata on 
both sides.
+   */
+  private def nestedSpjPushFlags(
       joinKeyPositions: Option[Seq[Int]],
-      reducers: Option[Seq[Option[Reducer[_, _]]]],
-      applyPartialClustering: Boolean,
-      replicatePartitions: Boolean): SparkPlan = plan match {
-    case scan: BatchScanExec =>
-      scan.copy(
-        spjParams = scan.spjParams.copy(
-          commonPartitionValues = Some(values),
-          joinKeyPositions = joinKeyPositions,
-          reducers = reducers,
-          applyPartialClustering = applyPartialClustering,
-          replicatePartitions = replicatePartitions
-        )
-      )
-    case node =>
-      node.mapChildren(child => populateCommonPartitionInfo(
-        child, values, joinKeyPositions, reducers, applyPartialClustering, 
replicatePartitions))
+      isMatchingSide: Boolean): SpjPushFlags = {
+    val syncCommonValuesToBothSides = joinKeyPositions.isEmpty
+    if (joinKeyPositions.isDefined && !syncCommonValuesToBothSides) {
+      SpjPushFlags(pushJoinKeyPositions = isMatchingSide, pushCommonValues = 
false)
+    } else {
+      SpjPushFlags(
+        pushJoinKeyPositions = isMatchingSide || syncCommonValuesToBothSides,
+        pushCommonValues = isMatchingSide || syncCommonValuesToBothSides)
+    }
   }
 
+  /**
+   * Push SPJ parameters down to the scan in each child. When a child is 
itself a join, push to
+   * both inner sides so partition counts stay aligned, but only compose 
join-key positions on
+   * the side that feeds this join. When join keys are a subset of partition 
keys, keep the inner
+   * join's common partition values on both sides and only push join-key 
positions to the
+   * matching side.
+   */
+  private def populateSpjParamsOnSide(
+      plan: SparkPlan,
+      fromLeft: Boolean,
+      commonPartitionValues: Option[Seq[(InternalRow, Int)]],
+      joinKeyPositions: Option[Seq[Int]],
+      reducers: Option[Seq[Option[Reducer[_, _]]]] = None,
+      applyPartialClustering: Boolean = false,
+      replicatePartitions: Boolean = false): SparkPlan = plan match {
+    case smj: SortMergeJoinExec =>
+      // Decide per side whether to push join-key positions, common values, or 
retain inner
+      // SPJ metadata already pushed by a nested join below.
+      val leftFlags = nestedSpjPushFlags(joinKeyPositions, isMatchingSide = 
fromLeft)
+      val rightFlags = nestedSpjPushFlags(joinKeyPositions, isMatchingSide = 
!fromLeft)
+      smj.copy(
+        // Pass join-key positions only to the side that feeds this join.
+        left = populateSpjParams(
+          smj.left, commonPartitionValues, if (fromLeft) joinKeyPositions else 
None,
+          reducers, applyPartialClustering, replicatePartitions, leftFlags),
+        right = populateSpjParams(
+          smj.right, commonPartitionValues, if (!fromLeft) joinKeyPositions 
else None,
+          reducers, applyPartialClustering, replicatePartitions, rightFlags))
+    case shj: ShuffledHashJoinExec =>
+      val leftFlags = nestedSpjPushFlags(joinKeyPositions, isMatchingSide = 
fromLeft)
+      val rightFlags = nestedSpjPushFlags(joinKeyPositions, isMatchingSide = 
!fromLeft)
+      shj.copy(
+        // Pass join-key positions only to the side that feeds this join.
+        left = populateSpjParams(
+          shj.left, commonPartitionValues, if (fromLeft) joinKeyPositions else 
None,
+          reducers, applyPartialClustering, replicatePartitions, leftFlags),
+        right = populateSpjParams(
+          shj.right, commonPartitionValues, if (!fromLeft) joinKeyPositions 
else None,
+          reducers, applyPartialClustering, replicatePartitions, rightFlags))
+    case other =>
+      // Child is a scan or unary operator; push directly without side 
selection.
+      populateSpjParams(
+        other, commonPartitionValues, joinKeyPositions, reducers,
+        applyPartialClustering, replicatePartitions)
+  }
 
-  private def populateJoinKeyPositions(
+  // Populate the common partition information down to the scan nodes.
+  private def populateSpjParams(
       plan: SparkPlan,
-      joinKeyPositions: Option[Seq[Int]]): SparkPlan = plan match {
+      commonPartitionValues: Option[Seq[(InternalRow, Int)]],
+      joinKeyPositions: Option[Seq[Int]],
+      reducers: Option[Seq[Option[Reducer[_, _]]]] = None,
+      applyPartialClustering: Boolean = false,
+      replicatePartitions: Boolean = false,
+      flags: SpjPushFlags = SpjPushFlags()): SparkPlan = plan match {
     case scan: BatchScanExec =>
+      val existing = scan.spjParams
       scan.copy(
-        spjParams = scan.spjParams.copy(
-          joinKeyPositions = joinKeyPositions
+        spjParams = existing.copy(
+          // Overwrite common partition values only when this push targets 
this side.
+          commonPartitionValues = commonPartitionValues match {
+            case Some(values) if flags.pushCommonValues => Some(values)
+            case _ => existing.commonPartitionValues
+          },
+          // Compose with positions already pushed by an inner join instead of 
replacing them.
+          joinKeyPositions = if (flags.pushJoinKeyPositions) {
+            composeJoinKeyPositions(existing.joinKeyPositions, 
joinKeyPositions)
+          } else {
+            existing.joinKeyPositions
+          },
+          // Keep inner reducers when the outer join does not push new ones.
+          reducers = reducers.orElse(existing.reducers),
+          applyPartialClustering = commonPartitionValues match {
+            case Some(_) if flags.pushCommonValues => applyPartialClustering
+            case _ => existing.applyPartialClustering
+          },
+          replicatePartitions = commonPartitionValues match {
+            case Some(_) if flags.pushCommonValues => replicatePartitions
+            case _ => existing.replicatePartitions
+          }
         )
       )
     case node =>
-      node.mapChildren(child => populateJoinKeyPositions(
-        child, joinKeyPositions))
+      // Recurse through filters, projects, and other unary nodes above the 
scan.

Review Comment:
   Addressed



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/KeyGroupedPartitionedScan.scala:
##########
@@ -154,7 +196,9 @@ trait KeyGroupedPartitionedScan[T] {
 
       // Now fill missing partition keys with empty partitions
       val partitionMapping = nestGroupedPartitions.toMap
-      spjParams.commonPartitionValues.get.flatMap {
+      val commonPartValues = projectCommonPartitionValuesIfNeeded(

Review Comment:
   Addressed



##########
sql/core/src/test/scala/org/apache/spark/sql/connector/KeyGroupedPartitioningSuite.scala:
##########
@@ -2980,6 +2980,238 @@ class KeyGroupedPartitioningSuite extends 
DistributionAndOrderingSuiteBase {
     }
   }
 
+  test("SPARK-60232: nested SPJ preserves inner join key positions") {
+    val cols = Array(
+      Column.create("store_id", IntegerType),
+      Column.create("dept_id", IntegerType),
+      Column.create("data", StringType))
+    // t4 is partitioned by (store_id, dept_id) and joins on dept_id only. The 
inner join
+    // pushes joinKeyPositions [1] into the t4 scan. The outer join must not 
overwrite that
+    // with [0] against the already projected dept_id, or t4 is grouped by 
store_id and
+    // the three-way join returns no rows.
+    createTable("t4", cols, Array(identity("store_id"), identity("dept_id")))
+    sql("INSERT INTO testcat.ns.t4 VALUES (1, 2, 'a'), (2, 3, 'b'), (3, 1, 
'c')")
+
+    createTable("t5", cols, Array(identity("dept_id")))
+    sql("INSERT INTO testcat.ns.t5 VALUES (0, 1, 'x1'), (0, 2, 'x2'), (0, 3, 
'x3')")
+
+    createTable("t6", cols, Array(identity("dept_id")))
+    sql("INSERT INTO testcat.ns.t6 VALUES (0, 1, 'x1'), (0, 2, 'x2'), (0, 3, 
'x3')")
+
+    Seq(true, false).foreach { aqe =>
+      withSQLConf(
+        SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> aqe.toString,
+        SQLConf.V2_BUCKETING_ALLOW_JOIN_KEYS_SUBSET_OF_PARTITION_KEYS.key -> 
"true",
+        SQLConf.REQUIRE_ALL_CLUSTER_KEYS_FOR_CO_PARTITION.key -> "false") {
+        val df = sql(
+          """
+            |SELECT /*+ MERGE(t4, t5, t6) */
+            |  t4.store_id, t4.dept_id, t4.data, t5.data, t6.data
+            |FROM testcat.ns.t4
+            |JOIN testcat.ns.t5 ON t4.dept_id = t5.dept_id
+            |JOIN testcat.ns.t6 ON t4.dept_id = t6.dept_id
+            |ORDER BY t4.store_id, t4.dept_id
+            |""".stripMargin)
+
+        val plan = df.queryExecution.executedPlan
+        assert(collectShuffles(plan).isEmpty, "SPJ should be triggered for 
both joins")
+        val t4Scan = 
collectScans(plan).find(_.spjParams.joinKeyPositions.isDefined).get

Review Comment:
   Addressed



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