peter-toth commented on code in PR #58044:
URL: https://github.com/apache/spark/pull/58044#discussion_r3819934258
##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameSetOperationsSuite.scala:
##########
@@ -1589,6 +1590,141 @@ class DataFrameSetOperationsSuite extends
SharedSparkSession with AdaptiveSparkP
}
}
+ test("SPARK-58819: union outputPartitioning compares children in the union's
attribute space") {
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ withTempView("t1", "t2") {
+ // `DISTRIBUTE BY id` resolves its key with the `t1` qualifier inside
the subquery, but
+ // the outer `Project` exposes `id` with the subquery qualifier, so
child 0's partitioning
+ // differs from its output in qualifier, not nullability. The branches
overlap on `id = 1`,
Review Comment:
**Finding 8.** The nullability test pins its discriminator four ways — same
`exprId`, same `qualifier`, `nullable` differs, `withNullability` round-trips —
so a change that shifts the cause fails loudly there. This test states its
discriminator only in prose, and it is the more fragile of the two: the drift
comes from the auto-generated derived-table alias re-qualifying `id`, i.e. from
name resolution, not from an operator's own contract. If that ever stops
happening, child 0's output attribute and its partitioning attribute become
`==`, base propagates `hashpartitioning(id#9, 5)` on its own, and this test
goes green on unfixed code with nothing to flag it.
Same shape as the sibling test:
```scala
val child0 = unionExec.head.children.head
val outputId = child0.output.head.asInstanceOf[AttributeReference]
val partitioningId =
child0.outputPartitioning.asInstanceOf[HashPartitioning].expressions.head
.asInstanceOf[AttributeReference]
assert(outputId.exprId == partitioningId.exprId,
s"expected the same column: $outputId vs $partitioningId")
assert(outputId.nullable == partitioningId.nullable,
s"nullability must match (qualifier is the sole difference): $outputId vs
$partitioningId")
assert(outputId.qualifier != partitioningId.qualifier,
s"expected a qualifier difference: $outputId vs $partitioningId")
```
Measured on this head: `out qual=List(__auto_generated_subquery_name)` vs
`part qual=List(t1)`, `nullable=true` on both sides.
##########
sql/core/src/test/scala/org/apache/spark/sql/DataFrameSetOperationsSuite.scala:
##########
@@ -1589,6 +1590,141 @@ class DataFrameSetOperationsSuite extends
SharedSparkSession with AdaptiveSparkP
}
}
+ test("SPARK-58819: union outputPartitioning compares children in the union's
attribute space") {
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+ withTempView("t1", "t2") {
+ // `DISTRIBUTE BY id` resolves its key with the `t1` qualifier inside
the subquery, but
+ // the outer `Project` exposes `id` with the subquery qualifier, so
child 0's partitioning
+ // differs from its output in qualifier, not nullability. The branches
overlap on `id = 1`,
+ // so a shared key's grouping 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)
+
+ // Child 0's `HashPartitioning` references `id` with the `t1`
qualifier while its output
+ // `id` carries the subquery qualifier; remapping both to the union's
output attributes
+ // still propagates the hash partitioning despite the qualifier
difference. The propagated
+ // partitioning must be expressed in the union's own output attribute
(the subquery
+ // qualifier), not child 0's `[t1]` attribute, since `toUnionOutput`
was removed.
+
assert(unionExec.head.outputPartitioning.isInstanceOf[HashPartitioning],
+ s"expected a HashPartitioning pass-through but got
${unionExec.head.outputPartitioning}")
+ val hashPartitioning =
+ unionExec.head.outputPartitioning.asInstanceOf[HashPartitioning]
+ assert(hashPartitioning.expressions == Seq(unionExec.head.output.head))
+
+ // 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 freshly-planned path is the oracle for both the raw
union rows and the
+ // grouped result.
+ 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)
+ }
+ }
+ }
+
+ test("SPARK-58819: union outputPartitioning ignores partition key
nullability") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "2",
+ // `PushDownPredicates` would otherwise push `IsNotNull` below the
shuffle, turning child
+ // 0 into a `ShuffleExchangeExec` whose partitioning and output carry
the same nullability.
+ // Disabling it keeps `FilterExec` directly above the shuffle, so its
output (nullability
+ // narrowed) differs from its passed-through partitioning only in
nullability.
+ SQLConf.OPTIMIZER_EXCLUDED_RULES.key ->
+ "org.apache.spark.sql.catalyst.optimizer.PushDownPredicates") {
Review Comment:
**Finding 7.** The comment is right that `PushPredicateThroughNonJoin`
relocates `IsNotNull` below the shuffle *for the shape written here*, but the
drift itself does not need the rule disabled — `canPushThrough`
(`Optimizer.scala:2393`) has no `Sample` case, so a filter above a `Sample`
stays put with the full optimizer running.
Measured, dropping the excluded-rules entry and inserting one `.sample(...)`:
```scala
val df1 = Seq((Option(1), 10L), (Option(2), 20L)).toDF("id", "v")
.repartition($"id").sample(withReplacement = false, fraction = 1.0, seed =
42)
.filter($"id".isNotNull)
```
- head: `child0 out(null=false, qual=List()) part(null=true, qual=List())
sameExprId=true onlyNullabilityDiffers=true`,
`union.outputPartitioning=hashpartitioning(id#9, 2)`, 2 shuffles, rows `[1,2]
[2,1] [3,1]`.
- base (`5460c109699`): same drift, `UnknownPartitioning(0)`, 3 shuffles.
`fraction = 1.0` passes every row, so the counts and `checkAnswer` stay
deterministic. That buys two things: the test stops asserting on a plan shape
the optimizer would never hand you, and the "not reachable through the normal
SQL path" note in the PR thread can go — the shape is reachable, it just needs
a node `canPushThrough` doesn't cover instead of a config.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala:
##########
@@ -892,22 +892,23 @@ 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 attributes that differ from its own
`output` in any
+ // field `AttributeReference.equals` compares (name, nullability,
metadata, qualifier): a
Review Comment:
**Finding 9.** `AttributeReference.equals` (`namedExpressions.scala:301`)
compares `name`, `dataType`, `nullable`, `metadata`, `exprId` and `qualifier`.
The parenthetical drops `dataType`, which is the one entry that isn't cosmetic:
name, nullability, metadata and qualifier can all differ without changing what
the partitioning means, because `HashPartitioning.partitionIdExpression` hashes
values (`Pmod(CollationAwareMurmur3Hash(expressions), n)`,
`partitioning.scala:328`), while remapping an `int` attribute onto a `bigint`
one would turn a true co-location claim into a false one. The remap is safe
because two attributes sharing an `exprId` agree on `dataType` in a resolved
plan — that is the assumption worth writing down rather than leaving off the
list.
```scala
// `outputPartitioning` may reference attributes that differ from its
own `output` in any
// field `AttributeReference.equals` compares other than `dataType`,
which two attributes
// sharing an `ExprId` agree on (so name, nullability, metadata,
qualifier): a Filter narrows
// nullability via `IsNotNull` while passing its child's partitioning
through, and a
// partitioning built inside a view or subquery carries that relation's
qualifier.
```
--
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]