LuciferYang commented on code in PR #58419:
URL: https://github.com/apache/spark/pull/58419#discussion_r4038308106


##########
sql/core/src/test/scala/org/apache/spark/sql/execution/UnionCodegenSuite.scala:
##########
@@ -628,6 +694,346 @@ class UnionCodegenSuite extends SharedSparkSession {
     }
   }
 
+  test("SPARK-59122: a fused union keeps numOutputRows and reports 
UnknownPartitioning") {
+    // The children's partitioning is not stable while the plan is being 
prepared:
+    // `InMemoryTableScanExec.cachedPlan` unwraps the inner 
`AdaptiveSparkPlanExec` only once
+    // `isFinalPlan` is true, and reports `UnknownPartitioning(0)` until then, 
so the union looks
+    // plain and is fused. The projection is what makes that reachable: 
`supportsColumnar` is
+    // `children.forall`, so one row-based `ProjectExec` over the columnar 
scan is enough to make
+    // it false, and without one `supportCodegenFailureReason` reports 
`columnar` and nothing
+    // fuses. `SELECT *` or a plain alias collapses the projection away and 
does not reproduce
+    // this. Once the cache stages finalise, both children report the same 
concrete layout, and
+    // re-deriving the decision at that point left `metrics` empty while 
`doProduce` asked
+    // `metricTerm` for `numOutputRows`.
+    //
+    // Both halves of the decision are asserted here. Registering the metric 
unconditionally would
+    // fix the crash and leave the other half broken: a fused union 
concatenates its children's
+    // partitions, so claiming their partitioning would let a parent satisfy a 
clustered
+    // distribution from an RDD that does not have it.
+    withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+        SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
+        SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+      withTempView("v") {
+        cacheAggregateView("v")
+        val df = spark.sql("SELECT k, abs(s) AS s FROM v UNION ALL SELECT k, s 
FROM v")
+        // Execute this DataFrame rather than a count over it: the plan being 
inspected has to be
+        // the one that ran, and an AQE plan that never ran has no final plan 
to inspect.
+        assert(df.collect().length == 20)
+        val fused = fusedUnions(df)
+        assert(fused.nonEmpty,
+          "this shape must actually fuse, or the test is not exercising the 
defect")
+        fused.foreach { u =>
+          // Part of the premise, not the whole of it: the children expose a 
concrete layout by now,
+          // so this node is not reporting `UnknownPartitioning` merely for 
want of anything to
+          // derive from. `rawPartitioning` also falls back when the 
children's remapped
+          // partitionings do not compare equal, and that cannot be asserted 
here: each side carries
+          // its own exprIds, and they line up only after the private 
`prepareOutputPartitioning`.
+          val childPartitionings = u.children.map(_.outputPartitioning)
+          
assert(childPartitionings.forall(_.isInstanceOf[HashPartitioningLike]),
+            s"premise: got $childPartitionings")
+          assert(childPartitionings.map(_.numPartitions).distinct.size == 1,
+            s"premise: got $childPartitionings")
+          assert(u.metrics.contains("numOutputRows"),
+            "a fused union must register the metric its generated code 
increments")
+          assert(u.outputPartitioning.isInstanceOf[UnknownPartitioning],
+            s"a fused union must not claim a concrete partitioning, got 
${u.outputPartitioning}")
+        }
+      }
+    }
+  }
+
+  test("SPARK-59122: a partitioning-aware union keeps its layout when the conf 
changes between " +
+    "planning and execution") {
+    // `spark.sql.unionOutputPartitioning` is read once during preparation, 
ahead of
+    // `EnsureRequirements`, not on every `outputPartitioning` call, so a plan 
executes by the
+    // partitioning it was planned against. Reading it per call let the parent 
aggregate lose its
+    // exchange at planning and get a plain concatenation at execution, 
reporting each group twice.
+    // The `checkAnswer` below stays outside the block that planned the 
DataFrame on purpose: the
+    // plan is forced inside that block and `executedPlan` is memoized, so the 
two phases see
+    // different confs. Asserting inside it, or dropping the second 
`withSQLConf`, makes the test
+    // pass without testing this.
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val left = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS k")
+      val right = spark.range(20, 40, 1, 2).selectExpr("id % 5 AS k")
+
+      val planned = withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> 
"true") {
+        val df = left.repartition(4, col("k"))
+          .union(right.repartition(4, col("k"))).groupBy("k").count()
+        val plan = df.queryExecution.executedPlan
+        val unions = plan.collect { case u: UnionExec => u }
+        assert(unions.size == 1)
+        // Asserted through the exchanges rather than through `isPlainUnion`, 
so that the check
+        // does not depend on how the decision is stored: only the two 
repartitions may shuffle, so
+        // the aggregate's exchange was elided, which it could only be if the 
union reported a
+        // concrete partitioning.
+        val shuffles = plan.collect { case s: ShuffleExchangeExec => s }
+        assert(shuffles.size == 2)
+        assert(shuffles.forall(_.shuffleOrigin == REPARTITION_BY_NUM),
+          s"expected only the two repartitions, got 
${shuffles.map(_.shuffleOrigin)}")
+        df
+      }
+
+      withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") {
+        // Each side contributes four ids per `k`, so the answer is fixed. 
Comparing against the
+        // same query run with the conf off would also pass if both paths 
regressed to ten rows.
+        checkAnswer(planned, (0L until 5L).map(k => Row(k, 8L)))
+      }
+    }
+  }
+
+  test("SPARK-59122: a fused union keeps numOutputRows when the codegen conf 
changes between " +
+    "planning and execution") {
+    // `supportCodegenFailureReason` used to read 
`WHOLESTAGE_UNION_CODEGEN_ENABLED` live, and the
+    // copy that `insertInputAdapter` puts inside the codegen shell evaluated 
it for the first time
+    // at execution. Planned with the conf on the union is fused, so the 
generated code increments
+    // `numOutputRows`; if the copy re-derives the reason with the conf off, 
`metrics` comes back
+    // empty and `doProduce` throws `key not found: numOutputRows`.
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val planned = withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key 
-> "true") {
+        // Each child is an exchange, which is not `CodegenSupport`, so 
`insertInputAdapter` wraps
+        // it and the union is rebuilt through `withNewChildren`, the copy 
this test needs. Children
+        // that do support codegen can still produce one, since 
`insertInputAdapter` recurses into
+        // their descendants; exchanges just make it certain.
+        val df = rangeDF(100).repartition(2).union(rangeDF(100).repartition(2))
+        // `fusedUnions` requires the union to be the stage root, which is 
what this test needs: it
+        // reaches for that node itself below.
+        assert(fusedUnions(df).size == 1, "this shape must fuse, or the test 
exercises nothing")
+        df
+      }
+      withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
+        assert(planned.collect().length == 200)
+        // The row count alone does not discriminate, since the shell was 
installed at planning and
+        // keeps emitting; registering `numOutputRows` unconditionally and 
reading the conf per call
+        // passes it. The `supportCodegen` assertion below is what fails there.
+        val copy = fusedUnions(planned)
+        assert(copy.size == 1)
+        // The copy this test needs: `insertInputAdapter` wrapped both 
children, so the shell holds
+        // a copy rather than the instance the gate answered on. This copy's 
reason is first forced
+        // by the `SparkPlanInfo` that `collect()` above builds, with the conf 
already off, so what
+        // it answers can only come from the stamp.
+        assert(copy.head.children.forall(_.isInstanceOf[InputAdapter]))
+        assert(copy.head.supportCodegen,
+          "the copy in the shell must keep the decision it was planned with")
+      }
+    }
+  }
+
+  test("SPARK-59122: a fused union keeps numOutputRows when the child cap 
drops between " +
+    "planning and execution") {
+    // `WHOLESTAGE_UNION_MAX_CHILDREN` is on the same snapshot as the enable 
flag, so the same shape
+    // has to hold for it: prepared under a cap this union meets, it stays 
fused even if the cap is
+    // lowered under it. Reading the cap live would give the shell's copy 
`max-children-exceeded`,
+    // empty `metrics`, and `doProduce` failing at `metricTerm`. Three 
children against a cap of
+    // two, since the conf refuses anything below two.
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val planned = withSQLConf(
+          SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
+          SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "3") {
+        // Exchange children again, so the shell really holds a 
`withNewChildren` copy.
+        val df = rangeDF(100).repartition(2)
+          .union(rangeDF(100).repartition(2))
+          .union(rangeDF(100).repartition(2))
+        val fused = fusedUnions(df)
+        assert(fused.size == 1 && fused.head.children.size == 3,
+          s"this shape must fuse as one three-child union, got 
${fused.map(_.children.size)}")
+        df
+      }
+      withSQLConf(SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "2") {
+        assert(planned.collect().length == 300)
+        val copy = fusedUnions(planned)
+        assert(copy.size == 1)
+        assert(copy.head.children.forall(_.isInstanceOf[InputAdapter]))
+        assert(copy.head.supportCodegen,
+          "the copy in the shell must keep the cap it was planned with")
+        // Not `metrics.contains`, which `collect()` above already proves: an 
empty `metrics` would
+        // have thrown at `metricTerm`. The count is what says the fused code 
ran and counted.
+        assert(copy.head.metrics("numOutputRows").value == 300)
+      }
+    }
+  }
+
+  test("SPARK-59122: the codegen gate re-derives when a rule replaces the 
children") {
+    // The gate's children-dependent terms must not outlive the children they 
were taken from.
+    // `SQLExecution` builds a `SparkPlanInfo` before execution, which reads 
`metrics` on every
+    // node; a decision carried from there onto a node whose children a rule 
then replaced would
+    // fuse a topology that the gate rejects.
+    withSQLConf(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+      SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true") {
+      val df = rangeDF(100).union(rangeDF(100))
+      val unions = fusedUnions(df)
+      assert(unions.size == 1, "this shape must fuse, or the test exercises 
nothing")
+      val union = unions.head
+      // What the plan update does, and what decides the gate for this 
instance.
+      assert(union.metrics.contains("numOutputRows"))
+      assert(union.supportCodegen)
+
+      // A nested union is one of the topologies the gate rejects, and 
`withNewChildren` is the path
+      // a rule takes when it rewrites children in place. A rule returning an 
arbitrary replacement
+      // node is a different path, and one `copyTagsFrom` need not carry the 
tags along.
+      val nested = UnionExec(Seq(union.children.head, union.children.head))
+      val rebuilt = union.withNewChildren(Seq(nested, 
union.children.last)).asInstanceOf[UnionExec]
+      assert(!rebuilt.supportCodegen, "the rebuilt union must answer against 
its own children")
+      // Implied by the line above as the code stands, and kept as the pin on 
that: registering the
+      // metric unconditionally would leave the line above green, and only 
this one would fail.
+      assert(rebuilt.metrics.isEmpty)
+    }
+  }
+
+  test("SPARK-59122: reading the unprepared plan does not decide the prepared 
one") {
+    // `QueryExecution.executedPlan` is 
`prepareForExecution(sparkPlan.clone())`, and `clone` ends
+    // in `makeCopy`, which calls `copyTagsFrom`. A decision written while 
answering a read on
+    // `sparkPlan` would therefore ride into the prepared plan. Here the two 
answers differ: each
+    // child is an aggregate whose exchange `EnsureRequirements` has yet to 
insert, so the union
+    // passes nothing through before preparation and both children's 
`HashPartitioning` after it.
+    // Reads before `StampUnionDecisions` answer without writing, so only 
preparation decides.
+    withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+        SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+      val left = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS 
k").groupBy("k").count()
+      val right = spark.range(20, 40, 1, 2).selectExpr("id % 5 AS 
k").groupBy("k").count()
+      val df = left.union(right)
+
+      val unprepared = df.queryExecution.sparkPlan.collect { case u: UnionExec 
=> u }
+      assert(unprepared.size == 1)
+      
assert(unprepared.head.outputPartitioning.isInstanceOf[UnknownPartitioning],
+        "the aggregates have no exchange under them yet, so there is nothing 
to pass through")
+
+      val prepared = df.queryExecution.executedPlan.collect { case u: 
UnionExec => u }
+      assert(prepared.size == 1)
+      
assert(!prepared.head.outputPartitioning.isInstanceOf[UnknownPartitioning],
+        "the read above must not have decided for the prepared plan, got " +
+          s"${prepared.head.outputPartitioning}")
+      checkAnswer(df, (0L until 5L).flatMap(k => Seq(Row(k, 4L), Row(k, 4L))))
+    }
+  }
+
+  test("SPARK-59122: a partitioning-aware union follows its children's 
coalesced partition count") {
+    // Only the decision is stamped, never the `Partitioning`. AQE coalescing 
changes the children's
+    // `numPartitions` after the stamp, and `unionRDDs` hands whatever it 
reports to
+    // `SQLPartitioningAwareUnionRDD`, which builds exactly that many 
partitions from each child: a
+    // count frozen at stamping time asks for partitions the coalesced 
children no longer have.
+    withSQLConf(
+        SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+        SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "true",
+        SQLConf.SHUFFLE_PARTITIONS.key -> "20",
+        SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+      val left = spark.range(0, 100, 1, 4).selectExpr("id % 10 AS 
k").groupBy("k").count()
+      val right = spark.range(100, 200, 1, 4).selectExpr("id % 10 AS 
k").groupBy("k").count()
+      val df = left.union(right).groupBy("k").agg(sum("count").as("c"))
+      checkAnswer(df, (0L until 10L).map(k => Row(k, 20L)))
+
+      val unions = collect(df.queryExecution.executedPlan) { case u: UnionExec 
=> u }
+      assert(unions.size == 1)
+      val children = 
unions.head.children.map(_.outputPartitioning.numPartitions)
+      assert(children.distinct.size == 1 && children.head < 20,
+        s"the children must have been coalesced as one group, got $children")
+      assert(unions.head.outputPartitioning.numPartitions == children.head,
+        "the union must report what its children report now, got " +
+          s"${unions.head.outputPartitioning}")
+    }
+  }
+
+  test("SPARK-59122: a later stamping pass fills in a fresh union and keeps 
stamped ones") {
+    // `StampUnionDecisions` is listed again after the phases that can add a 
`UnionExec`, so one an
+    // injected columnar or query-stage rule created does not answer from 
whatever the conf says
+    // wherever it is first asked. A later pass must also not move a decision 
already taken, which
+    // is the second half here. The rule is driven directly, since what this 
case is about is its
+    // contract; that the pipelines still list it after each phase that can 
add a union is pinned
+    // from the outside by the extension-driven cases in 
`SparkSessionExtensionSuite`, which need a
+    // session of their own.
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      // Pins the property the standard pipeline has to keep: a stamping pass 
runs after
+      // `EnsureRequirements`, so the decision is taken from the plan the 
exchanges were placed in.
+      // A count would break on a sixth legitimate pass and say nothing about 
the order. The AQE
+      // lists are private to `AdaptiveSparkPlanExec`, so their first pass has 
no counterpart here.
+      val rules = QueryExecution.preparations(spark, subquery = false)
+      val firstStamp = rules.indexWhere(_ eq StampUnionDecisions)
+      val ensureRequirements = 
rules.indexWhere(_.isInstanceOf[EnsureRequirements])
+      val columnarRules =
+        
rules.indexWhere(_.isInstanceOf[ApplyColumnarRulesAndInsertTransitions])
+      assert(ensureRequirements >= 0 && firstStamp > ensureRequirements &&

Review Comment:
   Done: `firstStamp == ensureRequirements + 1`, with the later-stamp check 
left where it was. The comment above the assertion also claimed the AQE lists 
were out of reach from this suite, which stopped being true when 
`queryStagePreparationRules` became package-visible, so it now points at the 
`AdaptiveQueryExecSuite` case that asserts the same adjacency for the list AQE 
builds.



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