This is an automated email from the ASF dual-hosted git repository.
LuciferYang pushed a commit to branch branch-4.3
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.3 by this push:
new 13e88a007e43 [SPARK-59122][4.3][SQL] Take UnionExec's plain-union
decision once instead of re-deriving it per caller
13e88a007e43 is described below
commit 13e88a007e43867fdc95da334ea9f8f75073caf9
Author: YangJie <[email protected]>
AuthorDate: Fri Oct 2 21:54:40 2026 +0800
[SPARK-59122][4.3][SQL] Take UnionExec's plain-union decision once instead
of re-deriving it per caller
### What changes were proposed in this pull request?
This backports SPARK-59122 (#58419, already in master and branch-4.x) to
branch-4.3. The change makes `UnionExec` decide once, right after
`EnsureRequirements`, whether it is a plain concatenation, and freeze that
decision plus the codegen confs in a `TreeNodeTag`, so the fusion gate,
`metrics`, `unionRDDs` and the copy of the union inside the codegen shell can
no longer give different answers depending on when each is read. See #58419 for
the full design and the two failures it fixes.
Two conflicts came up against branch-4.3, both because the branch predates
code the master change was built on. Neither changes the fix:
- branch-4.3 does not have SPARK-57399, so `AQEEnablePipelinedShuffle` is
not in the AQE query-stage preparation rule list. The two union barriers are
appended after `RemoveRedundantSorts` exactly as on master, without that rule.
- branch-4.3 does not have SPARK-58819 or SPARK-59285, so `UnionExec` still
derives its candidate partitioning by remapping to the first child's attributes
and lifting with a local `toUnionOutput`, and `comparePartitioning` does not
compare `keyDataTypes`. The backport keeps that body. The SPARK-59122 edit to
it is only the rename of the old `outputPartitioning` body to a private
`rawPartitioning`, moving the `spark.sql.unionOutputPartitioning` gate out of
an early return and into the [...]
### Why are the changes needed?
SPARK-59122 is a Blocker correctness fix. On default settings a union whose
children share a partitioning could concatenate at execution while a parent had
already dropped the exchange it needed, giving wrong results; a fused union
could also lose the `numOutputRows` metric its generated code asks for and
crash. branch-4.3 is exposed to both, since `spark.sql.unionOutputPartitioning`
defaults to true there.
### Does this PR introduce _any_ user-facing change?
Yes, the same change as #58419: the wrong-result and codegen-crash cases
above now behave correctly. The three confs the decision reads no longer reach
a plan that has already been prepared, only plans prepared afterwards.
### How was this patch tested?
The tests come with the cherry-pick, unchanged from #58419; CI runs them
against branch-4.3. The AQE rule-order case asserts positions relative to
`EnsureRequirements` and the end of the list, so it holds with
`AQEEnablePipelinedShuffle` absent without editing.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5
Closes #59151 from LuciferYang/SPARK-59122-4.3.
Authored-by: YangJie <[email protected]>
Signed-off-by: yangjie01 <[email protected]>
---
.../org/apache/spark/sql/internal/SQLConf.scala | 19 +-
.../spark/sql/execution/QueryExecution.scala | 17 +-
.../spark/sql/execution/StampUnionDecisions.scala | 165 +++++
.../execution/adaptive/AdaptiveSparkPlanExec.scala | 47 +-
.../sql/execution/basicPhysicalOperators.scala | 240 ++++++-
.../spark/sql/SparkSessionExtensionSuite.scala | 379 ++++++++++-
.../spark/sql/execution/UnionCodegenSuite.scala | 758 +++++++++++++++++++--
.../adaptive/AdaptiveQueryExecSuite.scala | 41 +-
8 files changed, 1558 insertions(+), 108 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 04f778aca33c..2a42778e283e 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -2865,10 +2865,12 @@ object SQLConf {
val WHOLESTAGE_UNION_CODEGEN_ENABLED =
buildConf("spark.sql.codegen.wholeStage.union.enabled")
.internal()
- .doc("When both this conf and `spark.sql.codegen.wholeStage` are true, "
+
- "UnionExec participates in whole-stage codegen on its " +
- "non-partitioning-aware path: the parent and all children fuse into " +
- "a single WholeStageCodegenExec stage.")
+ .doc("When both this conf and `spark.sql.codegen.wholeStage` are true,
an eligible " +
+ "UnionExec on its non-partitioning-aware path takes part in
whole-stage codegen. " +
+ "The union's other eligibility checks still apply, and a child that
does not support " +
+ "codegen still ends the stage at an InputAdapter. The value is read
once per physical " +
+ "preparation, so a union's codegen gate and the copy of it inside the
generated stage " +
+ "agree.")
.version("4.2.0")
.withBindingPolicy(ConfigBindingPolicy.SESSION)
.booleanConf
@@ -2883,7 +2885,9 @@ object SQLConf {
"bytecode size, constant pool growth, JIT compilation time) rather " +
"than the JVM per-method bytecode limit. Unions with more children " +
"fall back to per-child codegen stages. Only effective when " +
- s"`${WHOLESTAGE_UNION_CODEGEN_ENABLED.key}` is true.")
+ s"`${WHOLESTAGE_UNION_CODEGEN_ENABLED.key}` is true. The value is read
once per physical " +
+ "preparation, so a union's codegen gate and the copy of it inside the
generated stage " +
+ "agree.")
.version("4.2.0")
.withBindingPolicy(ConfigBindingPolicy.SESSION)
.intConf
@@ -7928,7 +7932,10 @@ object SQLConf {
.internal()
.doc("When set to true, the output partitioning of UnionExec will be the
same as the " +
"input partitioning if its children have same partitioning. Otherwise,
it will be a " +
- "default partitioning.")
+ "default partitioning. The value is read once per physical
preparation, and the decision " +
+ "taken with it, so the exchanges planned around a UnionExec and the
decision it executes " +
+ "under agree. One decided to concatenate keeps reporting the default
partitioning if its " +
+ "children come to share one afterwards.")
.version("4.1.0")
.booleanConf
.createWithDefault(true)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
index 586cb5226173..aea928107900 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/QueryExecution.scala
@@ -772,6 +772,9 @@ object QueryExecution {
sparkSession: SparkSession,
adaptiveExecutionRule: Option[InsertAdaptiveSparkPlan] = None,
subquery: Boolean): Seq[Rule[SparkPlan]] = {
+ // Read once here so that both union barriers below, and the codegen gate
they stamp for, answer
+ // from the same values however long preparation takes.
+ val unionConf = UnionConfSnapshot(sparkSession.sessionState.conf)
// `AdaptiveSparkPlanExec` is a leaf node. If inserted, all the following
rules will be no-op
// as the original plan is hidden behind `AdaptiveSparkPlanExec`.
adaptiveExecutionRule.toSeq ++
@@ -780,7 +783,14 @@ object QueryExecution {
PlanDynamicPruningFilters(sparkSession),
PlanSubqueries(sparkSession),
RemoveRedundantProjects,
+ // Must run before `EnsureRequirements`, which asks a `UnionExec` what
it reports: it
+ // records the conf that answer depends on, so the following
`StampUnionDecisions` freezes the
+ // decision under the same value the exchanges were planned against.
+ new SnapshotUnionPreparationConf(unionConf),
EnsureRequirements(),
+ // Must run after `EnsureRequirements`: it fixes each `UnionExec`'s
partitioning decision, and
+ // the answer to fix is the one the exchanges around it were planned
against.
+ new StampUnionDecisions(unionConf),
// This rule must be run after `EnsureRequirements`.
InsertSortForLimitAndOffset,
// `PushDownLocalSort` pushes a wider local sort down onto a narrower
one below it, so a
@@ -805,7 +815,12 @@ object QueryExecution {
// see `AdaptiveSparkPlanExec.queryStagePreparationRules`.)
RemoveRedundantSorts,
ApplyColumnarRulesAndInsertTransitions(
- sparkSession.sessionState.columnarRules, outputsColumnar = false),
+ SnapshotUnionPreparationConf.after(unionConf,
sparkSession.sessionState.columnarRules),
+ outputsColumnar = false),
+ // A barrier for a `UnionExec` an injected columnar rule just created,
which has no decision
+ // yet and would otherwise take one wherever it is first asked. A
decision already stamped on
+ // a node is kept.
+ new StampUnionDecisions(unionConf),
CollapseCodegenStages()) ++
(if (subquery) {
Nil
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/StampUnionDecisions.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/StampUnionDecisions.scala
new file mode 100644
index 000000000000..bd86fe165964
--- /dev/null
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/StampUnionDecisions.scala
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.execution
+
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.internal.SQLConf
+
+/**
+ * Fixes each [[UnionExec]]'s partitioning decision and the confs its codegen
gate reads, at one
+ * defined point.
+ *
+ * `UnionExec` derives both from state that moves: its children's
`outputPartitioning` sharpens as
+ * AQE finalises the plans behind them, and `conf` is the live session conf.
That sharpening does
+ * not run backwards: a cached plan is executed at most once, through
`SparkPlan`'s memoized
+ * `execute` or `executeColumnar`, so a node planned against it cannot see the
window where it
+ * reports no final plan reopen, and a rebuild replaces the relation rather
than re-entering that
+ * plan. Every reader used to derive its own answer, so the answer depended on
when it was read: the
+ * codegen gate could fuse a union whose copy in the shell then answered the
other way, so `metrics`
+ * came back empty and `doProduce` failed asking `metricTerm` for
`numOutputRows`. This rule asks
+ * right after `EnsureRequirements`, so the decision the exchanges around a
union were planned
+ * against is the one `unionRDDs` and the codegen gate use.
+ *
+ * It is listed again after the injected columnar and query-stage rules, the
hooks that can add a
+ * `UnionExec` of their own. One created there has no decision yet, and would
otherwise take one
+ * wherever it is first asked, where the copy in the codegen shell can
disagree with the gate. The
+ * cached-scan branch of stage creation needs no barrier: it rejects a result
that is no longer an
+ * `InMemoryTableScanLike`, which is a leaf.
+ *
+ * A tag rather than a constructor field, because a field would land in
`argString` and so in
+ * every `explain` and `PlanStability` golden holding a `Union`, and in
`canonicalized`, which
+ * exchange and cached-plan reuse key on. Writing it in place is safe here,
unlike in
+ * `MarkSingleTaskExecution`, because preparation runs on `sparkPlan.clone()`
and every
+ * shared-subtree boundary is a leaf, so `foreach` cannot reach a node another
query owns.
+ *
+ * It only writes what is not there yet, so a later pass cannot move a
decision already stamped on
+ * a node, a second pass over the same nodes keeps the first answer, and a
node rebuilt from a
+ * stamped one keeps the tag `copyTagsFrom` gave it. AQE re-plans between
rounds, so a union above
+ * the stages already created is stamped again, from what that round sees and
against that round's
+ * exchanges; a round whose plan loses on cost is discarded whole, stamps
included. A union inside a
+ * stage is not revisited, since `foreach` stops at `QueryStageExec`.
+ */
+class StampUnionDecisions(snapshot: UnionConfSnapshot) extends Rule[SparkPlan]
{
+ override def apply(plan: SparkPlan): SparkPlan = {
+ plan.foreach {
+ case u: UnionExec => u.stampDecisions(snapshot)
+ case _ =>
+ }
+ plan
+ }
+}
+
+/**
+ * Records the preparation's [[UnionConfSnapshot]] on each [[UnionExec]], so
that every reader ahead
+ * of a barrier answers from the same values the barrier will stamp.
+ *
+ * Without this, each phase samples the confs separately: `EnsureRequirements`
reads what a union
+ * reports under the value then, and [[StampUnionDecisions]] freezes the
decision under the value
+ * one rule later. `conf` is the live session conf, so another thread turning
+ * `UNION_OUTPUT_PARTITIONING` off in that window would let a parent drop an
exchange over a
+ * concrete partitioning and then have the union concatenate, which puts one
group in two
+ * partitions.
+ *
+ * The same gap opens between two injected rules: one can return a `UnionExec`
of its own, which
+ * carries no record yet, and a later one can plan requirements over it, build
a parent over it, or
+ * ask its codegen gate. [[SnapshotUnionPreparationConf.before]] closes that
for the two lists whose
+ * rules are listed as plain `Rule[SparkPlan]`, the AQE post-planner-strategy
rules and the
+ * query-stage preparation rules. [[SnapshotUnionPreparationConf.after]]
closes it for the injected
+ * columnar rules, which share one `ApplyColumnarRulesAndInsertTransitions`
and so have no place
+ * between them for a listed pass: the record rides on each rule's own
transitions instead.
+ * `AdaptiveSparkPlanExec.optimizeQueryStage` writes it on every rule result
that changed the plan,
+ * which covers the next rule, and `ValidateRequirements` for an injected rule
that is itself an
+ * `AQEShuffleReadRule`. The built-in ones are listed ahead of the injected
list, so what they
+ * validate holds no union without a record.
+ *
+ * Only the confs are recorded, never a partitioning. `EnsureRequirements` has
not inserted the
+ * exchanges it adds yet, so a decision taken now would freeze plain on a
union whose children only
+ * become co-partitioned there, which is why the decision itself waits for the
barrier behind it.
+ *
+ * Writing the tag in place is safe for the reason given on
[[StampUnionDecisions]].
+ */
+class SnapshotUnionPreparationConf(snapshot: UnionConfSnapshot) extends
Rule[SparkPlan] {
+ override def apply(plan: SparkPlan): SparkPlan = {
+ plan.foreach {
+ case u: UnionExec => u.recordPreparationConf(snapshot)
+ case _ =>
+ }
+ plan
+ }
+}
+
+object SnapshotUnionPreparationConf {
+ /**
+ * `rules` with a snapshot pass ahead of each of them, so a `UnionExec` one
rule creates carries
+ * the preparation's confs before the next rule reads them. Ahead of rather
than behind, so that a
+ * union left by the rules listed before `rules` is covered too. `rules` is
empty unless an
+ * extension injected something, so this adds no pass to an ordinary
preparation.
+ */
+ def before(snapshot: UnionConfSnapshot, rules: Seq[Rule[SparkPlan]]):
Seq[Rule[SparkPlan]] =
+ rules.flatMap(rule => Seq(new SnapshotUnionPreparationConf(snapshot),
rule))
+
+ /**
+ * `rules` with the same record written behind each of their transitions.
+ * `ApplyColumnarRulesAndInsertTransitions` applies these itself, every
+ * `preColumnarTransitions` in order and then every
`postColumnarTransitions` in reverse, so a
+ * pass cannot be listed between two of them from outside; wrapping the
transitions puts it there.
+ * Behind rather than ahead, because the plan reaching the wrapper has
passed a barrier already,
+ * and it is what a rule returns that can hold a union no pass has seen.
`rules` is empty unless
+ * an extension injected something.
+ */
+ def after(snapshot: UnionConfSnapshot, rules: Seq[ColumnarRule]):
Seq[ColumnarRule] =
+ rules.map(RecordUnionPreparationConf(_, snapshot))
+}
+
+/**
+ * Records `snapshot` on each `UnionExec` in what `inner` returns, so a union
`inner` created is
+ * read from the preparation's confs by the next rule in the same
+ * `ApplyColumnarRulesAndInsertTransitions`. See
[[SnapshotUnionPreparationConf.after]].
+ */
+private case class RecordUnionPreparationConf(inner: ColumnarRule, snapshot:
UnionConfSnapshot)
+ extends ColumnarRule {
+
+ private val record = new SnapshotUnionPreparationConf(snapshot)
+
+ override def preColumnarTransitions: Rule[SparkPlan] =
+ plan => record(inner.preColumnarTransitions(plan))
+
+ override def postColumnarTransitions: Rule[SparkPlan] =
+ plan => record(inner.postColumnarTransitions(plan))
+}
+
+/**
+ * The union confs one preparation answers from, read once and shared by every
barrier in it.
+ *
+ * A barrier that read the live conf instead would let two of them disagree:
an injected rule can
+ * return an equivalent `UnionExec` carrying tags it set itself, and
`copyTagsFrom` adds nothing to
+ * a node that already has one, so such a replacement reaches the late barrier
with no record of its
+ * own and would be stamped from whatever the conf says by then, rather than
from what the exchanges
+ * above it were planned against.
+ */
+case class UnionConfSnapshot(
+ outputPartitioning: Boolean,
+ codegenEnabled: Boolean,
+ maxChildren: Int)
+
+object UnionConfSnapshot {
+ def apply(conf: SQLConf): UnionConfSnapshot = UnionConfSnapshot(
+ outputPartitioning = conf.getConf(SQLConf.UNION_OUTPUT_PARTITIONING),
+ codegenEnabled = conf.getConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED),
+ maxChildren = conf.getConf(SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN))
+}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala
index bfe6a9a3f633..30af829c324b 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala
@@ -107,10 +107,20 @@ case class AdaptiveSparkPlanExec(
conf.costEvaluatorCountLocalSortEnabled)
}
+ // Read once for this execution so that the union barriers in the lists
below, which run per
+ // re-planning round and per stage created, cannot answer from different
values. Taken from the
+ // same session conf this query's `QueryExecution.preparations` reads.
+ @transient private val unionConf =
UnionConfSnapshot(context.session.sessionState.conf)
+
+ // The same record the listed passes write, for the two places that need it
between two rules
+ // rather than as a pass of their own. See `SnapshotUnionPreparationConf`.
+ @transient private val recordUnionConf = new
SnapshotUnionPreparationConf(unionConf)
+
// A list of physical plan rules to be applied before creation of query
stages. The physical
// plan should reach a final status of query stages (i.e., no more addition
or removal of
// Exchange nodes) after running these rules.
- @transient private val queryStagePreparationRules: Seq[Rule[SparkPlan]] = {
+ // Visible in the package so that a test can assert where the two union
barriers below sit.
+ @transient private[adaptive] val queryStagePreparationRules:
Seq[Rule[SparkPlan]] = {
// For cases like `df.repartition(a, b).select(c)`, there is no
distribution requirement for
// the final plan, but we do need to respect the user-specified
repartition. Here we ask
// `EnsureRequirements` to not optimize out the user-specified
repartition-by-col to work
@@ -122,7 +132,15 @@ case class AdaptiveSparkPlanExec(
Seq(
CoalesceBucketsInJoin,
RemoveRedundantProjects,
+ // Must run before `ensureRequirements`, which asks a `UnionExec` what
it reports: it
+ // records the conf that answer depends on, so the following
`StampUnionDecisions` freezes the
+ // decision under the same value the exchanges were planned against.
+ new SnapshotUnionPreparationConf(unionConf),
ensureRequirements,
+ // Must run after `EnsureRequirements`: it fixes each `UnionExec`'s
partitioning decision, so
+ // every rule below and the execution itself read the answer the
exchanges above it were
+ // planned against.
+ new StampUnionDecisions(unionConf),
// This rule must be run after `EnsureRequirements`.
InsertSortForLimitAndOffset,
AdjustShuffleExchangePosition,
@@ -147,7 +165,11 @@ case class AdaptiveSparkPlanExec(
// local sort left dangling right below the extra shuffle that skew join
optimization may
// insert between two joins.
RemoveRedundantSorts
- ) ++ context.session.sessionState.adaptiveRulesHolder.queryStagePrepRules
+ ) ++ SnapshotUnionPreparationConf.before(
+ unionConf,
context.session.sessionState.adaptiveRulesHolder.queryStagePrepRules) :+
+ // A barrier for a `UnionExec` an injected prep rule just created.
Decisions already stamped
+ // above are kept.
+ new StampUnionDecisions(unionConf)
}
// A list of physical optimizer rules to be applied to a new stage before
its execution. These
@@ -171,7 +193,12 @@ case class AdaptiveSparkPlanExec(
// plan to these rules has exchange as its root node.
private def postStageCreationRules(outputsColumnar: Boolean) = Seq(
ApplyColumnarRulesAndInsertTransitions(
- context.session.sessionState.columnarRules, outputsColumnar),
+ SnapshotUnionPreparationConf.after(unionConf,
context.session.sessionState.columnarRules),
+ outputsColumnar),
+ // A barrier for a `UnionExec` an injected stage-optimizer or columnar
rule just created, which
+ // has no decision yet and would otherwise take one wherever it is first
asked. A decision
+ // already stamped on a node is kept, so this pass cannot move one.
+ new StampUnionDecisions(unionConf),
collapseCodegenStagesRule
)
@@ -184,6 +211,14 @@ case class AdaptiveSparkPlanExec(
}
val optimized = rules.foldLeft(plan) { case (latestPlan, rule) =>
val applied = rule.apply(latestPlan)
+ if (applied ne latestPlan) {
+ // A `UnionExec` this rule just created carries no record of the confs
this execution
+ // answers from, and the next rule reads the plan before the barrier in
+ // `postStageCreationRules` stamps it. So does the
`ValidateRequirements` check below, for a
+ // rule that is itself an `AQEShuffleReadRule`. A rule that returned
its input added
+ // nothing.
+ recordUnionConf(applied)
+ }
val result = rule match {
case _: AQEShuffleReadRule if !applied.fastEquals(latestPlan) =>
val distribution = if (isFinalStage) {
@@ -213,7 +248,11 @@ case class AdaptiveSparkPlanExec(
private def applyQueryPostPlannerStrategyRules(plan: SparkPlan): SparkPlan =
{
applyPhysicalRules(
plan,
-
context.session.sessionState.adaptiveRulesHolder.queryPostPlannerStrategyRules,
+ // These rules run before `ensureRequirements`, so one of them can still
add or drop an
+ // exchange over a `UnionExec` another just created. A snapshot pass
ahead of each is what
+ // keeps that read off the live conf.
+ SnapshotUnionPreparationConf.before(
+ unionConf,
context.session.sessionState.adaptiveRulesHolder.queryPostPlannerStrategyRules),
"AQE Query Post Planner Strategy Rules"
)
}
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala
index 9a417876b830..9677243b0770 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/basicPhysicalOperators.scala
@@ -35,6 +35,7 @@ import org.apache.spark.sql.catalyst.expressions.codegen._
import org.apache.spark.sql.catalyst.optimizer.CollapseProject
import org.apache.spark.sql.catalyst.plans.logical.Sample
import org.apache.spark.sql.catalyst.plans.physical._
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
import org.apache.spark.sql.execution.joins.{ShuffledHashJoinExec,
SortMergeJoinExec}
import org.apache.spark.sql.execution.metric.{SQLMetric, SQLMetrics}
import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
@@ -923,7 +924,7 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
// Compares two leaf partitionings for union pass-through equivalence.
Callers pass leaf
// partitionings only; a `PartitioningCollection` is flattened to its
members by
- // `outputPartitioning` before reaching here.
+ // `rawPartitioning` before reaching here.
private def comparePartitioning(left: Partitioning, right: Partitioning):
Boolean = {
(left, right) match {
case (SinglePartition, SinglePartition) => true
@@ -931,7 +932,7 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
// For `KeyedPartitioning`, only the partition expressions must match
(the other child's
// expressions have already been remapped to the first child's
attributes by
// `prepareOutputPartitioning`). The partition keys are intentionally
not compared here:
- // children typically carry different key sets, and `outputPartitioning`
merges them.
+ // children typically carry different key sets, and `rawPartitioning`
merges them.
case (l: KeyedPartitioning, r: KeyedPartitioning) =>
l.expressions.length == r.expressions.length &&
l.expressions.zip(r.expressions).forall { case (le, re) =>
le.semanticEquals(re) }
@@ -941,11 +942,15 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
}
}
- override def outputPartitioning: Partitioning = {
- if (!conf.getConf(SQLConf.UNION_OUTPUT_PARTITIONING)) {
- return super.outputPartitioning
- }
-
+ /**
+ * The SPARK-52921 candidate partitioning derived from the children, which
`outputPartitioning`
+ * reports when the decision says this union is not a plain concatenation.
That decision comes out
+ * plain on either of two grounds: `UNION_OUTPUT_PARTITIONING` being off,
which is taken from the
+ * record `SnapshotUnionPreparationConf` writes rather than read here, or
this coming back
+ * `UnknownPartitioning`. Under that conf the candidate can still be
concrete while the union
+ * reports unknown.
+ */
+ private def rawPartitioning: Partitioning = {
// Children's partitionings with attributes remapped to the first child's
attributes.
val partitionings = prepareOutputPartitioning()
// Map from the first child's attributes to this union's own output
attributes.
@@ -1013,13 +1018,119 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
}
}
- // True when the codegen path applies: `outputPartitioning` is
`UnknownPartitioning`,
- // and `unionedInputRDD` matches the semantics of `sparkContext.union(...)`
in `unionRDDs`.
- // A `KeyedPartitioning` union also uses `sparkContext.union(...)` in
`unionRDDs`, but
- // codegen is disabled for it (`supportCodegenFailureReason` reports
"partitioning-aware"):
- // the per-partition key descriptor is consumed by a downstream
`GroupPartitionsExec`, and
- // keeping these unions out of whole-stage codegen matches the
`HashPartitioning` union case.
- private[sql] def isPlainUnion: Boolean =
outputPartitioning.isInstanceOf[UnknownPartitioning]
+ /**
+ * True when this union behaves as a plain concatenation, so
`unionedInputRDD` matches the
+ * semantics of `sparkContext.union(...)` in `unionRDDs`. It satisfies the
partitioning gate on
+ * the codegen path, not the whole of it: `supportCodegenFailureReason`
still applies its other
+ * checks. When this union merges its children's `KeyedPartitioning`
instead, it concatenates all
+ * the same, but codegen stays off, with the reason "partitioning-aware",
because a downstream
+ * `GroupPartitionsExec` consumes its key descriptor.
+ *
+ * Stamped, because the answer moves under its consumers.
+ * `InMemoryTableScanExec.outputPartitioning` reports `UnknownPartitioning`
while its inner
+ * `AdaptiveSparkPlanExec` has no final plan, so a union can look plain when
+ * `CollapseCodegenStages` gates on it and partitioning-aware by the time
the stage runs. The
+ * shell that gate builds wraps a `withNewChildren` copy where a child had
to be adapted, and a
+ * copy that re-derived here came back with empty `metrics` while
`doProduce` asked `metricTerm`
+ * for `numOutputRows`. A fresh copy inherits the answer instead, since
`withNewChildren` ends in
+ * `copyTagsFrom`.
+ *
+ * `UNION_OUTPUT_PARTITIONING` is taken from `recordPreparationConf`,
recorded before
+ * `EnsureRequirements`, so the value the exchanges are planned against is
the value execution
+ * uses. Reading it live here would leave one rule between the two: `conf`
is live, and another
+ * thread setting it in that window would let a parent drop an exchange over
a concrete
+ * partitioning and then have the stamp freeze plain concatenation under it.
A node created after
+ * that pass carries no record of its own and takes the same preparation's
value when it is
+ * stamped; only a union no barrier reached at all falls back to the live
conf.
+ *
+ * A read before `StampUnionDecisions` answers from the children as they are
then, and does not
+ * write, so observing an unprepared plan cannot decide anything for the
prepared one.
+ */
+ private[execution] def isPlainUnion: Boolean = isPlainUnion(rawPartitioning)
+
+ /**
+ * The same decision, for a caller that has already derived the raw
partitioning: deriving it
+ * builds an `AttributeMap` per child and intersects the candidates, and
`outputPartitioning`
+ * needs the value the answer was derived from. By name so that a stamped
node never derives it.
+ */
+ private def isPlainUnion(raw: => Partitioning): Boolean =
+ stampedDecisions.map(_.plainUnion).getOrElse {
+ !outputPartitioningEnabled || raw.isInstanceOf[UnknownPartitioning]
+ }
+
+ private def stampedDecisions: Option[UnionExec.Decisions] =
+ getTagValue(UnionExec.DECISIONS)
+
+ private def preparationConf: Option[UnionConfSnapshot] =
+ getTagValue(UnionExec.PREPARATION_CONF)
+
+ private def outputPartitioningEnabled: Boolean =
+ preparationConf.map(_.outputPartitioning)
+ .getOrElse(conf.getConf(SQLConf.UNION_OUTPUT_PARTITIONING))
+
+ /**
+ * Records the confs this node answers from until its decisions are stamped,
read once per
+ * preparation into a `UnionConfSnapshot` and passed in here.
`SnapshotUnionPreparationConf` does
+ * it ahead of `EnsureRequirements`, whose reads the following stamp has to
agree with, and ahead
+ * of the two injected lists that can still change an exchange. Two more
writers cover the rules
+ * that are folded over rather than listed: each injected columnar rule's
transitions carry the
+ * record behind them, and `optimizeQueryStage` writes it on every rule
result that changed the
+ * plan. `stampDecisions` does it for a node no such pass saw. Only the
confs, never
+ * a partitioning: the exchanges `EnsureRequirements` adds are not there
yet, so a decision taken
+ * there would freeze plain on a union whose children only become
co-partitioned in it.
+ */
+ private[execution] def recordPreparationConf(snapshot: UnionConfSnapshot):
Unit =
+ if (preparationConf.isEmpty) {
+ setTagValue(UnionExec.PREPARATION_CONF, snapshot)
+ }
+
+ /**
+ * Fixes this node's decisions for the rest of the plan's life. Called by
`StampUnionDecisions`,
+ * first right after `EnsureRequirements`, so what the exchanges around this
union were planned
+ * against is what execution uses; the confs come from one read per
preparation. Nothing else
+ * writes this tag on an existing node, and the nodes the rule writes are
freshly planned and not
+ * yet published, so no reader can be looking at one; `metrics` and the
codegen gate read it
+ * later, and a node that already carries it keeps it, which is how the copy
in the codegen shell
+ * stays in step with the gate.
+ */
+ private[execution] def stampDecisions(snapshot: UnionConfSnapshot): Unit =
+ if (stampedDecisions.isEmpty) {
+ // A node the pass ahead of `EnsureRequirements` never saw takes that
pass's value now rather
+ // than the live conf, so a late barrier cannot decide from a conf
changed since. Reachable
+ // for a union an injected rule added, and for one it rebuilt carrying
tags of its own, which
+ // is enough to stop `copyTagsFrom` from bringing this tag across.
+ recordPreparationConf(snapshot)
+ setTagValue(UnionExec.DECISIONS, UnionExec.Decisions(plainUnion =
isPlainUnion))
+ }
+
+ /**
+ * A node stamped plain reports `UnknownPartitioning` even once its children
agree on a concrete
+ * one: a fused union concatenates, and claiming their partitioning would
let a parent skip an
+ * exchange it needs. The cost is SPARK-52921's exchange elimination for
such a union.
+ *
+ * Only the decision is stamped, never the `Partitioning` itself. AQE
coalescing changes the
+ * children's `numPartitions` after the stamp, and a stale count is what
`unionRDDs` would hand
+ * `SQLPartitioningAwareUnionRDD`, which builds exactly that many partitions
from each child.
+ *
+ * The reverse costs fusion. A rule that runs after the stamp and drops a
child's partitioning
+ * leaves the node stamped non-plain, so the codegen gate answers
"partitioning-aware" and
+ * `numOutputRows` goes unregistered, whereas re-deriving at the gate would
have fused it.
+ * `DisableUnnecessaryBucketedScan` does that to a union over two bucketed
scans with a projection
+ * on each side. Results are unaffected, since the branch below re-derives
and concatenates.
+ *
+ * That branch is derived per call, so `unionRDDs` can take the
concatenating arm even though
+ * `EnsureRequirements` planned the parent against a concrete partitioning:
`comparePartitioning`
+ * compares `HashPartitioningLike` by equality, so a change to one child's
partitioning that its
+ * siblings do not mirror can empty the intersection. AQE reconciles that,
by validating a
+ * partitioning change against the parents' requirements; an injected rule
can skip it.
+ */
+ override def outputPartitioning: Partitioning = {
+ // Derived at most once per call, and only when the decision is not
stamped yet: `isPlainUnion`
+ // needs the same value to answer. Not held across calls -- AQE changes
the children's partition
+ // counts, and the answer has to follow them.
+ lazy val raw = rawPartitioning
+ if (isPlainUnion(raw)) super.outputPartitioning else raw
+ }
// Per-child projection from the child's output to the union's output. The
wrapped
// child is always the source `Attribute` (deterministic by construction);
the Alias
@@ -1037,23 +1148,37 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
}
}
- // Memoized: consulted by `supportCodegen` (called multiple times by
- // `CollapseCodegenStages`) and by `metrics`. Conf and children are stable
- // for a given UnionExec instance; cross-plan staleness is impossible since
- // UnionExec is a case class and `withNewChildren` produces a fresh instance.
- @transient private lazy val supportCodegenFailureReason: Option[String] = {
- if (!conf.getConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED)) {
- Some("union-codegen-disabled")
- } else if (!isPlainUnion) {
- Some("partitioning-aware")
- } else if (children.exists(_.exists(_.isInstanceOf[UnionExec]))) {
+ // The confs the gate reads, taken from the record rather than live for the
reason the plain-union
+ // decision is stamped: `conf` is live, so the gate, `metrics` and the copy
`insertInputAdapter`
+ // puts inside the codegen shell would otherwise be free to read different
values. When a child is
+ // not `CodegenSupport` that copy is real and its first evaluation lands at
execution, which is
+ // where reading the conf produced the failure described on `isPlainUnion`.
A stamped node always
+ // carries a record, so these do not need a branch for the stamp.
+ private def unionCodegenEnabled: Boolean =
+ preparationConf.map(_.codegenEnabled)
+ .getOrElse(conf.getConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED))
+
+ private def maxCodegenChildren: Int =
+ preparationConf.map(_.maxChildren)
+ .getOrElse(conf.getConf(SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN))
+
+ // Memoized per instance rather than stamped on the tag. Every term here
reads the children, and a
+ // tag outlives them: `SQLExecution` builds the initial `SparkPlanInfo`
before execution, forcing
+ // `metrics` on every node it visits, so a rule that replaces a child after
that would inherit an
+ // allowing answer and fuse a topology that
`hasPartitionIndexDependentCodegen` or
+ // `supportsColumnar` rejects. That is a trade rather than a free win: a
rule placed after
+ // `CollapseCodegenStages` that weakened a child would instead leave a
stamped union with empty
+ // `metrics` inside a shell that still fuses, which `doProduce` reports as a
missing
+ // `numOutputRows`. Nothing in the tree does that. The copy in the codegen
shell agrees with the
+ // gate either way: `InputAdapter` delegates `output` and `supportsColumnar`
to its child, the
+ // other terms walk the subtree through it, and each is fixed for a given
set of children.
+ @transient private lazy val childTopologyFailureReason: Option[String] = {
+ if (children.exists(_.exists(_.isInstanceOf[UnionExec]))) {
Some("nested-union")
} else if
(children.exists(_.exists(UnionExec.isKnownMultiInputRDDCodegen))) {
Some("multi-rdd-child")
} else if (children.exists(UnionExec.hasPartitionIndexDependentCodegen)) {
Some("partition-index-dependent-child")
- } else if (children.size >
conf.getConf(SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN)) {
- Some("max-children-exceeded")
} else if (supportsColumnar) {
Some("columnar")
} else if (children.exists(c =>
@@ -1064,6 +1189,26 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
}
}
+ // The three preparation-scoped terms are recomputed per call.
`isPlainUnion` has to be: it
+ // derives `rawPartitioning` from the children, so a read before
`stampDecisions` would otherwise
+ // settle the gate on children that move. The two conf terms have to be for
a different reason: a
+ // node with no record answers live, and a pass can write the record
afterwards, so a memoized
+ // answer would keep the live value while the copy `insertInputAdapter`
builds, a fresh instance
+ // carrying that record, reads it. No Spark rule reads this gate before
`CollapseCodegenStages`,
+ // which every barrier precedes; a late extension hook can, and so can a
caller inspecting
+ // `sparkPlan`.
+ private def supportCodegenFailureReason: Option[String] = {
+ if (!unionCodegenEnabled) {
+ Some("union-codegen-disabled")
+ } else if (!isPlainUnion) {
+ Some("partitioning-aware")
+ } else if (children.size > maxCodegenChildren) {
+ Some("max-children-exceeded")
+ } else {
+ childTopologyFailureReason
+ }
+ }
+
override def supportCodegen: Boolean = {
val reason = supportCodegenFailureReason
if (reason.isEmpty) true
@@ -1076,11 +1221,15 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
}
}
- // Registered only when fusion will actually run, so plans that fall back
- // to `doExecute` (which never updates the metric) do not surface a
- // 0-valued row count in the SQL UI. `doConsume` is the sole incrementer.
+ // Registered only when this union's own codegen gate allows fusion, so a
union the gate rejects
+ // does not surface a 0-valued row count in the SQL UI; `doConsume` is the
sole incrementer, and
+ // `doExecute` never touches it. An unstamped node is the exception: its
gate is provisional,
+ // because `isPlainUnion` derives from children that can still move, so the
stamp can land either
+ // way and a map built without the metric would leave `doProduce` asking
`metricTerm` for one that
+ // is not there. Registering it then costs an unused metric on a union an
extension inspected and
+ // the stamp went on to reject.
override lazy val metrics: Map[String, SQLMetric] =
- if (supportCodegenFailureReason.isEmpty) {
+ if (stampedDecisions.isEmpty || supportCodegenFailureReason.isEmpty) {
Map("numOutputRows" -> SQLMetrics.createMetric(sparkContext, "number of
output rows"))
} else {
Map.empty
@@ -1262,6 +1411,37 @@ case class UnionExec(children: Seq[SparkPlan]) extends
SparkPlan with CodegenSup
}
object UnionExec {
+ /**
+ * What `StampUnionDecisions` fixes on a `UnionExec`: whether it is a plain
concatenation. That is
+ * the one answer the confs alone do not give, since it also depends on the
children. The confs
+ * themselves stay on `PREPARATION_CONF`, which a stamped node always
carries. Everything else the
+ * gate asks is derived per instance, so a rule replacing a child cannot
inherit an answer taken
+ * from the topology it replaced.
+ */
+ private case class Decisions(plainUnion: Boolean)
+
+ /**
+ * The stamped decisions. See `isPlainUnion` and `stampDecisions`.
+ *
+ * `withNewChildren` copies the tag onto a rebuilt node, and so does a
transform rule's
+ * replacement, but only where the target carries no tags of its own:
`copyTagsFrom` leaves a node
+ * that already has some untouched. Such a node is stamped when a barrier
next reaches it, and one
+ * that reaches execution unstamped answers from the state it sees then.
+ */
+ private val DECISIONS = TreeNodeTag[Decisions]("unionDecisions")
+
+ /**
+ * The [[UnionConfSnapshot]] this node answers from, and the one a stamped
node's codegen gate
+ * keeps answering from. See `recordPreparationConf`.
`SnapshotUnionPreparationConf` writes it
+ * before `EnsureRequirements`, and the stamp after it reads what that rule
wrote, so both phases
+ * use one value; the same rule runs ahead of the two injected lists that
can still change an
+ * exchange, behind each injected columnar rule's transitions, and on every
rule result
+ * `optimizeQueryStage` sees changed, and `stampDecisions` writes it itself
for a node no such
+ * pass saw. The tag travels onto rebuilt nodes the same way `DECISIONS`
does, which is what
+ * carries it across the copies `EnsureRequirements` makes.
+ */
+ private val PREPARATION_CONF =
TreeNodeTag[UnionConfSnapshot]("unionPreparationConf")
+
/**
* Codegen operators that return more than one RDD from `inputRDDs()`.
* `UnionExec`'s fusion assumes each direct child contributes one RDD.
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/SparkSessionExtensionSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/SparkSessionExtensionSuite.scala
index 05619efa285f..a54acd00eb77 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/SparkSessionExtensionSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/SparkSessionExtensionSuite.scala
@@ -34,7 +34,7 @@ import
org.apache.spark.sql.catalyst.expressions.aggregate.{AggregateExpression,
import org.apache.spark.sql.catalyst.parser.{CatalystSqlParser,
ParserInterface, SqlStatementSplitResult}
import org.apache.spark.sql.catalyst.plans.PlanTest
import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, AggregateHint,
ColumnStat, Limit, LocalRelation, LogicalPlan, Project, Range, Sort, SortHint,
Statistics, UnresolvedHint}
-import org.apache.spark.sql.catalyst.plans.physical.{Partitioning,
SinglePartition}
+import org.apache.spark.sql.catalyst.plans.physical.{Partitioning,
SinglePartition, UnknownPartitioning}
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.trees.TreeNodeTag
import org.apache.spark.sql.classic.ClassicConversions._
@@ -42,10 +42,10 @@ import org.apache.spark.sql.classic.Dataset
import org.apache.spark.sql.connector.catalog.CatalogManager
import org.apache.spark.sql.connector.write.WriterCommitMessage
import org.apache.spark.sql.execution._
-import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
AdaptiveSparkPlanHelper, AQEShuffleReadExec, QueryStageExec,
ShuffleQueryStageExec}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
AdaptiveSparkPlanHelper, AQEShuffleReadExec, AQEShuffleReadRule,
QueryStageExec, ShuffleQueryStageExec}
import org.apache.spark.sql.execution.aggregate.HashAggregateExec
import org.apache.spark.sql.execution.datasources.{FileFormat, WriteFilesExec,
WriteFilesExecBase, WriteFilesSpec}
-import org.apache.spark.sql.execution.exchange.{BroadcastExchangeExec,
BroadcastExchangeLike, ShuffleExchangeExec, ShuffleExchangeLike, ShuffleOrigin}
+import org.apache.spark.sql.execution.exchange.{BroadcastExchangeExec,
BroadcastExchangeLike, ENSURE_REQUIREMENTS, ShuffleExchangeExec,
ShuffleExchangeLike, ShuffleOrigin}
import org.apache.spark.sql.execution.vectorized.OnHeapColumnVector
import org.apache.spark.sql.functions._
import org.apache.spark.sql.internal.SQLConf
@@ -619,6 +619,265 @@ class SparkSessionExtensionSuite extends PlanTest with
AdaptiveSparkPlanHelper {
}
}
+ /**
+ * Prepares a plan whose `UnionExec` was made by an injected rule, then turns
+ * `UNION_OUTPUT_PARTITIONING` off and reads that node again. A union the
`StampUnionDecisions`
+ * barrier following the hook reached keeps answering from the decision it
was prepared with; one
+ * that reached no barrier and no snapshot pass has nothing to answer from,
so this read derives
+ * an answer from the conf as it is now and comes back
`UnknownPartitioning`. `UnionCodegenSuite`
+ * covers the stamping itself by calling the rule directly, so it stays
green if one of the
+ * post-hook listings is dropped; these pin the post-hook stamping in each
pipeline.
+ */
+ private def checkInjectedUnionIsStamped(
+ extensions: Seq[SparkSessionExtensionsProvider], aqeEnabled: Boolean):
Unit = {
+ withSession(extensions) { session =>
+ session.conf.set(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key, aqeEnabled)
+ // The union ends up over the projection above a repartition by `k`, so
it has a concrete
+ // partitioning to pass through. A filter would not do:
`PushPredicateThroughNonJoin` pushes
+ // it below the exchange.
+ val df = session.range(0, 20, 1, 2).selectExpr("id % 5 AS k", "id AS v")
+ .repartition(4, col("k")).selectExpr("k", "v + 1 AS w")
+ // Also what makes the final adaptive plan available in the two AQE
cases. `w` is `id + 1`, so
+ // a union that dropped or duplicated a row would show up here.
+ assert(df.collect().map(_.getLong(1)).sorted.toSeq == (1L to 20L).toSeq,
+ "the injected union must not drop or duplicate rows")
+
+ val unions = collect(df.queryExecution.executedPlan) { case u: UnionExec
=> u }
+ assert(unions.size == 1, s"expected the one union the rule adds, got
${unions.size}")
+ val union = unions.head
+ val prepared = union.outputPartitioning
+ assert(!prepared.isInstanceOf[UnknownPartitioning],
+ s"this union must be prepared partitioning-aware, got $prepared")
+
+ session.conf.set(SQLConf.UNION_OUTPUT_PARTITIONING.key, false)
+ assert(union.outputPartitioning == prepared,
+ "no barrier stamped the union the rule added, so this conf change
decided it: " +
+ s"${union.outputPartitioning}")
+ }
+ }
+
+ test("SPARK-59122: the barrier after the injected columnar rules stamps a
union they added") {
+ checkInjectedUnionIsStamped(
+ create(_.injectColumnar(_ => WrapRootInUnionColumnarRule)), aqeEnabled =
false)
+ }
+
+ test("SPARK-59122: a union an injected query stage prep rule adds is stamped
before execution") {
+ // The barrier in `postStageCreationRules` stands behind the one after the
prep rules, so the
+ // conf flip in `checkInjectedUnionIsStamped` cannot tell them apart. What
only the earlier one
+ // can do is have the answer ready for the stage optimizers, which run in
between and read it:
+ // `CoalesceShufflePartitions` asks whether a union's children have to be
coalesced as one
+ // group. `ObserveUnionPartitioning` reads the node from there, so
removing the earlier barrier
+ // fails this case.
+ val seen = ListBuffer.empty[Partitioning]
+ checkInjectedUnionIsStamped(
+ create { extensions =>
+ extensions.injectQueryStagePrepRule(_ => WrapRootInUnion)
+ extensions.injectQueryStageOptimizerRule(_ =>
ObserveUnionPartitioning(seen))
+ }, aqeEnabled = true)
+ assert(seen.nonEmpty, "the stage optimizers must have seen the union")
+ assert(!seen.exists(_.isInstanceOf[UnknownPartitioning]),
+ s"the barrier after the prep rules must decide before the stage
optimizers read: $seen")
+ }
+
+ test("SPARK-59122: the barrier in AQE post stage creation stamps a union
added there") {
+ // With AQE on, the columnar rules reach this plan only through
`postStageCreationRules`: the
+ // root of the plan `QueryExecution.preparations` hands them is
`AdaptiveSparkPlanExec`, which
+ // `WrapRootInUnion` leaves alone. An injected stage-optimizer rule runs
ahead of the same
+ // barrier, so it needs no case of its own.
+ checkInjectedUnionIsStamped(
+ create(_.injectColumnar(_ => WrapRootInUnionColumnarRule)), aqeEnabled =
true)
+ }
+
+ test("SPARK-59122: a union one injected prep rule adds is recorded before
the next one reads") {
+ // The barrier sits after the whole injected list, so between two of them
a fresh union used to
+ // have no record and answered live. A rule planning requirements over it
could then elide an
+ // exchange over a concrete partitioning while the barrier stamped the
preparation's value and
+ // execution concatenated, which puts one group in two partitions.
+ // `SnapshotUnionPreparationConf.before` lists a snapshot pass ahead of
each injected rule.
+ val seen = ListBuffer.empty[Partitioning]
+ checkInjectedUnionIsStamped(
+ create { extensions =>
+ extensions.injectQueryStagePrepRule(_ => WrapRootInUnion)
+ extensions.injectQueryStagePrepRule(_ =>
ObserveUnionPartitioning(seen))
+ }, aqeEnabled = true)
+ assert(seen.nonEmpty, "the second prep rule must have seen the union")
+ assert(!seen.exists(_.isInstanceOf[UnknownPartitioning]),
+ s"the snapshot pass between the two rules must have recorded the conf:
$seen")
+ }
+
+ test("SPARK-59122: a union an injected post planner strategy rule adds is
recorded next") {
+ // These rules run before `ensureRequirements`, so a reader among them can
still drop an
+ // exchange over a union another of them created, which makes this the
injected list where the
+ // window costs wrong rows rather than a lost fusion.
+ val seen = ListBuffer.empty[Partitioning]
+ checkInjectedUnionIsStamped(
+ create { extensions =>
+ extensions.injectQueryPostPlannerStrategyRule(_ => WrapRootInUnion)
+ extensions.injectQueryPostPlannerStrategyRule(_ =>
ObserveUnionPartitioning(seen))
+ }, aqeEnabled = true)
+ assert(seen.nonEmpty, "the second post planner strategy rule must have
seen the union")
+ assert(!seen.exists(_.isInstanceOf[UnknownPartitioning]),
+ s"the snapshot pass between the two rules must have recorded the conf:
$seen")
+ }
+
+ test("SPARK-59122: a union one injected stage optimizer rule adds is
recorded before the next") {
+ // These rules are folded over inside `optimizeQueryStage`, which has no
list to put a pass
+ // into, so the record is written on each rule result that changed the
plan. What this case
+ // reads is the next rule; the `ValidateRequirements` check in the same
fold answers from the
+ // same write, and reaching it takes an injected rule that is itself an
`AQEShuffleReadRule`,
+ // since the built-in ones are listed ahead of the injected list.
+ val seen = ListBuffer.empty[Partitioning]
+ withSession(create { extensions =>
+ extensions.injectQueryStageOptimizerRule(_ => WrapRootInUnion)
+ extensions.injectQueryStageOptimizerRule(_ =>
ObserveUnionPartitioning(seen))
+ }) { session =>
+ session.conf.set(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key, true)
+ val df = session.range(0, 20, 1, 2).selectExpr("id % 5 AS k", "id AS v")
+ .repartition(4, col("k")).selectExpr("k", "v + 1 AS w")
+ assert(df.collect().map(_.getLong(1)).sorted.toSeq == (1L to 20L).toSeq,
+ "the injected union must not drop or duplicate rows")
+ // These rules run on each stage's plan as well, and the union the first
one adds inside the
+ // shuffle stands over a range, which has nothing to pass through either
way.
+ assert(seen.exists(!_.isInstanceOf[UnknownPartitioning]),
+ s"the union over the shuffle read must answer from the recorded conf:
$seen")
+ }
+ }
+
+ test("SPARK-59122: a union one injected columnar rule adds is recorded
before the next") {
+ // The injected columnar rules share one
`ApplyColumnarRulesAndInsertTransitions`, so the record
+ // rides on each rule's own transitions, one override per phase. The post
transitions run in
+ // reverse list order and the pre transitions in list order, so each phase
needs the rule that
+ // adds the union injected at the other end.
+ Seq(false, true).foreach { aqeEnabled =>
+ val post = ListBuffer.empty[Partitioning]
+ checkInjectedUnionIsStamped(
+ create { extensions =>
+ extensions.injectColumnar(_ =>
ObserveUnionPartitioningColumnarRule(post))
+ extensions.injectColumnar(_ => WrapRootInUnionColumnarRule)
+ }, aqeEnabled)
+ val pre = ListBuffer.empty[Partitioning]
+ checkInjectedUnionIsStamped(
+ create { extensions =>
+ extensions.injectColumnar(_ => WrapRootInUnionPreColumnarRule)
+ extensions.injectColumnar(_ =>
ObserveUnionPartitioningPreColumnarRule(pre))
+ }, aqeEnabled)
+ Seq("post" -> post, "pre" -> pre).foreach { case (phase, seen) =>
+ assert(seen.nonEmpty,
+ s"the second columnar rule must have seen the union, $phase,
aqe=$aqeEnabled")
+ assert(!seen.exists(_.isInstanceOf[UnknownPartitioning]),
+ s"the record must ride behind the rule that added it, $phase,
aqe=$aqeEnabled: $seen")
+ }
+ }
+ }
+
+ test("SPARK-59122: the codegen conf is recorded for a prep rule after the
one that added it") {
+ // The same window, read through the codegen gate rather than the
partitioning. The repartition
+ // is what makes AQE engage at all; it is round-robin, so the union above
it has nothing to pass
+ // through and stays plain, which leaves its gate turning on the codegen
confs. Only enablement
+ // is observable here: a one-child union is under any legal `maxChildren`,
which cannot go below
+ // two, so `UnionCodegenSuite` pins that field on a union of three.
+ val seen = ListBuffer.empty[Boolean]
+ withSession(create { extensions =>
+ extensions.injectQueryStagePrepRule(_ => WrapRootInUnion)
+ extensions.injectQueryStagePrepRule(_ =>
ObserveUnionSupportCodegen(seen))
+ }) { session =>
+ session.conf.set(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key, true)
+ val df = session.range(0, 20, 1, 2).repartition(2).selectExpr("id + 1 AS
w")
+ assert(df.collect().map(_.getLong(0)).sorted.toSeq == (1L to 20L).toSeq,
+ "the injected union must not drop or duplicate rows")
+ assert(seen.nonEmpty, "the second prep rule must have seen the union")
+ assert(seen.forall(identity),
+ s"the snapshot pass between the two rules must have recorded the
codegen conf: $seen")
+ }
+ }
+
+ test("SPARK-59122: an injected shuffle read rule's union is recorded before
validation") {
+ // The other consumer inside `optimizeQueryStage`. For an
`AQEShuffleReadRule` the fold checks
+ // the rewrite with `ValidateRequirements` and keeps the plan from before
the rule when it
+ // fails, so a union the rule put under the aggregate is present
afterwards only if the check
+ // saw the recorded conf. The flip lands after the wrapper read its
snapshot, so the live value
+ // says the union concatenates while the record says it passes the
partitioning through.
+ withSession(create(_.injectQueryStageOptimizerRule(_ =>
WrapAggChildInUnion))) { session =>
+ session.conf.set(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key, true)
+ session.conf.set(SQLConf.UNION_OUTPUT_PARTITIONING.key, true)
+ val df = session.range(0, 20, 1, 2).selectExpr("id % 5 AS
k").groupBy("k").count()
+
assert(df.queryExecution.executedPlan.isInstanceOf[AdaptiveSparkPlanExec])
+
+ session.conf.set(SQLConf.UNION_OUTPUT_PARTITIONING.key, false)
+ assert(df.collect().map(r => (r.getLong(0),
r.getLong(1))).sortBy(_._1).toSeq ==
+ (0L until 5L).map((_, 4L)), "the injected union must not drop or
duplicate rows")
+
+ val unions = collect(df.queryExecution.executedPlan) { case u: UnionExec
=> u }
+ assert(unions.size == 1,
+ "the aggregate's requirement must have been judged against the
recorded conf, or the " +
+ s"rewrite was dropped, got\n${df.queryExecution.executedPlan}")
+ }
+ }
+
+ test("SPARK-59122: the barrier in AQE post stage creation stamps from the
adaptive snapshot") {
+ // The cases above leave the conf where it is until their barriers have
run, so one answering
+ // from a read of its own rather than the snapshot `AdaptiveSparkPlanExec`
took at construction
+ // would pass them too. Here the two differ: the flip lands after the
wrapper is built and
+ // before the stage carrying the injected union is created. This is where
it can be seen, since
+ // the rules listed per stage are the ones built anew each time; the lists
that run per
+ // re-planning round are built once, with the snapshot, so a barrier in
them cannot take a
+ // later value to begin with.
+ withSession(create(_.injectColumnar(_ => WrapRootInUnionColumnarRule))) {
session =>
+ session.conf.set(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key, true)
+ session.conf.set(SQLConf.UNION_OUTPUT_PARTITIONING.key, true)
+ val df = session.range(0, 20, 1, 2).selectExpr("id % 5 AS k", "id AS v")
+ .repartition(4, col("k")).selectExpr("k", "v + 1 AS w")
+ // Constructing the wrapper is what reads AQE's snapshot.
+
assert(df.queryExecution.executedPlan.isInstanceOf[AdaptiveSparkPlanExec])
+
+ session.conf.set(SQLConf.UNION_OUTPUT_PARTITIONING.key, false)
+ // The stages, the columnar rules and the barrier behind them all run
here.
+ assert(df.collect().map(_.getLong(1)).sorted.toSeq == (1L to 20L).toSeq,
+ "the injected union must not drop or duplicate rows")
+
+ val unions = collect(df.queryExecution.executedPlan) { case u: UnionExec
=> u }
+ assert(unions.size == 1, s"expected the one union the rule adds, got
${unions.size}")
+ assert(!unions.head.outputPartitioning.isInstanceOf[UnknownPartitioning],
+ "the barrier must stamp from the snapshot, not from the value the conf
holds when it " +
+ s"runs, got ${unions.head.outputPartitioning}")
+ }
+ }
+
+ test("SPARK-59122: a late AQE barrier records the codegen confs the wrapper
was built with") {
+ // The same divergence read through the codegen gate. A barrier taking the
value the conf holds
+ // when it runs would record fusion as out, so nothing would fuse and the
copy of the union
+ // inside the shell would carry no `numOutputRows`. Round-robin children,
so the union has
+ // nothing to pass through and stays plain, which is what leaves its gate
on these two confs.
+ withSession(create(_.injectColumnar(_ => RebuildRootUnionColumnarRule))) {
session =>
+ session.conf.set(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key, true)
+ Seq(
+ "enablement" -> (SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key ->
"false"),
+ "the child cap" -> (SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "2")
+ ).foreach { case (what, (key, flipped)) =>
+ session.conf.set(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key, "true")
+ // Three children, so that a cap of two excludes this union; the cap
cannot go below two.
+ session.conf.set(SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key, "3")
+ val df = session.range(0, 20, 1, 2).repartition(2)
+ .union(session.range(20, 40, 1, 2).repartition(2))
+ .union(session.range(40, 60, 1, 2).repartition(2))
+
assert(df.queryExecution.executedPlan.isInstanceOf[AdaptiveSparkPlanExec])
+
+ session.conf.set(key, flipped)
+ assert(df.collect().map(_.longValue()).sorted.toSeq == (0L until
60L).toSeq,
+ "the rebuilt union must not drop or duplicate rows")
+
+ val fused = collect(df.queryExecution.executedPlan) { case w:
WholeStageCodegenExec => w }
+ .flatMap(_.collect { case u: UnionExec => u })
+ assert(fused.size == 1, s"the rebuilt union must fuse with $what
flipped, got\n" +
+ df.queryExecution.executedPlan)
+ assert(fused.head.children.forall(_.isInstanceOf[InputAdapter]),
+ s"expected the copy inside the shell, got
${fused.head.children.map(_.getClass)}")
+ assert(fused.head.metrics("numOutputRows").value == 60,
+ s"the copy inside the shell must count the rows with $what flipped")
+ }
+ }
+ }
+
test("custom aggregate hint") {
// The custom hint allows us to replace the aggregate (without grouping
keys) with just
// Literal.
@@ -1387,6 +1646,120 @@ object MyQueryPostPlannerStrategyRule extends
Rule[SparkPlan] {
}
}
+/**
+ * Stands for an extension that introduces a `UnionExec` of its own: replaces
a root `ProjectExec`
+ * with a fresh one-child union, which carries no stamped decision because it
is a new instance, and
+ * leaves the rows alone. Matching only the root keeps it idempotent, since
the root is a
+ * `UnionExec` afterwards; matching a `ProjectExec` keeps it out of the way of
+ * `postStageCreationRules`, which requires the exchange it is handed to stay
an exchange.
+ */
+object WrapRootInUnion extends Rule[SparkPlan] {
+ override def apply(plan: SparkPlan): SparkPlan = plan match {
+ case p: ProjectExec => UnionExec(Seq(p))
+ case other => other
+ }
+}
+
+/**
+ * Stands for an injected shuffle-read optimizer that builds a parent over a
`UnionExec` of its own:
+ * a fresh one-child union under the final aggregate, which leaves the rows
alone and makes
+ * `ValidateRequirements` judge the aggregate's clustering requirement against
what that union
+ * reports. `optimizeQueryStage` drops the whole rewrite when that check
fails, so the union
+ * survives only if it answered from the recorded conf. Matching `Final` mode
keeps it off the
+ * partial aggregate inside the shuffle stage, whose requirement is
unspecified either way.
+ */
+object WrapAggChildInUnion extends Rule[SparkPlan] with AQEShuffleReadRule {
+ override protected def supportedShuffleOrigins: Seq[ShuffleOrigin] =
Seq(ENSURE_REQUIREMENTS)
+
+ override def apply(plan: SparkPlan): SparkPlan = plan.transformUp {
+ case agg: HashAggregateExec
+ if agg.aggregateExpressions.exists(_.mode == Final) &&
+ !agg.child.isInstanceOf[UnionExec] =>
+ agg.withNewChildren(Seq(UnionExec(Seq(agg.child))))
+ }
+}
+
+/** The columnar-rule wrapper for `WrapRootInUnion`. */
+object WrapRootInUnionColumnarRule extends ColumnarRule {
+ override def postColumnarTransitions: Rule[SparkPlan] = WrapRootInUnion
+}
+
+/** `WrapRootInUnion` in the other phase, where the injected rules run in list
order. */
+object WrapRootInUnionPreColumnarRule extends ColumnarRule {
+ override def preColumnarTransitions: Rule[SparkPlan] = WrapRootInUnion
+}
+
+/**
+ * Stands for an extension that returns a `UnionExec` of its own in place of
one already in the
+ * plan: a fresh instance over the same children, so it carries no stamped
decision, and the rows
+ * are the same ones. Replaces the root only, which is where
`postStageCreationRules` hands it a
+ * union; a stage's own plan is rooted at the exchange, so those applications
leave it alone.
+ */
+object RebuildRootUnion extends Rule[SparkPlan] {
+ override def apply(plan: SparkPlan): SparkPlan = plan match {
+ case u: UnionExec => UnionExec(u.children)
+ case other => other
+ }
+}
+
+/** The columnar-rule wrapper for `RebuildRootUnion`. */
+object RebuildRootUnionColumnarRule extends ColumnarRule {
+ override def postColumnarTransitions: Rule[SparkPlan] = RebuildRootUnion
+}
+
+/**
+ * Records what each `UnionExec` reports, with `UNION_OUTPUT_PARTITIONING`
turned off: a concrete
+ * answer can then only come from a decision stamped earlier, or from the conf
a snapshot pass
+ * recorded. Injected as a stage-optimizer rule it runs after the barrier at
the end of the query
+ * stage preparation rules; injected as a prep rule it runs among them. Puts
the conf back, so
+ * nothing downstream sees the flip.
+ */
+case class ObserveUnionPartitioning(seen: ListBuffer[Partitioning]) extends
Rule[SparkPlan] {
+ override def apply(plan: SparkPlan): SparkPlan = {
+ plan.foreach {
+ case u: UnionExec =>
+ val enabled = u.conf.getConf(SQLConf.UNION_OUTPUT_PARTITIONING)
+ u.conf.setConf(SQLConf.UNION_OUTPUT_PARTITIONING, false)
+ try seen += u.outputPartitioning
+ finally u.conf.setConf(SQLConf.UNION_OUTPUT_PARTITIONING, enabled)
+ case _ =>
+ }
+ plan
+ }
+}
+
+/** The columnar-rule wrapper for `ObserveUnionPartitioning`. */
+case class ObserveUnionPartitioningColumnarRule(seen: ListBuffer[Partitioning])
+ extends ColumnarRule {
+ override def postColumnarTransitions: Rule[SparkPlan] =
ObserveUnionPartitioning(seen)
+}
+
+/** `ObserveUnionPartitioning` in the other phase. */
+case class ObserveUnionPartitioningPreColumnarRule(seen:
ListBuffer[Partitioning])
+ extends ColumnarRule {
+ override def preColumnarTransitions: Rule[SparkPlan] =
ObserveUnionPartitioning(seen)
+}
+
+/**
+ * Records whether each `UnionExec` says it supports codegen, read with
+ * `WHOLESTAGE_UNION_CODEGEN_ENABLED` turned off: a `true` can then only come
from the conf a
+ * snapshot pass recorded for it, or from a decision stamped earlier. Puts the
conf back, so nothing
+ * downstream sees the flip.
+ */
+case class ObserveUnionSupportCodegen(seen: ListBuffer[Boolean]) extends
Rule[SparkPlan] {
+ override def apply(plan: SparkPlan): SparkPlan = {
+ plan.foreach {
+ case u: UnionExec =>
+ val enabled = u.conf.getConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED)
+ u.conf.setConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED, false)
+ try seen += u.supportCodegen
+ finally u.conf.setConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED,
enabled)
+ case _ =>
+ }
+ plan
+ }
+}
+
// Example of an Aggregate hint that tells that 'attribute' values are no
larger than 'max'.
// We will use them to rewrite MAX(attribute) with 'max' constant.
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/UnionCodegenSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/UnionCodegenSuite.scala
index 6af286b610e2..4636a3ae0955 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/UnionCodegenSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/UnionCodegenSuite.scala
@@ -21,6 +21,12 @@ import java.util.concurrent.{CountDownLatch, Executors,
TimeUnit}
import org.apache.spark.SparkConf
import org.apache.spark.sql.{DataFrame, Row}
+import org.apache.spark.sql.catalyst.plans.physical.{HashPartitioningLike,
UnknownPartitioning}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
AdaptiveSparkPlanHelper}
+import org.apache.spark.sql.execution.columnar.InMemoryTableScanLike
+import org.apache.spark.sql.execution.exchange.{EnsureRequirements,
REPARTITION_BY_NUM, ShuffleExchangeExec}
import org.apache.spark.sql.functions._
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.test.SharedSparkSession
@@ -30,9 +36,10 @@ import org.apache.spark.sql.types._
* Tests for `UnionExec` whole-stage codegen fusion: plan-shape assertions,
* correctness, type widening, metrics, and fallbacks.
*/
-class UnionCodegenSuite extends SharedSparkSession {
+class UnionCodegenSuite extends SharedSparkSession with
AdaptiveSparkPlanHelper {
- // Union codegen fusion is off by default; turn it on for this suite.
+ // Pinned rather than inherited: these cases turn on which value the union
was prepared with, so a
+ // change to the conf's default must not silently change what they exercise.
override protected def sparkConf: SparkConf =
super.sparkConf.set(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key, "true")
@@ -53,16 +60,86 @@ class UnionCodegenSuite extends SharedSparkSession {
case s: WholeStageCodegenExec => s
}.size
- private def unionInsideWSCG(df: DataFrame): Boolean =
- df.queryExecution.executedPlan.collect {
- case w: WholeStageCodegenExec if
w.find(_.isInstanceOf[UnionExec]).isDefined => w
- }.nonEmpty
+ /**
+ * `AdaptiveSparkPlanHelper.collect` descends through AQE wrappers and query
stages;
+ * `SparkPlan.collect` stops at them, since both are `LeafExecNode`s.
+ *
+ * Stricter than `codegenUnions` on purpose: this matches only a union that
is the root of its own
+ * codegen stage, which is the node whose tags and metrics the callers here
inspect.
+ */
+ private def fusedUnions(df: DataFrame): Seq[UnionExec] =
+ collect(df.queryExecution.executedPlan) {
+ case w: WholeStageCodegenExec if w.child.isInstanceOf[UnionExec] =>
+ w.child.asInstanceOf[UnionExec]
+ }
- /** Run query with flag on, then flag off, assert results match. */
- protected def assertFlagParity(buildDf: () => DataFrame): Unit = {
- val onRows = buildDf().collect().toSeq
+ /**
+ * The unions that take part in a codegen stage: inside a
`WholeStageCodegenExec` with no
+ * `InputAdapter` between them and the stage root. That is what fusion
means. Asking only whether
+ * a stage holds a `UnionExec` somewhere does not answer it, since
`CollapseCodegenStages` leaves
+ * a non-participating union inside the stage too, under an `InputAdapter`;
`fusedUnions` asks for
+ * the stronger property of rooting the stage.
+ *
+ * Ask this of a plan that has run. `CollapseCodegenStages` is a
post-stage-creation rule under
+ * AQE, so a wrapped plan that never executed holds no
`WholeStageCodegenExec` at all and the
+ * answer is empty whatever the confs say. `AdaptiveSparkPlanHelper.collect`
is what descends
+ * through AQE wrappers and query stages; `SparkPlan.collect` stops at them,
since both are
+ * `LeafExecNode`s.
+ */
+ private def codegenUnions(df: DataFrame): Seq[UnionExec] = {
+ def participating(plan: SparkPlan): Seq[UnionExec] = plan match {
+ case _: InputAdapter => Nil
+ case u: UnionExec => u +: u.children.flatMap(participating)
+ case other => other.children.flatMap(participating)
+ }
+ collect(df.queryExecution.executedPlan) { case w: WholeStageCodegenExec =>
w }
+ .flatMap(w => participating(w.child))
+ }
+
+ /**
+ * A cached aggregate, so the union's children read an
`InMemoryTableScanExec`. The caller needs
+ * the cache unmaterialized; `withTempView` drops the view on the way out,
and `dropTempView`
+ * uncaches this view's plan.
+ */
+ private def cacheAggregateView(view: String): Unit = {
+ spark.range(0, 200, 1, 4)
+ .selectExpr("id % 10 AS k", "id AS v")
+ .groupBy("k")
+ .agg(sum("v").as("s"))
+ .createOrReplaceTempView(view)
+ spark.catalog.cacheTable(view)
+ }
+
+ /**
+ * Drive a union barrier the way a preparation would: the rules take one
`UnionConfSnapshot` for
+ * the whole pipeline, so a test driving them by hand samples it where the
rule it is standing in
+ * for runs, which is what the surrounding `withSQLConf` decides.
+ */
+ private def stampUnionDecisions(plan: SparkPlan): SparkPlan =
+ new StampUnionDecisions(UnionConfSnapshot(SQLConf.get))(plan)
+
+ private def snapshotUnionPreparationConf(plan: SparkPlan): SparkPlan =
+ new SnapshotUnionPreparationConf(UnionConfSnapshot(SQLConf.get))(plan)
+
+ /**
+ * Run `buildDf()` with union codegen on, then again with it off, and assert
the two agree.
+ *
+ * A fresh DataFrame per flag value, and not one built outside:
`queryExecution` is memoized and
+ * the decision is stamped at preparation, so collecting the same DataFrame
twice replays the plan
+ * the first value prepared and compares its output against itself. The off
half also has to show
+ * no union took part in codegen, which is only observable once the plan has
executed, hence
+ * after `checkAnswer`. Whether the on half fused anything is the caller's
to assert, since a
+ * fallback case is unfused either way: both DataFrames are returned,
collected, for that.
+ */
+ protected def assertFlagParity(buildDf: () => DataFrame): (DataFrame,
DataFrame) = {
+ val fused = buildDf()
+ val onRows = fused.collect().toSeq
withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
- checkAnswer(buildDf(), onRows)
+ val plain = buildDf()
+ checkAnswer(plain, onRows)
+ assert(codegenUnions(plain).isEmpty,
+ s"expected no union taking part in codegen with the flag
off:\n${plain.queryExecution}")
+ (fused, plain)
}
}
@@ -86,14 +163,14 @@ class UnionCodegenSuite extends SharedSparkSession {
test("SPARK-56482: plain union with filter fuses into one WSCG stage") {
val df = rangeDF(100).union(rangeDF(100)).filter(col("id") > 0)
assert(wscgCount(df) == 1)
- assert(unionInsideWSCG(df))
+ assert(codegenUnions(df).nonEmpty)
}
test("SPARK-56482: flag off restores pre-patch plan shape") {
withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
val df = rangeDF(100).union(rangeDF(100)).filter(col("id") > 0)
assert(wscgCount(df) >= 2)
- assert(!unionInsideWSCG(df))
+ assert(codegenUnions(df).isEmpty)
}
}
@@ -102,7 +179,7 @@ class UnionCodegenSuite extends SharedSparkSession {
SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "2") {
val df = rangeDF(10).union(rangeDF(10)).union(rangeDF(10))
- assert(!unionInsideWSCG(df))
+ assert(codegenUnions(df).isEmpty)
}
}
@@ -200,7 +277,7 @@ class UnionCodegenSuite extends SharedSparkSession {
val b = rangeDF(3).select(col("id").cast(DecimalType(10, 2)).as("v"))
a.union(b)
}
- assert(unionInsideWSCG(build().filter(col("v") >= 0)),
+ assert(codegenUnions(build().filter(col("v") >= 0)).nonEmpty,
"decimal precision/scale widening should still fuse into one WSCG stage")
assertFlagParity(() => build().orderBy("v"))
}
@@ -223,7 +300,7 @@ class UnionCodegenSuite extends SharedSparkSession {
val a = rangeDF(3).select(col("id").cast(IntegerType).as("v"))
val b = rangeDF(3).select(col("id").as("v"))
val df = a.union(b).filter(col("v") >= 0)
- assert(unionInsideWSCG(df),
+ assert(codegenUnions(df).nonEmpty,
"widened-children Union should fuse with filter into a single WSCG
stage")
checkAnswer(df, Seq(Row(0L), Row(1L), Row(2L), Row(0L), Row(1L), Row(2L)))
}
@@ -244,7 +321,7 @@ class UnionCodegenSuite extends SharedSparkSession {
val b = spark.createDataFrame(
java.util.Arrays.asList(Row(Row(3)), Row(Row(4))), structOuterNullable)
val df = a.union(b)
- assert(!unionInsideWSCG(df),
+ assert(codegenUnions(df).isEmpty,
"Nested-nullability mismatch must fall back to non-codegen")
val unionExec = df.queryExecution.executedPlan.collectFirst {
case u: UnionExec => u
@@ -267,7 +344,7 @@ class UnionCodegenSuite extends SharedSparkSession {
val b = spark.createDataFrame(
java.util.Arrays.asList(Row(java.util.Arrays.asList(3, 4))),
schemaNullable)
val df = a.union(b)
- assert(!unionInsideWSCG(df),
+ assert(codegenUnions(df).isEmpty,
"Array containsNull mismatch must fall back to non-codegen")
val unionExec = df.queryExecution.executedPlan.collectFirst {
case u: UnionExec => u
@@ -401,16 +478,15 @@ class UnionCodegenSuite extends SharedSparkSession {
withSQLConf(
SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10485760") {
- val left = rangeDF(100).select(col("id").as("lk"), col("id").as("lv"))
- val right = rangeDF(100).select(col("id").as("rk"))
- val bhj = left.join(broadcast(right), col("lk") === col("rk"))
- .select("lk", "lv")
- val df = bhj.union(
- rangeDF(100).select(col("id").as("lk"), col("id").as("lv")))
- val flagOn = df.collect().toSeq
- withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
- checkAnswer(df, flagOn)
+ val (fused, _) = assertFlagParity { () =>
+ val left = rangeDF(100).select(col("id").as("lk"), col("id").as("lv"))
+ val right = rangeDF(100).select(col("id").as("rk"))
+ val bhj = left.join(broadcast(right), col("lk") === col("rk"))
+ .select("lk", "lv")
+ bhj.union(rangeDF(100).select(col("id").as("lk"), col("id").as("lv")))
}
+ assert(codegenUnions(fused).size == 1,
+ s"the aliasing this case is about needs the fused
path:\n${fused.queryExecution}")
}
}
@@ -418,17 +494,19 @@ class UnionCodegenSuite extends SharedSparkSession {
withSQLConf(
SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "10485760") {
- val probe = rangeDF(10).select(col("id").as("k"))
- val build = rangeDF(20)
- .select((col("id") % 5).as("k"), col("id").as("v"))
- val bhj = probe.join(broadcast(build), "k")
- val df = bhj.union(
- rangeDF(0).select(col("id").as("k"), col("id").as("v")))
- val agg = df.groupBy("k").count().orderBy("k")
- val flagOn = agg.collect()
- withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
- checkAnswer(agg, flagOn.toSeq)
+ val (fused, _) = assertFlagParity { () =>
+ val probe = rangeDF(10).select(col("id").as("k"))
+ val build = rangeDF(20)
+ .select((col("id") % 5).as("k"), col("id").as("v"))
+ val bhj = probe.join(broadcast(build), "k")
+ val df = bhj.union(
+ rangeDF(0).select(col("id").as("k"), col("id").as("v")))
+ df.groupBy("k").count().orderBy("k")
}
+ // The partial aggregate roots the stage here and the union is fused
into it, so this is the
+ // case `fusedUnions` would miss.
+ assert(codegenUnions(fused).size == 1,
+ s"the aliasing this case is about needs the fused
path:\n${fused.queryExecution}")
}
}
@@ -442,14 +520,16 @@ class UnionCodegenSuite extends SharedSparkSession {
withSQLConf(
SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
- val left = rangeDF(100).select(col("id").as("k"))
- val right = rangeDF(100).select(col("id").as("k"))
- val smj = left.join(right, "k")
- val df = smj.union(rangeDF(100).select(col("id").as("k")))
- val flagOn = df.collect().toSeq
- withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
- checkAnswer(df, flagOn)
+ val (fused, _) = assertFlagParity { () =>
+ val left = rangeDF(100).select(col("id").as("k"))
+ val right = rangeDF(100).select(col("id").as("k"))
+ val smj = left.join(right, "k")
+ smj.union(rangeDF(100).select(col("id").as("k")))
}
+ // `codegenUnions`, not `fusedUnions`: what the denylist owes is that
this union takes no part
+ // in codegen at any depth, and without it the union would root a stage
here.
+ assert(codegenUnions(fused).isEmpty,
+ s"the denylist has to keep this union out of
codegen:\n${fused.queryExecution}")
}
}
@@ -480,11 +560,12 @@ class UnionCodegenSuite extends SharedSparkSession {
val cached = rangeDF(100).cache()
try {
cached.count()
- val df = cached.union(rangeDF(100, 200))
- val flagOn = df.collect().toSet
- withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
- assert(df.collect().toSet == flagOn)
- }
+ val (fused, _) = assertFlagParity(() => cached.union(rangeDF(100, 200)))
+ assert(codegenUnions(fused).size == 1, s"expected a fused
union:\n${fused.queryExecution}")
+ // The cached child is the point of the case: without it this is a plain
range union.
+ assert(collect(fused.queryExecution.executedPlan) {
+ case s: InMemoryTableScanLike => s
+ }.nonEmpty, s"expected a cached child:\n${fused.queryExecution}")
} finally {
cached.unpersist()
}
@@ -538,7 +619,7 @@ class UnionCodegenSuite extends SharedSparkSession {
val dfs = (0 until n).map(i => rangeDF(i.toLong, i.toLong + 1L))
val unioned = dfs.reduce((x, y) => x.union(y))
assert(unioned.count() == n.toLong)
- assert(!unionInsideWSCG(unioned))
+ assert(codegenUnions(unioned).isEmpty)
}
}
@@ -565,7 +646,7 @@ class UnionCodegenSuite extends SharedSparkSession {
val a = rangeDF(10).select(col("id"), rand(42).as("r"))
val b = rangeDF(10).select(col("id"), rand(43).as("r"))
val df = a.union(b)
- assert(!unionInsideWSCG(df),
+ assert(codegenUnions(df).isEmpty,
"Union with Nondeterministic child must not be inside WSCG")
// Verify correctness despite fallback
assertFlagParity(() => a.union(b).orderBy("id"))
@@ -575,19 +656,19 @@ class UnionCodegenSuite extends SharedSparkSession {
val a = rangeDF(10).select(col("id"),
monotonically_increasing_id().as("mid"))
val b = rangeDF(10).select(col("id"),
monotonically_increasing_id().as("mid"))
val df = a.union(b)
- assert(!unionInsideWSCG(df),
+ assert(codegenUnions(df).isEmpty,
"Union with monotonically_increasing_id child must not be inside WSCG")
}
test("SPARK-56482: column pruning works under union codegen
(usedInputs=empty)") {
// Union of 2-column children, parent selects only 1 column
- val a = rangeDF(10).select(col("id"), (col("id") * 2).as("v"))
- val b = rangeDF(10, 20).select(col("id"), (col("id") * 3).as("v"))
- val df = a.union(b).select("id").orderBy("id")
- val flagOn = df.collect()
- withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
- checkAnswer(df, flagOn.toSeq)
+ val (fused, _) = assertFlagParity { () =>
+ val a = rangeDF(10).select(col("id"), (col("id") * 2).as("v"))
+ val b = rangeDF(10, 20).select(col("id"), (col("id") * 3).as("v"))
+ a.union(b).select("id").orderBy("id")
}
+ assert(codegenUnions(fused).size == 1,
+ s"pruning is what this case is about, so the union has to be
fused:\n${fused.queryExecution}")
}
test("SPARK-56482: numOutputRows with empty union children") {
@@ -608,16 +689,15 @@ class UnionCodegenSuite extends SharedSparkSession {
test("SPARK-56482: partitioning-aware union falls back to non-codegen") {
// After repartition, both children expose a `HashPartitioning` on the
same key,
// so `UnionExec.outputPartitioning` is non-Unknown and the codegen path
is denied.
- // AQE is disabled here so the executedPlan exposes the UnionExec directly
- // (under AQE the plan is wrapped in `AdaptiveSparkPlanExec`, which does
not
- // surface its inputPlan via `children`).
+ // AQE is disabled here for the `collectFirst` below: `SparkPlan.collect`
stops at
+ // `AdaptiveSparkPlanExec`, and before execution there is no final plan to
reach anyway.
withSQLConf(
SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true",
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
val a = rangeDF(100).repartition(4, col("id"))
val b = rangeDF(100, 200).repartition(4, col("id"))
val df = a.union(b)
- assert(!unionInsideWSCG(df),
+ assert(codegenUnions(df).isEmpty,
"Partitioning-aware union must not fuse into WSCG")
val unionExec = df.queryExecution.executedPlan.collectFirst {
case u: UnionExec => u
@@ -628,6 +708,558 @@ 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 answered "partitioning-aware",
so `metrics` came back
+ // empty while `doProduce` asked `metricTerm` for `numOutputRows`.
+ //
+ // Executing the query is what proves that crash is gone: an empty
`metrics` throws at
+ // `metricTerm` while `doProduce` runs. The metric's value then says the
fused code ran and
+ // counted, and the partitioning assertion is the other half, which
registering the metric
+ // unconditionally would have left 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("numOutputRows").value == 20,
+ "a fused union must count the rows its generated code emitted")
+ 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. The copy
carries the record the
+ // preparation wrote, which is the only place its gate can get the
conf from now that the
+ // live one says the opposite.
+ 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 read before the barrier decides neither the gate nor
the metric") {
+ // An extension hook can build a `UnionExec` and force `supportCodegen` or
`metrics` on it
+ // before the barrier behind that hook stamps it. Memoizing the whole gate
there settles it on
+ // the live conf, and the copy `insertInputAdapter` builds, a fresh
instance carrying the
+ // stamped tag, derives the other answer: that disagreement is what
`doProduce` used to hit as
+ // `key not found: numOutputRows`. The preparation-scoped terms are
recomputed per call instead,
+ // and an unstamped node registers the metric, since either stamped
outcome is still open.
+ // Nothing here is executed, so AQE does not enter into it: the children
come from `sparkPlan`,
+ // and the union under test is built by hand.
+ val planned = rangeDF(100).union(rangeDF(100))
+ .queryExecution.sparkPlan.collect { case u: UnionExec => u }
+ assert(planned.size == 1)
+ val kids = planned.head.children
+
+ Seq(true, false).foreach { live =>
+ val union = UnionExec(kids)
+ withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key ->
live.toString) {
+ assert(union.supportCodegen == live,
+ "an unstamped read answers from the live conf, or this case starts
from nothing")
+ assert(union.metrics.contains("numOutputRows"),
+ "an unstamped union must register the metric: the stamp can still
fuse it")
+ }
+ withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key ->
(!live).toString) {
+ stampUnionDecisions(union)
+ assert(union.supportCodegen == !live,
+ s"the gate must answer from the barrier's snapshot, not from the
read taken with the " +
+ s"conf $live")
+ }
+ }
+ }
+
+ 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 immediately after
+ // `EnsureRequirements`, so the decision is taken from the plan the
exchanges were placed in
+ // and no rule in between can change a partitioning the parent already
planned against. A
+ // count would break on a sixth legitimate pass and say nothing about
the order.
+ // `AdaptiveQueryExecSuite` asserts the same adjacency for the list AQE
builds.
+ val rules = QueryExecution.preparations(spark, subquery = false)
+ val firstStamp = rules.indexWhere(_.isInstanceOf[StampUnionDecisions])
+ val ensureRequirements =
rules.indexWhere(_.isInstanceOf[EnsureRequirements])
+ val columnarRules =
+
rules.indexWhere(_.isInstanceOf[ApplyColumnarRulesAndInsertTransitions])
+ assert(ensureRequirements >= 0 && firstStamp == ensureRequirements + 1 &&
+ firstStamp < columnarRules,
+ "expected a stamping pass right behind EnsureRequirements and before
the columnar " +
+ s"rules, got $ensureRequirements/$firstStamp/$columnarRules")
+
+ val stamped = withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key ->
"true") {
+ val df = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k"))
+ .union(spark.range(20, 40, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k")))
+ val union = df.queryExecution.executedPlan.collect { case u: UnionExec
=> u }
+ assert(union.size == 1)
+ union.head
+ }
+
+ // A fresh node standing in for one an extension made after the first
pass: no decision yet.
+ val fresh = UnionExec(stamped.children)
+ withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") {
+ stampUnionDecisions(fresh)
+ }
+ // Read back with the conf the other way round, so the answer can only
come from the stamp:
+ // deriving here would make it non-plain, these children being
co-partitioned.
+ withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+ assert(fresh.isPlainUnion, "the barrier must have decided the fresh
node")
+ }
+
+ // The other half. The conf a barrier records is the part a second pass
could move, so the
+ // node to watch is one whose gate the conf still answers: plain, and
with its reason not yet
+ // forced. `fusedUnions` returns the copy inside the codegen shell,
whose reason no
+ // preparation rule has asked for, so what it answers below comes from
the recorded conf.
+ val fused = withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key ->
"true") {
+ val df = rangeDF(100).repartition(2).union(rangeDF(100).repartition(2))
+ val union = fusedUnions(df)
+ assert(union.size == 1, "this shape must fuse, or the test exercises
nothing")
+ union.head
+ }
+ withSQLConf(SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false") {
+ stampUnionDecisions(fused)
+ assert(fused.supportCodegen, "a second pass must not restamp the conf
it was decided with")
+ }
+ }
+ }
+
+ test("SPARK-59122: the stamp uses the conf the exchanges were planned
against") {
+ // `EnsureRequirements` asks the union what it reports, and the barrier
behind it freezes that
+ // answer one rule later. `conf` is live, so another thread turning
`UNION_OUTPUT_PARTITIONING`
+ // off in between would leave the parent's elided exchange standing over a
union that then
+ // concatenates. `SnapshotUnionPreparationConf` records the value ahead of
+ // `EnsureRequirements` for both to use. Driven rule by rule, because the
two sit next to each
+ // other in the pipeline and no injected rule can run in the window.
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+ // The three have to stay contiguous, which nothing at the list itself
says: an injected rule
+ // cannot land between them, but an edit to the list can. AQE builds its
own list, and
+ // `AdaptiveQueryExecSuite` asserts the same order there.
+ val rules = QueryExecution.preparations(spark, subquery = false)
+ val snapshot =
rules.indexWhere(_.isInstanceOf[SnapshotUnionPreparationConf])
+ val ensureRequirements =
rules.indexWhere(_.isInstanceOf[EnsureRequirements])
+ assert(snapshot >= 0 && snapshot == ensureRequirements - 1,
+ s"expected the conf snapshot right before EnsureRequirements at
$ensureRequirements, " +
+ s"got $snapshot")
+
+ val df = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k"))
+ .union(spark.range(20, 40, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k")))
+ .groupBy("k").count()
+ val required = EnsureRequirements()(
+ snapshotUnionPreparationConf(df.queryExecution.sparkPlan.clone()))
+ assert(required.collect { case s: ShuffleExchangeExec => s.shuffleOrigin
} ==
+ Seq(REPARTITION_BY_NUM, REPARTITION_BY_NUM),
+ "the aggregate's exchange must have been elided, or the window has
nothing at stake")
+
+ val union = required.collect { case u: UnionExec => u }
+ assert(union.size == 1)
+ withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") {
+ stampUnionDecisions(required)
+
assert(!union.head.outputPartitioning.isInstanceOf[UnknownPartitioning],
+ "the answer must come from the conf snapshot, not from the value
read now, got " +
+ s"${union.head.outputPartitioning}")
+ }
+ }
+ }
+
+ test("SPARK-59122: a late barrier stamps a replacement from the
preparation's conf") {
+ // An injected rule can return a `UnionExec` of its own in place of a
stamped one, and
+ // `copyTagsFrom` gives nothing to a node already carrying a tag, so such
a replacement reaches
+ // the barrier behind the extension hooks with no record of the value the
exchanges above it
+ // were planned against. Every barrier in one preparation answers from the
same
+ // `UnionConfSnapshot`, so that is still the value the replacement is
stamped with.
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false",
+ SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+ val df = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k"))
+ .union(spark.range(20, 40, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k")))
+ .groupBy("k").count()
+ val prepared = stampUnionDecisions(EnsureRequirements()(
+ snapshotUnionPreparationConf(df.queryExecution.sparkPlan.clone())))
+ val union = prepared.collect { case u: UnionExec => u }
+ assert(union.size == 1)
+ // The value a preparation would carry to its late barrier, taken while
the conf still says
+ // what `EnsureRequirements` read above.
+ val prepConf = UnionConfSnapshot(SQLConf.get)
+
+ // What `transformUp` leaves behind for a rule that replaced the node:
`copyTagsFrom` is
+ // all-or-nothing, so one unrelated tag of its own costs the replacement
both of ours.
+ val replacement = UnionExec(union.head.children)
+ replacement.setTagValue(TreeNodeTag[Unit]("SPARK-59122-injected"), ())
+ replacement.copyTagsFrom(union.head)
+
+ withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") {
+ assert(replacement.isPlainUnion,
+ "the replacement must reach the barrier with no decision of its own,
or this case " +
+ "exercises nothing")
+ new StampUnionDecisions(prepConf)(replacement)
+
assert(!replacement.outputPartitioning.isInstanceOf[UnknownPartitioning],
+ "the late barrier must stamp from the preparation's conf, not the
value read now, got " +
+ s"${replacement.outputPartitioning}")
+ }
+ }
+ }
+
+ test("SPARK-59122: the barrier records the preparation's codegen confs for a
fresh node") {
+ // The case above pins the same for `UNION_OUTPUT_PARTITIONING`. The
codegen confs live on
+ // the record rather than on the stamped decision, so what needs pinning
here is that a barrier
+ // reaching a union no snapshot pass saw records that preparation's values
and not the ones the
+ // conf says by then. Nothing here is executed, so AQE does not enter into
it.
+ val df = rangeDF(100).union(rangeDF(100)).union(rangeDF(100))
+ val planned = df.queryExecution.sparkPlan.collect { case u: UnionExec => u
}
+ assert(planned.size == 1 && planned.head.children.size == 3,
+ s"expected one union of three children, got
${planned.map(_.children.size)}")
+ val kids = planned.head.children
+
+ // What a preparation would carry to its barriers: fusion allowed, cap
high enough for three.
+ val prepConf = withSQLConf(
+ SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
+ SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "3") {
+ UnionConfSnapshot(SQLConf.get)
+ }
+
+ Seq(
+ "enablement" -> (SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key ->
"false"),
+ "the child cap" -> (SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "2")
+ ).foreach { case (what, flipped) =>
+ val union = UnionExec(kids)
+ withSQLConf(flipped) {
+ new StampUnionDecisions(prepConf)(union)
+ assert(union.supportCodegen, s"the record must hold $what from the
snapshot")
+ }
+ }
+ }
+
+ test("SPARK-59122: a union fused under AQE keeps both codegen decisions
through execution") {
+ // The AQE counterpart of the two "changes between planning and execution"
cases above.
+ // `CollapseCodegenStages` runs after each stage is created, so the shell
and the copy inside it
+ // are built while the query runs, with the flipped value below already in
effect: a gate that
+ // reads either codegen conf live there would deny fusion, and the copy
would come back with
+ // empty `metrics`. What this case does not pin is where the stamp took
its values, because AQE
+ // stamps the union while it builds `initialPlan`, which is still inside
the outer conf; the
+ // case above is what pins that. That AQE reads its `UnionConfSnapshot`
once, at construction,
+ // rather than per re-planning round is visible only in the code, since
reaching a per-stage
+ // barrier with an unstamped union takes an injected rule.
+ Seq(
+ SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "false",
+ SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "2").foreach { flipped =>
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true") {
+ // Exchange children are not `CodegenSupport`, so `insertInputAdapter`
wraps them and the
+ // union inside the shell is a `withNewChildren` copy rather than the
instance the gate
+ // answered on. Three of them, so that a cap of two excludes this
union; the cap cannot go
+ // below two.
+ val df = rangeDF(100).repartition(2)
+ .union(rangeDF(100).repartition(2))
+ .union(rangeDF(100).repartition(2))
+ // Constructing the wrapper is what reads AQE's snapshot, and it
happens here, before the
+ // flipped value below. The stages that hold the union are created
during `collect()`.
+
assert(df.queryExecution.executedPlan.isInstanceOf[AdaptiveSparkPlanExec])
+ withSQLConf(flipped) {
+ assert(df.collect().length == 300)
+ val fused = fusedUnions(df)
+ assert(fused.size == 1,
+ s"the union must still fuse under AQE,
got\n${df.queryExecution.executedPlan}")
+ // The cap iteration needs three children to say anything: at two, a
cap of two admits it.
+ assert(fused.head.children.size == 3)
+ assert(fused.head.children.forall(_.isInstanceOf[InputAdapter]))
+ assert(fused.head.metrics("numOutputRows").value == 300)
+ }
+ }
+ }
+ }
+
+ test("SPARK-59122: a snapshot pass ahead of each injected rule fills in what
one created") {
+ // The barrier sits after the whole injected list, so between two injected
rules a fresh union
+ // used to carry no record and answer live.
`SnapshotUnionPreparationConf.before` lists a pass
+ // ahead of each of them. Driven by hand here;
`SparkSessionExtensionSuite` runs it through real
+ // injected rules.
+ withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "true") {
+ val df = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k"))
+ .union(spark.range(20, 40, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k")))
+ .groupBy("k").count()
+ val prepared = EnsureRequirements()(
+ snapshotUnionPreparationConf(df.queryExecution.sparkPlan.clone()))
+ val kids = prepared.collect { case u: UnionExec => u }.head.children
+ // What a preparation carries to the rules below it, taken while the
conf still says what
+ // `EnsureRequirements` above read.
+ val prepConf = UnionConfSnapshot(SQLConf.get)
+
+ // Stands for an injected rule that returns a `UnionExec` of its own,
twice over: the pass
+ // between the two is what the rule listed second would otherwise be
missing.
+ val fresh = UnionExec(kids)
+ val injected = new Rule[SparkPlan] {
+ override def apply(plan: SparkPlan): SparkPlan = fresh
+ }
+ val listed = SnapshotUnionPreparationConf.before(prepConf, Seq(injected,
injected))
+ assert(listed.size == 4 &&
+ listed.head.isInstanceOf[SnapshotUnionPreparationConf] &&
+ listed(2).isInstanceOf[SnapshotUnionPreparationConf],
+ s"a snapshot pass must sit ahead of each injected rule, got
${listed.map(_.getClass)}")
+
+ withSQLConf(SQLConf.UNION_OUTPUT_PARTITIONING.key -> "false") {
+ assert(fresh.isPlainUnion, "the fresh union must reach the list with
no record of its own")
+ listed.foldLeft(prepared)((plan, rule) => rule(plan))
+ assert(!fresh.outputPartitioning.isInstanceOf[UnknownPartitioning],
+ "the pass after the rule that created it must have recorded the
preparation's conf, " +
+ s"got ${fresh.outputPartitioning}")
+ }
+ }
+ }
+
+ test("SPARK-59122: a recorded conf answers the codegen gate before a
decision is stamped") {
+ // The gate's two codegen confs fall back to the record the way the
partitioning decision does,
+ // so a rule reading the gate on a union an earlier rule created gets the
preparation's values.
+ // Three children, because the cap cannot go below two and so says nothing
about a union of one.
+ val df = rangeDF(100).union(rangeDF(100)).union(rangeDF(100))
+ val kids = df.queryExecution.sparkPlan.collect { case u: UnionExec => u
}.head.children
+ assert(kids.size == 3, s"expected one union of three children, got
${kids.size}")
+ val prepConf = withSQLConf(
+ SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key -> "true",
+ SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "3") {
+ UnionConfSnapshot(SQLConf.get)
+ }
+
+ Seq(
+ "enablement" -> (SQLConf.WHOLESTAGE_UNION_CODEGEN_ENABLED.key ->
"false"),
+ "the child cap" -> (SQLConf.WHOLESTAGE_UNION_MAX_CHILDREN.key -> "2")
+ ).foreach { case (what, flipped) =>
+ val union = UnionExec(kids)
+ new SnapshotUnionPreparationConf(prepConf)(union)
+ withSQLConf(flipped) {
+ assert(union.supportCodegen, s"the record must answer $what, not the
value read now")
+ }
+ }
+
+ // A record is not a decision: `isPlainUnion` still derives from the
children, so an unstamped
+ // gate stays provisional and the metric has to be there whichever way the
stamp lands. Recorded
+ // with fusion off, so the gate denies and only the unstamped term can
register it.
+ val denied = UnionExec(kids)
+ new SnapshotUnionPreparationConf(prepConf.copy(codegenEnabled =
false))(denied)
+ assert(!denied.supportCodegen, "this half needs the gate to deny, or it
pins nothing")
+ assert(denied.metrics.contains("numOutputRows"),
+ "a recorded but unstamped union must register the metric even where its
gate denies")
+ }
+
test("SPARK-56482: input_file_name child fuses (Nondeterministic but
partition-index-free)") {
// `InputFileName` is `Nondeterministic` but reads from
`InputFileBlockHolder`
// (a per-task thread-local) and does not embed `partitionIndex`. The
gate's
@@ -638,7 +1270,7 @@ class UnionCodegenSuite extends SharedSparkSession {
val a = spark.read.parquet(path).select(col("id"),
input_file_name().as("f"))
val b = spark.read.parquet(path).select(col("id"),
input_file_name().as("f"))
val df = a.union(b).filter(col("id") > 0)
- assert(unionInsideWSCG(df),
+ assert(codegenUnions(df).nonEmpty,
"Union with input_file_name child should fuse into WSCG")
assertFlagParity(() => a.union(b).orderBy("id", "f"))
}
@@ -667,7 +1299,7 @@ class UnionCodegenSuite extends SharedSparkSession {
// emission window" requirement. Generating the same fused stage from many
// threads reproduces the race.
val df = rangeDF(100).union(rangeDF(100)).filter(col("id") > 0)
- assert(unionInsideWSCG(df))
+ assert(codegenUnions(df).nonEmpty)
val wscg = df.queryExecution.executedPlan.collectFirst {
case w: WholeStageCodegenExec if
w.find(_.isInstanceOf[UnionExec]).isDefined => w
}.getOrElse(fail("expected a fused UnionExec stage"))
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
index 9523ce897ecf..38832ad3723e 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
@@ -43,7 +43,7 @@ import
org.apache.spark.sql.execution.columnar.{InMemoryTableScanExec, InMemoryT
import org.apache.spark.sql.execution.command.DataWritingCommandExec
import org.apache.spark.sql.execution.datasources.noop.NoopDataSource
import org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec
-import org.apache.spark.sql.execution.exchange.{BroadcastExchangeExec,
ENSURE_REQUIREMENTS, Exchange, REPARTITION_BY_COL, REPARTITION_BY_NUM,
ReusedExchangeExec, ShuffleExchangeExec, ShuffleExchangeLike, ShuffleOrigin}
+import org.apache.spark.sql.execution.exchange.{BroadcastExchangeExec,
ENSURE_REQUIREMENTS, EnsureRequirements, Exchange, REPARTITION_BY_COL,
REPARTITION_BY_NUM, ReusedExchangeExec, ShuffleExchangeExec,
ShuffleExchangeLike, ShuffleOrigin}
import org.apache.spark.sql.execution.joins.{BaseJoinExec,
BroadcastHashJoinExec, BroadcastNestedLoopJoinExec,
HashedRelationBroadcastMode, ShuffledHashJoinExec, ShuffledJoin,
SortMergeJoinExec}
import org.apache.spark.sql.execution.metric.SQLShuffleReadMetricsReporter
import org.apache.spark.sql.execution.streaming.runtime.{MemoryStream,
StreamingQueryWrapper}
@@ -4241,6 +4241,45 @@ class AdaptiveQueryExecSuite
}
}
+ test("SPARK-59122: query stage preparation keeps the union barriers around
EnsureRequirements") {
+ // `EnsureRequirements` asks a `UnionExec` what it reports, and
`StampUnionDecisions` freezes
+ // that answer so every rule below it and the execution read what the
exchanges were planned
+ // against. `SnapshotUnionPreparationConf` has to run first, or the value
the stamp reads
+ // is whatever `conf` says by then rather than the one
`EnsureRequirements` saw. The two sit
+ // next to `EnsureRequirements` with nothing in between, and nothing at
the list itself says the
+ // three have to stay contiguous: an injected rule cannot land between
them, since those are
+ // appended at the tail, but an edit to the list can. AQE builds its own
list, and
+ // `UnionCodegenSuite` covers the one `QueryExecution` builds when AQE is
off.
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true") {
+ // The repartition exchanges are what bring AQE in, with the aggregate's
distribution
+ // requirement as a second reason: `InsertAdaptiveSparkPlan` tests for
either and leaves a
+ // plain union of ranges alone.
+ val df = spark.range(0, 20, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k"))
+ .union(spark.range(20, 40, 1, 2).selectExpr("id % 5 AS
k").repartition(4, col("k")))
+ .groupBy("k").count()
+ val aqe = df.queryExecution.executedPlan.collectFirst {
+ case a: AdaptiveSparkPlanExec => a
+ }
+ assert(aqe.isDefined, s"expected an
AdaptiveSparkPlanExec:\n${df.queryExecution}")
+ val rules = aqe.get.queryStagePreparationRules
+ val snapshot =
rules.indexWhere(_.isInstanceOf[SnapshotUnionPreparationConf])
+ val ensureRequirements =
rules.indexWhere(_.isInstanceOf[EnsureRequirements])
+ // Both barriers, not the first one: the list ends with a second
`StampUnionDecisions` for a
+ // union an injected prep rule created, and asking only for the first
index would let that one
+ // stand in for the barrier behind `EnsureRequirements`.
+ val stamps = rules.zipWithIndex.collect {
+ case (rule, i) if rule.isInstanceOf[StampUnionDecisions] => i
+ }
+ assert(snapshot >= 0 && snapshot == ensureRequirements - 1,
+ s"expected the conf snapshot right before EnsureRequirements at
$ensureRequirements, " +
+ s"got $snapshot")
+ assert(stamps.headOption.contains(ensureRequirements + 1),
+ s"expected the stamp right behind EnsureRequirements at
$ensureRequirements, got $stamps")
+ assert(stamps.last == rules.length - 1,
+ s"expected the trailing stamp last of ${rules.length} rules, got
$stamps")
+ }
+ }
+
test("SPARK-44065: Optimize BroadcastHashJoin skew") {
withSQLConf(
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]