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


##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameSetOperationsSuite.scala:
##########
@@ -1589,6 +1589,57 @@ class DataFrameSetOperationsSuite extends 
SharedSparkSession with AdaptiveSparkP
     }
   }
 
+  test("SPARK-58819: union outputPartitioning ignores partition key 
nullability") {
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      withTempView("t1", "t2") {
+        // `id` is nullable (Option[Int]) so that `IsNotNull` in the Filter 
actually adjusts it.
+        // The two branches overlap on `id = 1`, so a shared key's grouping 
result depends on the
+        // union's co-location claim being honored (guarded by 
`checkAnswer(grouped, ...)` below).
+        Seq((Option(1), 10), (Option(2), 20)).toDF("id", 
"v").createOrReplaceTempView("t1")
+        Seq((Option(1), 30), (Option(3), 40)).toDF("id", 
"v").createOrReplaceTempView("t2")
+
+        val union = spark.sql(
+          """
+            |SELECT id, v FROM (SELECT id, v FROM t1 DISTRIBUTE BY id) WHERE 
id IS NOT NULL
+            |UNION ALL
+            |SELECT id, sum(v) AS v FROM t2 GROUP BY id
+            |""".stripMargin)
+        val unionExec = union.queryExecution.executedPlan.collect { case u: 
UnionExec => u }
+        assert(unionExec.size == 1)
+
+        // `IsNotNull` in the Filter adjusts the nullability of the partition 
key, but nullability
+        // does not affect the hash, so the union should still propagate the 
hash partitioning.
+        
assert(unionExec.head.outputPartitioning.isInstanceOf[HashPartitioning],
+          s"expected a HashPartitioning pass-through but got 
${unionExec.head.outputPartitioning}")
+
+        // The two branches contribute one shuffle each (DISTRIBUTE BY and 
GROUP BY). The propagated
+        // HashPartitioning lets the downstream group-by reuse them instead of 
adding a third.
+        val unionShuffles = union.queryExecution.executedPlan.collect {
+          case s: ShuffleExchangeExec => s
+        }.size
+        val grouped = union.groupBy($"id").count()
+        val groupedShuffles = grouped.queryExecution.executedPlan.collect {
+          case s: ShuffleExchangeExec => s
+        }.size
+        assert(unionShuffles == 2, s"union should have 2 shuffles but got 
$unionShuffles")
+        assert(groupedShuffles == 2,
+          s"group-by should reuse the union's partitioning (expect 2 shuffles) 
but got " +
+            s"$groupedShuffles\n${grouped.queryExecution.executedPlan}")
+
+        // `UNION_OUTPUT_PARTITIONING=false` drops the pass-through so the 
group-by adds its own
+        // shuffle; that path is the oracle for both the raw union rows and 
the grouped result.
+        val correctResult = withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key 
-> "false") {

Review Comment:
   **Finding 1.** Neither of these blocks produces a baseline. `union` and 
`grouped` were already planned above (`union.queryExecution.executedPlan` at 
:1607, `grouped.queryExecution.executedPlan` at :1621) and `QueryExecution` 
memoizes that — `lazyExecutedPlan` is a `LazyTry` held in a `val` 
(`QueryExecution.scala:353`), and `Dataset.collect()` goes through 
`withAction(...)` on the same `QueryExecution` instance 
(`classic/Dataset.scala:1563`, `:2323`). So 
`withSQLConf(UNION_OUTPUT_PARTITIONING -> "false")` never re-plans and both 
`checkAnswer`s compare a plan against itself.
   
   That makes the co-location guard a no-op: it stays green even if 
`prepareOutputPartitioning`/`comparePartitioning` were to claim a co-location 
the union RDD can't honor, which is exactly the regression the comment says it 
catches.
   
   I measured it with a probe inside the `correctGrouped` block:
   
   ```
   PROBE stale-plan-shuffles=2 fresh-plan-shuffles=3
   ```
   
   The "oracle" runs the 2-shuffle pass-through plan; only a freshly built 
DataFrame gets the 3-shuffle baseline.
   
   The sibling test at :1545 has the right shape — it builds the DataFrame 
*inside* the conf block. Same fix here, hoisting the query text:
   
   ```scala
   val sqlText =
     """
       |SELECT id, v FROM (SELECT id, v FROM t1 DISTRIBUTE BY id) WHERE id IS 
NOT NULL
       |UNION ALL
       |SELECT id, sum(v) AS v FROM t2 GROUP BY id
       |""".stripMargin
   val union = spark.sql(sqlText)
   // ...
   val (correctResult, correctGrouped) =
     withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") {
       val baseline = spark.sql(sqlText)
       (baseline.collect(), baseline.groupBy($"id").count().collect())
     }
   checkAnswer(union, correctResult)
   checkAnswer(grouped, correctGrouped)
   ```
   
   Or skip the config flip and assert the rows directly, which is a stronger 
oracle and shorter (I ran the baseline to get these):
   
   ```scala
   checkAnswer(union, Row(1, 10L) :: Row(2, 20L) :: Row(1, 30L) :: Row(3, 40L) 
:: Nil)
   checkAnswer(grouped, Row(1, 2L) :: Row(2, 1L) :: Row(3, 1L) :: Nil)
   ```
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -928,7 +924,7 @@ case class UnionExec(children: Seq[SparkPlan]) extends 
SparkPlan with CodegenSup
       case (SinglePartition, SinglePartition) => true
       case (l: HashPartitioningLike, r: HashPartitioningLike) => l == r
       // For `KeyedPartitioning`, only the partition expressions must match 
(the other child's
-      // expressions have already been remapped to the first child's 
attributes by
+      // expressions have already been remapped to this union's output 
attributes by

Review Comment:
   **Finding 3.** The target of the remap got updated but not the subject: with 
the first child remapped too, it isn't only "the other child's" expressions. 
Suggest rewording 926-927 to:
   
   ```scala
         // For `KeyedPartitioning`, only the partition expressions must match 
(both sides'
         // expressions have already been remapped to this union's output 
attributes by
   ```
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -892,22 +892,19 @@ case class UnionExec(children: Seq[SparkPlan]) extends 
SparkPlan with CodegenSup
   }
 
   /**
-   * Returns the output partitionings of the children, with the attributes 
converted to
-   * the first child's attributes at the same position.
+   * Returns the output partitionings of the children, with the attributes 
converted to this
+   * union's output attributes at the same position.
    */
   private def prepareOutputPartitioning(): Seq[Partitioning] = {
-    // Create a map of attributes from the other children to the first child.
-    val firstAttrs = children.head.output
-    val attributesMap = children.tail.map(_.output).map { otherAttrs =>
-      AttributeMap(otherAttrs.zip(firstAttrs))
+    // Map every child's partitioning attributes to this union's output 
attributes, so all
+    // partitionings are expressed in the same attribute space before 
comparison. A child's
+    // `outputPartitioning` may reference its own input attributes (e.g. a 
Filter passes through
+    // its child's partitioning but adjusts the output nullability), so even 
the first child is
+    // remapped.
+    val attributesMap = children.map(_.output).map { childAttrs =>
+      AttributeMap(childAttrs.zip(output))

Review Comment:
   **Finding 2.** `output` is inside the closure, so it runs once per child 
instead of once. `UnionExec.output` is a plain `def` that rebuilds from 
`children.map(_.output).transpose` (`:880`) and allocates a fresh 
`AttributeReference` per column whenever a child widened the dataType, so this 
turns an O(children x columns) step into O(children^2 x columns) — on a method 
nothing memoizes and that `EnsureRequirements` plus every 
`PartitioningPreservingUnaryExecNode` above the union calls repeatedly. Base 
called `output` exactly once, in the `toUnionOutput` helper this PR removes, so 
it's a regression rather than a pre-existing cost.
   
   ```scala
       val unionOutput = output
       val attributesMap = children.map(_.output).map { childAttrs =>
         AttributeMap(childAttrs.zip(unionOutput))
       }
   ```
   



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