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


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -892,22 +892,20 @@ 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

Review Comment:
   **Finding 4.** The general statement is right — a child's 
`outputPartitioning` need not be expressed in its own output attributes — but 
the parenthetical, and with it the PR title and the JIRA summary, names the 
wrong cause. I probed the union's children on base (`5460c109699`) for exactly 
the query the new test uses:
   
   ```
   child0 = Project
     child0.out(0):   id exprId=9 nullable=true 
qualifier=List(__auto_generated_subquery_name)
     child0.partAttr: id exprId=9 nullable=true qualifier=List(t1)
   PROBE child0.out(0) == child0.partAttr : false
   union.partitioning = UnknownPartitioning(0)
   ```
   
   Both sides are `nullable=true`. `AttributeReference.equals` 
(`namedExpressions.scala:301`) also compares `qualifier`, and that is the bit 
that differs: the shuffle's `HashPartitioning` holds `id#9` as resolved inside 
the `t1` view, while the `Project` above the `Filter` holds `id#9` as resolved 
against `__auto_generated_subquery_name`. `FilterExec.output`'s 
`outputWithNullability` narrowing (`:267`) does happen, but it never reaches 
child 0's `output` — the `Project` on top re-exposes the outer, still-nullable 
attribute.
   
   That makes this the direct continuation of SPARK-53550 (`8edc7685b97`, 
`c0acf45023f`), which fixed the same qualifier/metadata sensitivity on the 
*tail* children's map keys; the first child's own partitioning was the one 
remaining hop. Worth saying that in the description.
   
   Suggested rewording:
   
   ```scala
       // `outputPartitioning` may reference attributes that differ 
cosmetically from the ones in
       // the child's own `output`: a Filter passes its child's partitioning 
through verbatim while
       // rewriting its `output`, and a partitioning built inside a view or 
subquery carries that
       // relation's qualifier. `AttributeReference.equals` compares name, 
nullability, metadata and
       // qualifier, so the un-remapped first child compared unequal to every 
remapped sibling.
       // `AttributeMap` is keyed by `ExprId`, so remapping normalizes all of 
those.
   ```
   
   For the title, either something cause-neutral ("UnionExec outputPartitioning 
should compare children in the union's attribute space") or keep the 
nullability framing and add a test whose plan isolates it — I tried three 
shapes and the qualifier was the discriminator in every one, so I am not 
convinced a nullability-only plan is reachable here.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameSetOperationsSuite.scala:
##########
@@ -1589,6 +1589,58 @@ 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 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 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],

Review Comment:
   **Finding 5.** `isInstanceOf[HashPartitioning]` plus the shuffle counts 
don't cover the other half of the diff — the deletion of `toUnionOutput`, whose 
job was to express the chosen partitioning in the union's *own* output 
attributes. Everything downstream matches by `semanticEquals`, and 
`AttributeReference.canonicalized` keeps only `exprId` and `dataType` 
(`namedExpressions.scala:326`), so a partitioning that leaked a child attribute 
would still satisfy the group-by's `ClusteredDistribution` and 
`groupedShuffles` would still be 2.
   
   Here the two candidates are distinguishable, so the assertion is cheap and 
actually discriminating — measured on this head the union reports `id#9` with 
`qualifier=[__auto_generated_subquery_name]` (its own output attribute) while 
child 0's partitioning attribute carries `qualifier=[t1]`:
   
   ```scala
   val hashPartitioning = 
unionExec.head.outputPartitioning.asInstanceOf[HashPartitioning]
   assert(hashPartitioning.expressions == Seq(unionExec.head.output.head))
   ```
   



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