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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -502,6 +537,9 @@ case class EnsureRequirements(
       if (specOpt.isEmpty) return None
       specOpt.get
     }
+    if (Seq(left, right).zip(specs).exists { case (p, s) => 
isBuiltFromReducedScan(p, s) }) {

Review Comment:
   **Finding 32.** The description says `checkKeyGroupCompatible` returns 
`None` for a spec on a scan that `hasReducedScanOutsideJoin` finds, but only 
`candidateSpecs` checks it. When `OptimizeSkewedJoin` re-applies this rule, the 
inner join of a nested SPJ finds its scans directly below it, still carrying 
the reducers and values of the first pass. `isBuiltFromReducedScan` needs a 
join below the child, so it lets them through. The join then derives the 
reducer again from the transform the scan still reports, `reducePartValues` 
applies it to values it already produced, and `composeReducers` chains the two 
into `ChainedReducer(r, r)`. That is only right when `r(r(x)) == r(x)`. 
Bucket's `x % n` has that property, and the skew test uses only bucket reducers.
   
   What I ran: I registered `hours`, `days` and `years` functions in the test 
catalog that implement `ReducibleFunction`. They reduce hours to days with 
`floorDiv(h, 24)`, and days to years, which is the shape of the 
`ReducibleFunction` javadoc's days/hours example. `qh`, `qd` and `qy` are 
`PARTITIONED BY (hours|days|years(ts))` and hold the same 72 hourly rows from 
2026-01-01. With the confs of `nested SPJ survives EnsureRequirements 
re-running for a skewed join`:
   
   ```sql
   SELECT key1 AS k, count(*) AS c FROM skewData1 JOIN skewData2 ON key1 = key2 
GROUP BY key1
   UNION ALL
   SELECT /*+ MERGE(qh, qd, qy) */ qh.id AS k, 1L AS c
   FROM qh JOIN qd ON qh.ts = qd.ts JOIN qy ON qd.ts = qy.ts
   ```
   
   - With `forceOptimizeSkewedJoin=true`, the second branch returns 0 of 72 
rows.
   - With `false`, or without the skewed branch (AQE on and off), it returns 
all 72.
   - `branch-4.1` returns 0 rows in all of these.
   
   Checking it here too fixes the rows:
   
   ```suggestion
       if (Seq(left, right).zip(specs).exists { case (p, s) => 
isBuiltFromReducedScan(p, s) } ||
           Seq(left, right).exists(hasReducedScanOutsideJoin)) {
   ```
   
   With it, the query returns all 72 rows, and both suites pass. The re-run's 
inner join then shuffles too, so the skew test's query plans 6 shuffles instead 
of 5. Please update that count under the user-facing changes. A test with a 
reducer that is not idempotent would pin this, since bucket reducers cannot. 
These are the functions I registered with `catalog.createFunction` before 
creating the tables:
   
   <details><summary>Test functions</summary>
   
   ```scala
   import java.time.{Instant, LocalDate, ZoneOffset}
   import java.time.temporal.ChronoUnit
   
   import org.apache.spark.sql.catalyst.InternalRow
   import org.apache.spark.sql.catalyst.util.DateTimeUtils
   import org.apache.spark.sql.types._
   
   // hours reduce onto days with floorDiv(h, 24), and days onto years. Neither 
reducer is
   // idempotent, unlike bucket's x % n.
   object ProbeHoursFunction extends ScalarFunction[Long] with 
ReducibleFunction[Long, Long] {
     override def inputTypes(): Array[DataType] = Array(TimestampType)
     override def resultType(): DataType = LongType
     override def name(): String = "hours"
     override def canonicalName(): String = name()
     override def produceResult(input: InternalRow): Long =
       ChronoUnit.HOURS.between(Instant.EPOCH, 
DateTimeUtils.microsToInstant(input.getLong(0)))
     override def reducer(other: ReducibleFunction[_, _]): Reducer[Long, Long] =
       if (other eq ProbeDaysFunction) ProbeHoursToDays else null
   }
   
   object ProbeDaysFunction extends ScalarFunction[Long] with 
ReducibleFunction[Long, Long] {
     override def inputTypes(): Array[DataType] = Array(TimestampType)
     override def resultType(): DataType = LongType
     override def name(): String = "days"
     override def canonicalName(): String = name()
     override def produceResult(input: InternalRow): Long =
       ChronoUnit.DAYS.between(Instant.EPOCH, 
DateTimeUtils.microsToInstant(input.getLong(0)))
     override def reducer(other: ReducibleFunction[_, _]): Reducer[Long, Long] =
       if (other eq ProbeYearsFunction) ProbeDaysToYears else null
   }
   
   object ProbeYearsFunction extends ScalarFunction[Long] with 
ReducibleFunction[Long, Long] {
     override def inputTypes(): Array[DataType] = Array(TimestampType)
     override def resultType(): DataType = LongType
     override def name(): String = "years"
     override def canonicalName(): String = name()
     override def produceResult(input: InternalRow): Long = {
       val d = 
DateTimeUtils.microsToInstant(input.getLong(0)).atZone(ZoneOffset.UTC).toLocalDate
       ChronoUnit.YEARS.between(LocalDate.ofEpochDay(0), d)
     }
     override def reducer(other: ReducibleFunction[_, _]): Reducer[Long, Long] 
= null
   }
   
   case object ProbeHoursToDays extends Reducer[Long, Long] {
     override def reduce(hours: Long): Long = Math.floorDiv(hours, 24L)
   }
   
   case object ProbeDaysToYears extends Reducer[Long, Long] {
     override def reduce(days: Long): Long =
       ChronoUnit.YEARS.between(LocalDate.ofEpochDay(0), 
LocalDate.ofEpochDay(days))
   }
   
   class ProbeUnbound(fn: BoundFunction) extends UnboundFunction {
     override def bind(inputType: StructType): BoundFunction = fn
     override def description(): String = name()
     override def name(): String = fn.name()
   }
   ```
   
   </details>
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/EnsureRequirements.scala:
##########
@@ -698,42 +740,202 @@ case class EnsureRequirements(
         joinType == LeftAnti || joinType == LeftOuter
   }
 
-  // Populate the common partition information down to the scan nodes
-  private def populateCommonPartitionInfo(
-      plan: SparkPlan,
+  /**
+   * 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)],
-      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))
+      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)
+    }
 
-  private def populateJoinKeyPositions(
+  /**
+   * 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 = {
+    // A `Project` between the joins can rename the column under the 
transform, so map the spec's
+    // columns back through the aliases below `plan` before comparing them 
with the scan's. A
+    // broadcast hash join also reports the streamed side's partitioning on 
its build keys, so map
+    // those back to the streamed keys too.
+    val broadcastKeys = plan.flatMap {
+      case j: BroadcastHashJoinExec =>
+        val (buildKeys, streamedKeys) = j.buildSide match {
+          case BuildLeft => (j.leftKeys, j.rightKeys)
+          case BuildRight => (j.rightKeys, j.leftKeys)
+        }
+        buildKeys.zip(streamedKeys).collect { case (b: Attribute, s: 
Attribute) => b -> s }
+      case _ => Nil
+    }
+    val aliases = AttributeMap(plan.flatMap(_.expressions).flatMap(_.collect {
+      case a @ Alias(child: Attribute, _) => a.toAttribute -> child
+    }) ++ broadcastKeys)
+    def unalias(e: Expression): Expression = e.transformUp {
+      case a: Attribute if aliases.contains(a) => unalias(aliases(a))
+    }
+    val specExpressions = spec.partitioning.expressions.map(unalias)

Review Comment:
   **Finding 33.** The build-key map I suggested in [finding 
30](https://github.com/apache/spark/pull/59599#discussion_r4389493024) fails 
open. It takes only `(Attribute, Attribute)` key pairs, and `AttributeMap` 
keeps one streamed key per build key. A spec expression the map cannot follow 
is then trusted as if no inner join had reduced it. Three shapes still read the 
reduced `l12`'s stale `bucket(12)` layout, and each returns 8 of 24 rows with 
no shuffle, AQE on and off:
   
   - A build key equated to two streamed keys. With `JOIN lx ON lx.id = l12.id 
AND lx.id = l4.id`, the map keeps `lx.id -> l4.id`, which nothing reduced. With 
the two equalities swapped, the query returns all 24 rows.
   - A cast build key. With `lxi.id` and `l3i.id` as `INT` and the other ids 
`BIGINT`, the build key of `l12.id = lxi.id` is `cast(lxi.id as bigint)`:
   
     ```sql
     SELECT /*+ MERGE(l12, l4), BROADCAST(lxi), MERGE(l3i) */ l12.id, lxi.id, 
l3i.id
     FROM l12 JOIN l4 ON l12.id = l4.id
     JOIN lxi ON l12.id = lxi.id
     JOIN l3i ON lxi.id = l3i.id
     ```
   
   - A build key that a broadcast inside the build side also uses. `ly.id` is 
the build key of both broadcast joins. The inner one is visited last, so the 
map keeps `ly.id -> lx.id` and loses `ly.id -> l12.id`:
   
     ```sql
     SELECT /*+ MERGE(l12, l4), BROADCAST(b), MERGE(l3) */ l12.id, b.id, l3.id
     FROM l12 JOIN l4 ON l12.id = l4.id
     JOIN (SELECT /*+ BROADCAST(ly) */ lx.id AS xid, ly.id
           FROM lx JOIN ly ON lx.id = ly.id) b ON l12.id = b.id
     JOIN l3 ON b.id = l3.id
     ```
   
   The tables are those of finding 30, plus unpartitioned `lxi` and `ly` and 
`bucket(3)` `l3i`, all with ids 0 to 23. `branch-4.1` returns the same 8 rows 
for all three.
   
   Mapping more cases would leave open the next rename the map does not know. 
Reading `AliasAwareOutputExpression`, an alias of a struct field is one. 
Failing closed covers them all: trust a spec expression only when it maps to a 
scan position that no inner join reduced, and fall back to a shuffle otherwise. 
That keeps the exact match finding 22 asked for. Walking the positions first 
also lets it return before it builds the maps when nothing below carries a 
reducer:
   
   ```scala
     private def isBuiltFromReducedScan(plan: SparkPlan, spec: 
KeyGroupedShuffleSpec): Boolean = {
       // Each position of the scans that decide the layout above `plan`, with 
whether an inner join
       // reduced it. An exchange re-partitions its input, so a scan below it 
no longer decides that
       // layout. Only a nested join can hand a reduced scan's layout to a 
later join, which also keeps
       // a re-run of this rule from matching the scans directly below an 
already planned join.
       def positions(p: SparkPlan, belowJoin: Boolean): Seq[(Expression, 
Boolean)] = p match {
         case _: ShuffleExchangeLike | _: BroadcastExchangeLike => Nil
         case scan: BatchScanExec =>
           reportedLayout(scan.spjParams).toSeq.flatMap { layout =>
             val reducers = 
scan.spjParams.reducers.getOrElse(Seq.fill(layout.length)(None))
             layout.zip(reducers).map { case (e, r) => (e, belowJoin && 
r.isDefined) }
           }
         case join @ (_: SortMergeJoinExec | _: ShuffledHashJoinExec) =>
           join.children.flatMap(positions(_, belowJoin = true))
         case other => other.children.flatMap(positions(_, belowJoin))
       }
       val scanPositions = positions(plan, belowJoin = false)
       if (!scanPositions.exists(_._2)) return false
   
       // A `Project` between the joins can rename the column under the 
transform, so map the spec's
       // columns back through the aliases below `plan` before comparing them 
with the scan's. A
       // broadcast hash join also reports the streamed side's partitioning on 
its build keys, so map
       // those back to the streamed keys too.
       val broadcastKeys = plan.flatMap {
         case j: BroadcastHashJoinExec =>
           val (buildKeys, streamedKeys) = j.buildSide match {
             case BuildLeft => (j.leftKeys, j.rightKeys)
             case BuildRight => (j.rightKeys, j.leftKeys)
           }
           buildKeys.zip(streamedKeys).collect { case (b: Attribute, s: 
Attribute) => b -> s }
         case _ => Nil
       }
       val aliases = AttributeMap(plan.flatMap(_.expressions).flatMap(_.collect 
{
         case a @ Alias(child: Attribute, _) => a.toAttribute -> child
       }) ++ broadcastKeys)
       def unalias(e: Expression): Expression = e.transformUp {
         case a: Attribute if aliases.contains(a) => unalias(aliases(a))
       }
       // Trust a spec expression only when it maps to a position no inner join 
reduced. One the
       // mapping above cannot follow, e.g. a cast build key, falls back to a 
shuffle.
       spec.partitioning.expressions.map(unalias).exists { e =>
         !scanPositions.exists { case (p, reduced) => !reduced && 
p.semanticEquals(e) }
       }
     }
   ```
   
   With it, all three queries return all 24 rows with two shuffles, and both 
suites pass, including `keeps SPJ on an unreduced column with the same 
transform`. If you'd rather leave this to SPARK-59121, I'll add these shapes 
there.
   



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