cloud-fan commented on code in PR #58924:
URL: https://github.com/apache/spark/pull/58924#discussion_r4414361112
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -6869,6 +6869,33 @@ object SQLConf {
.booleanConf
.createWithDefault(false)
+ val REPLACE_CTE_REF_WITH_CTE_REUSE =
+ buildConf("spark.sql.optimizer.replaceCTERefWithCTEReuse.enabled")
+ .internal()
+ .doc("When true, plan-reuse repartitions (those carrying a nonzero
repartition id, " +
+ "currently produced only for CTE definitions marked forceSkipInline)
are converted to " +
+ "CTEReuse nodes instead of plain Repartition nodes; ordinary retained
or user " +
+ "MATERIALIZED CTEs keep plain repartitions and are unaffected.
CTEReuse enables " +
+ "guaranteed exchange reuse in AQE by sharing a single inner
AdaptiveSparkPlanExec " +
+ "across all references.")
+ .version("4.4.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .booleanConf
+ .createWithDefault(true)
+
+ val FAIL_ON_CTE_REUSE_WITHOUT_AQE =
+ buildConf("spark.sql.optimizer.failOnCTEReuseWithoutAQE.enabled")
+ .internal()
+ .doc("When true, throw an error if guaranteed CTE/subplan shuffle reuse
fails with AQE " +
+ "off, i.e. two or more shuffles tagged with the same cteId survive
after " +
+ "ReuseExchangeAndSubquery (they were not deduplicated into a
ReusedExchangeExec). " +
+ "When false, only log the plan, error, and cteIds. Enabled by default
in tests to " +
+ "catch reuse regressions.")
+ .version("4.4.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .booleanConf
+ .createWithDefault(false)
Review Comment:
Thanks for explaining. ReplaceRepartitionWithCTEReuse already seals a cteId
only after validateCTEReuseRelations confirms that every same-id sharedSubplan
is canonically equal, so a cteId-keyed owner on the non-AQE path would merge
only bodies that have passed that check. Today, on any non-AQE route (AQE
disabled, AQE enabled but not applied, or the CTEAdaptiveNotSupportedException
fallback), a nondeterministic forced CTE can still be materialized once per
reference, for example with spark.sql.exchange.reuse=false, and VerifyCTEReuse
only logs by default. That is the failure this PR sets out to fix, so I'd still
like the non-AQE path to either share one owner per cteId or fail before
execution, rather than run duplicate materializations by default.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4237377074","thread_id":"inline:4237377074","verdict_sha256":"7971a94f9214bdf1857a39473550a560c323a2594d2e01d88f4a127ddd3021af"}
-->
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/optimizer/ReplaceCTERefWithRepartition.scala:
##########
@@ -53,14 +54,48 @@ object ReplaceCTERefWithRepartition extends
Rule[LogicalPlan] {
case WithCTE(child, cteDefs) =>
cteDefs.foreach { cteDef =>
val inlined = replaceWithRepartition(cteDef.child, cteMap)
- val withRepartition =
- if (canSkipExtraRepartition(inlined) || cteDef.underSubquery) {
- // If the CTE definition plan itself is a repartition operation or
if it hosts a merged
- // scalar subquery, we do not need to add an extra repartition
shuffle.
- inlined
- } else {
- RepartitionByExpression(Seq.empty, inlined, None)
- }
+ // `forcePartitioning` is only honored together with
`forceSkipInline`. A pin requests a
+ // sealed, guaranteed-reuse materialization, which only applies to
definitions `InlineCTE`
+ // keeps (i.e. `forceSkipInline`). A pin on a non-`forceSkipInline`
definition is ignored
+ // here, because such a definition may be inlined by `InlineCTE`
before this rule runs, so
+ // honoring the pin would be inconsistent with the inlining decision.
+ val withRepartition = (cteDef.forceSkipInline,
cteDef.forcePartitioning) match {
+ case (true, Some(h: HashPartitioning)) =>
+ // A pinned partitioning materializes the CTE as a plan-reuse
RepartitionByExpression
+ // on the pinned expressions, so guaranteed CTE reuse can pick it
up. It takes
+ // precedence over `canSkipExtraRepartition` -- the pin states the
exact partitioning
+ // (keys and number of partitions) we must produce.
+ RepartitionByExpression(
+ h.expressions, inlined, optNumPartitions = Some(h.numPartitions))
+ .addRepartitionId(reassign = true)
+ case (true, Some(other)) =>
+ // forcePartitioning is an internal dev API; only HashPartitioning
is supported
+ // today (see CTERelationDef.forcePartitioning).
+ throw new UnsupportedOperationException(
+ s"CTERelationDef.forcePartitioning supports only
HashPartitioning, but got " +
+ s"${other.getClass.getSimpleName}")
+ case (true, None) =>
+ // A forced CTE must always carry the plan-reuse identity so every
reference seals into
+ // a CTEReuseRelation and materializes once, whatever the body's
root shape. Stamp an
+ // existing reusable repartition in place (no extra shuffle); wrap
any other root -- a
+ // plain repartition, a rebalance, or a merged-subquery body -- in
a plan-reuse
+ // RepartitionByExpression.
+ inlined match {
+ case r: PlanReusableRepartition => r.addRepartitionId()
+ case _ =>
+ RepartitionByExpression(Seq.empty, inlined, None)
Review Comment:
**Non-blocking (P2):** This wrapper is added to every forced CTE whose root
is not a `RepartitionByExpression`, whatever
`spark.sql.optimizer.replaceCTERefWithCTEReuse.enabled` says, because only
`ReplaceRepartitionWithCTEReuse` reads the flag. With the flag off nothing is
sealed, but a forced CTE rooted at `REPARTITION(3)` or `REBALANCE` still gets
an extra round-robin shuffle over the user's shuffle. Measured locally against
the merge base, that is 2 live shuffles instead of 1 for a single reference,
and 4 instead of 3 for a self-join. So turning the flag off does not roll the
change back. The existing flag-off test uses a non-forced CTE, so it cannot see
this.
See **Shared repair plan 1** in the review body.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/CTEReuseExchange.scala:
##########
@@ -0,0 +1,86 @@
+/*
+ * 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.exchange
+
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, SortOrder}
+import org.apache.spark.sql.catalyst.plans.physical.Partitioning
+import org.apache.spark.sql.catalyst.trees.TreePattern._
+import org.apache.spark.sql.execution.{LeafExecNode, SparkPlan}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanExec
+import org.apache.spark.sql.vectorized.ColumnarBatch
+
+/**
+ * A physical leaf node representing a reference to a shared CTE subplan. The
[[subplan]]
+ * (a [[ShuffleExchangeExec]] over the CTE body, produced from the Repartition
in
+ * [[CTEReuseRelation.sharedSubplan]]) is stored as metadata, not as a tree
child, so
+ * AQE's bottom-up stage creation does not traverse into it and redundantly
plan its
+ * substages before CTEReuseExchange is resolved.
+ *
+ * Consumed before execution:
+ * - AQE on: `createNonResultQueryStages` replaces this with
[[CTEReuseQueryStageExec]],
+ * building a shared inner [[AdaptiveSparkPlanExec]] keyed by `cteId`.
+ * - AQE off: `UnwrapCTEReuseExchange` replaces each ref with its
`LOCAL_SHUFFLE_FOR_CTE`
+ * shuffle (tagged with `cteId`); the final `ReuseExchangeAndSubquery` then
reuses the
+ * canonically-equal copies via [[ReusedExchangeExec]], and
`VerifyCTEReuse` confirms it held.
+ *
+ * @param cteId Unique identifier for the CTE definition to be reused.
+ * @param subplan The [[ShuffleExchangeLike]] over the shared subplan. Its
logical link
+ * points to the Repartition in
[[CTEReuseRelation.sharedSubplan]] so the
+ * inner AQE can re-optimize correctly.
+ * @param innerAQE The shared inner [[AdaptiveSparkPlanExec]] for this
`cteId`, attached by
+ * `PlanCTEReuse` (AQE on only) from
`AdaptiveExecutionContext.cteAQERegistry`.
+ * It is auxiliary metadata carried on the node; the registry,
shared across the
+ * main query and its subqueries, remains the source of truth.
`None` before
+ * `PlanCTEReuse` runs and on the AQE-off path.
+ */
+case class CTEReuseExchange(
+ cteId: Long,
+ subplan: ShuffleExchangeLike,
+ innerAQE: Option[AdaptiveSparkPlanExec] = None
+) extends LeafExecNode {
Review Comment:
**Non-blocking (P2):** `CTEReuseExchange` is a `LeafExecNode` that keeps the
CTE shuffle in a field, so `InsertAdaptiveSparkPlan.shouldApplyAQE` finds no
`Exchange` in a plan whose only shuffles are inside forced CTEs. I checked this
locally: `WITH c AS (...) SELECT * FROM c UNION ALL SELECT * FROM c` runs under
`AdaptiveSparkPlanExec` on the merge base but not with this PR. Such queries
lose AQE for the whole plan, including inside the CTE body, and fall back to
the best-effort non-AQE reuse path, so the PR's AQE-on guarantee does not hold
for them: with `spark.sql.exchange.reuse=false`, the two references still saw
different values from a nondeterministic body. Treating `CTEReuseExchange` (or
the `CTE_REUSE_EXCHANGE` pattern) as an exchange in `shouldApplyAQE` would keep
these queries adaptive.
**Verification:**
- **Regression:** With AQE enabled, a UNION ALL of two references to a
forced CTE produces an adaptive plan whose CTE reuse query stages share one
inner adaptive plan.
- **Compatibility:** Queries without forced CTEs get the same AQE
applicability decision as before.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/CTEReuseExchange.scala:
##########
@@ -0,0 +1,86 @@
+/*
+ * 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.exchange
+
+import org.apache.spark.rdd.RDD
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.expressions.{Attribute, SortOrder}
+import org.apache.spark.sql.catalyst.plans.physical.Partitioning
+import org.apache.spark.sql.catalyst.trees.TreePattern._
+import org.apache.spark.sql.execution.{LeafExecNode, SparkPlan}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanExec
+import org.apache.spark.sql.vectorized.ColumnarBatch
+
+/**
+ * A physical leaf node representing a reference to a shared CTE subplan. The
[[subplan]]
+ * (a [[ShuffleExchangeExec]] over the CTE body, produced from the Repartition
in
+ * [[CTEReuseRelation.sharedSubplan]]) is stored as metadata, not as a tree
child, so
+ * AQE's bottom-up stage creation does not traverse into it and redundantly
plan its
+ * substages before CTEReuseExchange is resolved.
+ *
+ * Consumed before execution:
+ * - AQE on: `createNonResultQueryStages` replaces this with
[[CTEReuseQueryStageExec]],
+ * building a shared inner [[AdaptiveSparkPlanExec]] keyed by `cteId`.
+ * - AQE off: `UnwrapCTEReuseExchange` replaces each ref with its
`LOCAL_SHUFFLE_FOR_CTE`
+ * shuffle (tagged with `cteId`); the final `ReuseExchangeAndSubquery` then
reuses the
+ * canonically-equal copies via [[ReusedExchangeExec]], and
`VerifyCTEReuse` confirms it held.
+ *
+ * @param cteId Unique identifier for the CTE definition to be reused.
+ * @param subplan The [[ShuffleExchangeLike]] over the shared subplan. Its
logical link
+ * points to the Repartition in
[[CTEReuseRelation.sharedSubplan]] so the
+ * inner AQE can re-optimize correctly.
+ * @param innerAQE The shared inner [[AdaptiveSparkPlanExec]] for this
`cteId`, attached by
+ * `PlanCTEReuse` (AQE on only) from
`AdaptiveExecutionContext.cteAQERegistry`.
+ * It is auxiliary metadata carried on the node; the registry,
shared across the
+ * main query and its subqueries, remains the source of truth.
`None` before
+ * `PlanCTEReuse` runs and on the AQE-off path.
+ */
+case class CTEReuseExchange(
+ cteId: Long,
+ subplan: ShuffleExchangeLike,
+ innerAQE: Option[AdaptiveSparkPlanExec] = None
Review Comment:
**Nit (P3):** Nothing in Apache Spark reads `innerAQE`. Stage creation looks
the inner AQE up in `cteAQERegistry`, canonicalization clears the field, and
the only reader the comments name is 'the Photon compiler'. For no effect here,
it costs a copy of every `CTEReuseExchange` on each preprocessing pass and a
canonicalization special case. Please drop it, or give it an in-tree reader.
**Verification:**
- **Compile:** No code refers to the removed field.
- **Behavior:** AQE CTE reuse tests still show that all references to a CTE
share one inner adaptive plan.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/QueryStageExec.scala:
##########
@@ -312,6 +312,84 @@ case class TableCacheQueryStageExec(
override def getRuntimeStatistics: Statistics =
inMemoryTableScan.runtimeStatistics
}
+/**
+ * A query stage that wraps the shared inner [[AdaptiveSparkPlanExec]]
materializing a reused CTE.
+ *
+ * All references to one CTE share a single inner AQE (keyed by cteId in
+ * [[AdaptiveExecutionContext.cteAQERegistry]]); each reference gets its own
stage whose `output`
+ * remaps the inner AQE's (primary reference's) attribute ids into this
reference's id space,
+ * mirroring [[org.apache.spark.sql.execution.exchange.ReusedExchangeExec]].
Execution and stats
+ * delegate to the inner AQE's post-iteration `executedPlan` so that going
through the inner AQE's
+ * own `execute()` (which would re-run iteration and wrap a result stage) is
avoided.
+ */
+case class CTEReuseQueryStageExec(
+ override val id: Int,
+ innerAQE: AdaptiveSparkPlanExec,
+ override val output: Seq[Attribute]) extends QueryStageExec {
+
+ override val plan: SparkPlan = innerAQE
Review Comment:
**Non-blocking (P2):** `doExecute` and `doExecuteColumnar` read
`innerAQE.executedPlan`, but `executeCollect`, `executeTake`, `executeTail` and
`executeToIterator` are still inherited from `QueryStageExec` and call the same
methods on `plan = innerAQE`. That re-drives the shared inner AQE through
`withFinalPlanUpdate` and puts a `ResultQueryStageExec` at its root, so every
other reference to the CTE breaks. Reproduced locally with AQE on: `SELECT id
FROM c UNION ALL SELECT c.id FROM c JOIN (SELECT id FROM side WHERE id > 100) e
ON c.id = e.id` fails with `[INTERNAL_ERROR] Expected ShuffleQueryStageExec at
the root of CTE inner AQE, but got ResultQueryStageExec`. Here AQE empties the
join branch, which makes the CTE stage itself the result stage. With the flag
off, the same query returns its rows. Overriding these four methods to use
`innerAQE.executedPlan` would close the gap.
**Verification:**
- **Regression:** With AQE on, a UNION ALL of a forced CTE reference and a
join of another reference with a side that is empty at runtime returns the
first branch's rows on the first and on a repeated collect, and the shared
inner adaptive plan still ends in a shuffle query stage.
- **Behavior:** Calling executeCollect, executeTake, executeTail and
executeToIterator directly on a CTE reuse stage returns the expected rows and
leaves the statistics of every other reference to the same CTE readable.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/ShuffleExchangeExec.scala:
##########
@@ -69,6 +69,18 @@ trait ShuffleExchangeLike extends Exchange {
*/
def shuffleOrigin: ShuffleOrigin
+ /**
+ * Whether this shuffle was inserted to guarantee CTE (subplan) reuse.
+ *
+ * When true, shuffle-planning rules must treat this exchange as immutable
so that all
+ * references to the same CTE stay canonically equal and dedup into a single
+ * `ReusedExchangeExec`. In particular `EnsureRequirements` must not rewrite
its
+ * partitioning in place; a consumer needing a different partitioning wraps
it in a new
+ * shuffle instead. A shuffle qualifies when it carries the
`LOCAL_SHUFFLE_FOR_CTE`
+ * origin, which is only produced for CTE reuse.
+ */
+ def isCreatedForSubplanReuse: Boolean =
shuffleOrigin.isInstanceOf[LOCAL_SHUFFLE_FOR_CTE]
Review Comment:
**Non-blocking (P2):** `isCreatedForSubplanReuse` documents this shuffle as
immutable for shuffle-planning rules, but `AQEEnablePipelinedShuffle` never
checks it. Its `hasRegularPrefix` also stops at `CTEReuseQueryStageExec.plan`,
a leaf `AdaptiveSparkPlanExec`, so the rule cannot see the regular CTE shuffle
below a CTE stage. Reproduced locally in a single-executor channel-shuffle
session with `spark.sql.shuffle.localPipelined.enabled=true`: a forced CTE
self-join pipelines both join exchanges above the CTE stages. The first collect
works, but collecting the same DataFrame again fails with 'A job that mixes a
pipelined shuffle dependency with a regular shuffle dependency is only
supported when every regular shuffle is a fully-materialized prefix below the
pipelined shuffles'. With the reuse flag off, nothing is pipelined.
**Verification:**
- **Regression:** In a single-executor local session with the pipelined
shuffle enabled and AQE on, a forced CTE self-join returns the same rows on its
first and on repeated collects of the same DataFrame, its inner root is a
regular shuffle query stage, and no outer exchange above a CTE stage is
pipelined.
- **Compatibility:** Plans without CTE reuse are still rewritten to
pipelined exactly as before.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/PlanCTEReuse.scala:
##########
@@ -0,0 +1,138 @@
+/*
+ * 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.exchange
+
+import org.apache.spark.SparkException
+import org.apache.spark.internal.Logging
+import org.apache.spark.internal.LogKeys.{QUERY_ID, QUERY_PLAN}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.SparkPlan
+
+/**
+ * Unwraps [[CTEReuseExchange]] nodes early in the preparation pipeline (AQE
off).
+ *
+ * Each [[CTEReuseExchange]] is replaced by its `subplan` (a
[[ShuffleExchangeExec]] with a
+ * `LOCAL_SHUFFLE_FOR_CTE(cteId)` origin over the CTE body). The `cteId` is
carried by the origin
+ * itself (set in `SparkStrategies` when the shuffle is built), so
[[VerifyCTEReuse]] can later
+ * confirm the copies were reused without relying on a tree-node tag -- the
origin survives node
+ * reconstructions (e.g. photonization rebuilding the shuffle) that would drop
a tag.
+ *
+ * After unwrapping, all preparation rules (EnsureRequirements, columnar
transitions, codegen,
+ * etc.) process each CTE subplan copy normally, in its own consumer context.
+ * The `LOCAL_SHUFFLE_FOR_CTE` shuffle serves as a protected boundary:
`EnsureRequirements` /
+ * `EnsureRequirementsDP` treat it as immutable (see
`ShuffleExchangeLike.isCreatedForSubplanReuse`)
+ * and add consumer-dependent operators above it, so the subtree below stays
canonically equal
+ * across copies. The final `ReuseExchangeAndSubquery` then reuses the copies
by canonical equality.
+ *
+ * Runs in both main and subquery preparation batches so CTE references inside
subqueries are also
+ * unwrapped.
+ */
+case object UnwrapCTEReuseExchange extends Rule[SparkPlan] with Logging {
+
+ override def apply(plan: SparkPlan): SparkPlan = {
+ // Plain transformUp (not transformUpWithSubqueries) is sufficient:
subquery plans are prepared
+ // separately by PlanSubqueries, which calls
QueryExecution.prepareExecutedPlan ->
+ // preparations(subquery = true), so this rule is applied recursively to
each subquery's plan
+ // on its own. We only need to handle CTEReuseExchange nodes in the
current plan tree here.
+ plan.transformUp {
+ case cte: CTEReuseExchange =>
+ // Recursively unwrap nested CTEReuseExchange nodes inside the subplan
+ // (e.g., cte_inner inside cte_outer's body).
+ val unwrapped = apply(cte.subplan)
+ unwrapped match {
+ case s: ShuffleExchangeLike if
s.shuffleOrigin.isInstanceOf[LOCAL_SHUFFLE_FOR_CTE] =>
+ // The cteId is already carried by the LOCAL_SHUFFLE_FOR_CTE
origin (SparkStrategies set
+ // it from the cteId in CTEReuseRelation); nothing extra to attach
here.
+ logDebug(log"CTE reuse (AQE off): unwrapped CTEReuseExchange for "
+
+ log"cteId=${MDC(QUERY_ID, cte.cteId)}")
Review Comment:
**Nit (P3):** `MDC(QUERY_ID, cte.cteId)` records a `RepartitionIdGenerator`
counter as a query id. `UnwrapCTEReuseExchange`'s error branch logs a node
name, and `VerifyCTEReuse` a cteId summary, under `QUERY_PLAN`. Structured-log
consumers that search those keys get the wrong values. A dedicated key such as
`CTE_ID`, with `QUERY_PLAN` kept for plan strings, would fix it.
**Verification:**
- **Inspection:** Every MDC in the CTE reuse rules labels its value with a
key that matches what the value is.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/PlanCTEReuse.scala:
##########
@@ -0,0 +1,138 @@
+/*
+ * 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.exchange
+
+import org.apache.spark.SparkException
+import org.apache.spark.internal.Logging
+import org.apache.spark.internal.LogKeys.{QUERY_ID, QUERY_PLAN}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.SparkPlan
+
+/**
+ * Unwraps [[CTEReuseExchange]] nodes early in the preparation pipeline (AQE
off).
+ *
+ * Each [[CTEReuseExchange]] is replaced by its `subplan` (a
[[ShuffleExchangeExec]] with a
+ * `LOCAL_SHUFFLE_FOR_CTE(cteId)` origin over the CTE body). The `cteId` is
carried by the origin
+ * itself (set in `SparkStrategies` when the shuffle is built), so
[[VerifyCTEReuse]] can later
+ * confirm the copies were reused without relying on a tree-node tag -- the
origin survives node
+ * reconstructions (e.g. photonization rebuilding the shuffle) that would drop
a tag.
+ *
+ * After unwrapping, all preparation rules (EnsureRequirements, columnar
transitions, codegen,
+ * etc.) process each CTE subplan copy normally, in its own consumer context.
+ * The `LOCAL_SHUFFLE_FOR_CTE` shuffle serves as a protected boundary:
`EnsureRequirements` /
+ * `EnsureRequirementsDP` treat it as immutable (see
`ShuffleExchangeLike.isCreatedForSubplanReuse`)
+ * and add consumer-dependent operators above it, so the subtree below stays
canonically equal
+ * across copies. The final `ReuseExchangeAndSubquery` then reuses the copies
by canonical equality.
+ *
+ * Runs in both main and subquery preparation batches so CTE references inside
subqueries are also
+ * unwrapped.
+ */
+case object UnwrapCTEReuseExchange extends Rule[SparkPlan] with Logging {
+
+ override def apply(plan: SparkPlan): SparkPlan = {
+ // Plain transformUp (not transformUpWithSubqueries) is sufficient:
subquery plans are prepared
+ // separately by PlanSubqueries, which calls
QueryExecution.prepareExecutedPlan ->
+ // preparations(subquery = true), so this rule is applied recursively to
each subquery's plan
+ // on its own. We only need to handle CTEReuseExchange nodes in the
current plan tree here.
+ plan.transformUp {
Review Comment:
**Blocking (P1):** This `transformUp`, and `VerifyCTEReuse`'s
`foreachWithSubqueries` below, run without tree-pattern pruning in every
non-AQE preparation. So any query that AQE does not wrap pays two extra
full-plan traversals even when it has no forced CTE. That covers AQE off, plans
`InsertAdaptiveSparkPlan` skips, and every streaming micro-batch, since
`IncrementalExecution` inherits this rule list. Neither traversal existed
before, and ordinary queries gain nothing from them.
`transformUpWithPruning(_.containsPattern(CTE_REUSE_EXCHANGE))`, plus running
the verifier only when something was unwrapped, would restore the previous cost.
**Verification:**
- **Regression:** For a non-AQE plan without forced CTEs, the CTE unwrap and
verification rules visit no nodes beyond the root pattern check.
- **Compatibility:** Forced CTE references in the main query, in subqueries
and in nested bodies are still unwrapped and reused.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/exchange/PlanCTEReuse.scala:
##########
@@ -0,0 +1,138 @@
+/*
+ * 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.exchange
+
+import org.apache.spark.SparkException
+import org.apache.spark.internal.Logging
+import org.apache.spark.internal.LogKeys.{QUERY_ID, QUERY_PLAN}
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.execution.SparkPlan
+
+/**
+ * Unwraps [[CTEReuseExchange]] nodes early in the preparation pipeline (AQE
off).
+ *
+ * Each [[CTEReuseExchange]] is replaced by its `subplan` (a
[[ShuffleExchangeExec]] with a
+ * `LOCAL_SHUFFLE_FOR_CTE(cteId)` origin over the CTE body). The `cteId` is
carried by the origin
+ * itself (set in `SparkStrategies` when the shuffle is built), so
[[VerifyCTEReuse]] can later
+ * confirm the copies were reused without relying on a tree-node tag -- the
origin survives node
+ * reconstructions (e.g. photonization rebuilding the shuffle) that would drop
a tag.
+ *
+ * After unwrapping, all preparation rules (EnsureRequirements, columnar
transitions, codegen,
+ * etc.) process each CTE subplan copy normally, in its own consumer context.
+ * The `LOCAL_SHUFFLE_FOR_CTE` shuffle serves as a protected boundary:
`EnsureRequirements` /
+ * `EnsureRequirementsDP` treat it as immutable (see
`ShuffleExchangeLike.isCreatedForSubplanReuse`)
+ * and add consumer-dependent operators above it, so the subtree below stays
canonically equal
+ * across copies. The final `ReuseExchangeAndSubquery` then reuses the copies
by canonical equality.
+ *
+ * Runs in both main and subquery preparation batches so CTE references inside
subqueries are also
+ * unwrapped.
+ */
+case object UnwrapCTEReuseExchange extends Rule[SparkPlan] with Logging {
Review Comment:
**Nit (P3):** This file defines only `UnwrapCTEReuseExchange` and
`VerifyCTEReuse`. The rule actually named `PlanCTEReuse` lives in the sibling
`adaptive/PlanCTEReuse.scala`, so two files with the same name hold unrelated
halves of the feature. Renaming this one, for example to
`UnwrapCTEReuseExchange.scala` or `CTEReuseRules.scala`, would make both easy
to find.
**Verification:**
- **Inspection:** No two files with the same name and unrelated contents
implement the two halves of the CTE reuse feature.
- **Compile:** Every reference to the AQE-off CTE reuse rules still resolves
after the rename.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala:
##########
@@ -1222,6 +1222,34 @@ abstract class SparkStrategies extends
QueryPlanner[SparkPlan] {
f, key, lObj, rObj, lGroup, rGroup, lAttr, rAttr, lOrder, rOrder,
oAttr,
planLater(left), planLater(right)) :: Nil
+ case r: logical.CTEReuseRelation =>
+ // Plan the shared subplan eagerly rather than using planLater, because
+ // CTEReuseExchange is a LeafExecNode whose subplan is not visible via
+ // children. QueryPlanner.collectPlaceholders would never find a
PlanLater
+ // inside the subplan, leaving it unresolved.
+ //
+ // sharedSubplan is a RepartitionByExpression wrapping the CTE body.
Planning it produces a
+ // plain ShuffleExchangeExec; we re-stamp its origin as
LOCAL_SHUFFLE_FOR_CTE(cteId) so the
+ // shuffle is recognized as a reuse boundary
(`isCreatedForSubplanReuse`) by
+ // EnsureRequirements, the AQE-off unwrap path, and VerifyCTEReuse. We
use this shuffle
+ // directly instead of adding a separate exchange -- the Repartition
serves as the logical
+ // plan for the inner AQE, enabling correct re-optimization (broadcast
stages are findable
+ // as descendants, and re-planning preserves the root exchange).
+ val physicalSubplan = SparkStrategies.this.plan(r.sharedSubplan).next()
+ val shuffle = physicalSubplan match {
+ case s: ShuffleExchangeExec => s.copy(shuffleOrigin =
LOCAL_SHUFFLE_FOR_CTE(r.cteId))
Review Comment:
**Non-blocking (P2):** The `LOCAL_SHUFFLE_FOR_CTE` origin is stamped only on
this initially planned shuffle. When a stage inside the CTE body materializes,
the inner AQE re-plans the root from the plain `RepartitionByExpression`, which
gets an ordinary origin. `EnsureRequirements` then removes a
`REPARTITION_BY_COL` or `REPARTITION_BY_NUM` hash shuffle that the body already
satisfies, and `CTEReuseQueryStageExec` finds no shuffle stage at the root.
Reproduced locally with AQE on: `WITH c AS (SELECT /*+ REPARTITION(k) */ k,
count(*) AS cnt FROM t GROUP BY k) SELECT ... FROM c c1 JOIN c c2 ON c1.k =
c2.k` fails with `[INTERNAL_ERROR] Expected ShuffleQueryStageExec at the root
of CTE inner AQE, but got HashAggregateExec`. The same body pinned with
`forcePartitioning` on `k` fails the same way. With the flag off, and on the
merge base, both return the right rows. Bodies whose root survives still lose
the CTE origin after the first replan; it became `REBALANCE_PARTITIONS_BY_NONE`.
**Recommended change:** Make the shared inner adaptive plan keep its root
reuse shuffle on every replan. When the inner plan's input is a
LOCAL_SHUFFLE_FOR_CTE shuffle, re-stamp the replanned root shuffle with the
same LOCAL_SHUFFLE_FOR_CTE(cteId) origin before the preparation rules run. If a
replan no longer has a shuffle at the root, reject it and keep the current
plan. Add AQE tests for pinned and hash-hinted forced CTEs whose body contains
a stage.
**Why this works:** Because the origin is restored before EnsureRequirements
runs, its optimize-out rule, which matches only REPARTITION_BY_COL/NUM origins,
cannot remove the root, and the AQE read rules keep excluding it.
CTEReuseQueryStageExec therefore always sees a shuffle-stage root. This follows
the existing restoration of a root BroadcastExchangeLike in reOptimize.
**Scope:** Keep the CTE reuse root intact across inner-AQE replans.
**Compatibility:** Forced CTE bodies without stages, and adaptive plans
without CTEs, replan exactly as before.
**Risks:** A replan that would have been adopted for a cheaper body plan is
rejected when it removes the root shuffle, so the CTE keeps one shuffle that
the merge target might have optimized out.
**Constraints:** The restored root must keep the sealed partitioning and the
cteId.
**Exclusions:**
- A root that collapses to an empty relation, which is raised separately as
a question.
**Success:** Under AQE, a forced CTE pinned on, or hinted to repartition by,
its body's grouping key materializes through a shuffle stage and returns
correct results. Inner-AQE replans never change the origin of the root shuffle.
**Verification:**
- **Regression:** With AQE on, a self-joined forced CTE with
forcePartitioning on its GROUP BY key and the default partition count completes
with the expected rows, and its inner adaptive plan ends in a shuffle query
stage.
- **Regression:** The same holds for an unpinned forced CTE whose body
carries a REPARTITION hint on its grouping key.
- **Behavior:** After every inner replan, the root shuffle's origin is still
the CTE reuse origin for that cteId.
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/InsertAdaptiveSparkPlan.scala:
##########
@@ -48,7 +48,8 @@ case class InsertAdaptiveSparkPlan(
override def apply(plan: SparkPlan): SparkPlan = applyInternal(plan, false)
- private def applyInternal(plan: SparkPlan, isSubquery: Boolean): SparkPlan =
plan match {
+ // Not private: `PlanCTEReuse` calls this to wrap a `CTEReuseExchange`'s
subplan in an inner AQE.
+ def applyInternal(plan: SparkPlan, isSubquery: Boolean): SparkPlan = plan
match {
Review Comment:
**Nit (P3):** `applyInternal` goes from private to public for
`PlanCTEReuse`, which is in the same package, so `private[adaptive]` would do.
`AdaptiveSparkPlanExec` also gains a public `materialize()`, and a public
`skipResultStage` flag on `withFinalPlanUpdate[T]`. With that flag set, the
method never calls `fun` and returns `().asInstanceOf[T]`, so a caller outside
this package can get a mistyped value. All callers are in
`org.apache.spark.sql.execution.adaptive`.
**Verification:**
- **Compile:** Code outside the adaptive package cannot call applyInternal,
materialize, or a skip-result variant of withFinalPlanUpdate.
- **Behavior:** AQE CTE reuse tests still show shared CTE materialization
working.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -7195,6 +7195,33 @@ object SQLConf {
.booleanConf
.createWithDefault(false)
+ val REPLACE_CTE_REF_WITH_CTE_REUSE =
+ buildConf("spark.sql.optimizer.replaceCTERefWithCTEReuse.enabled")
+ .internal()
+ .doc("When true, plan-reuse repartitions (those carrying a nonzero
repartition id, " +
+ "currently produced only for CTE definitions marked forceSkipInline)
are converted to " +
+ "CTEReuse nodes instead of plain Repartition nodes; ordinary retained
or user " +
+ "MATERIALIZED CTEs keep plain repartitions and are unaffected.
CTEReuse enables " +
+ "guaranteed exchange reuse in AQE by sharing a single inner
AdaptiveSparkPlanExec " +
+ "across all references.")
+ .version("4.4.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .booleanConf
+ .createWithDefault(true)
+
+ val FAIL_ON_CTE_REUSE_WITHOUT_AQE =
+ buildConf("spark.sql.optimizer.failOnCTEReuseWithoutAQE.enabled")
Review Comment:
**Nit (P3):** The three new keys sit in unrelated namespaces
(`spark.sql.optimizer.replaceCTERefWithCTEReuse.enabled`,
`spark.sql.optimizer.failOnCTEReuseWithoutAQE.enabled` and
`spark.sql.cteMaterialization.maxThreadThreshold`), and this boolean puts a
verb before `.enabled`. That goes against the naming guideline in
`ConfigEntry.scala`. The keys are internal and unreleased, so they can still
move under one feature namespace cheaply.
**Verification:**
- **Inspection:** Every new CTE reuse config key sits under one namespace,
and every boolean key is either the umbrella enabled switch or reads as a verb.
- **Compile:** No code, test or documentation still refers to an old key
name.
##########
sql/core/src/test/scala/org/apache/spark/sql/CTEReuseWithAQESuite.scala:
##########
@@ -0,0 +1,558 @@
+/*
+ * 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
+
+import org.apache.spark.sql.catalyst.plans.logical.{CTERelationDef,
CTEReuseRelation}
+import org.apache.spark.sql.execution.adaptive.{AdaptiveSparkPlanExec,
AdaptiveSparkPlanHelper}
+import org.apache.spark.sql.execution.adaptive.AQEShuffleReadExec
+import org.apache.spark.sql.execution.adaptive.CTEReuseQueryStageExec
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+
+/**
+ * End-to-end tests for CTE reuse through AQE: verifies that
CTEReuseQueryStageExec instances
+ * reusing one CTE share the same inner AdaptiveSparkPlanExec, and that AQE
re-optimization
+ * interacts properly with CTE reuse stages.
+ */
+class CTEReuseWithAQESuite
+ extends QueryTest with SharedSparkSession
+ with AdaptiveSparkPlanHelper {
+
+ private val cteReuseConf =
+ "spark.sql.optimizer.replaceCTERefWithCTEReuse.enabled"
+
+ private def withCTEReuseEnabled(f: => Unit): Unit = {
+ withSQLConf(
+ cteReuseConf -> "true",
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true"
+ )(f)
+ }
+
+ /**
+ * Verify the single-materialization invariant: all
[[CTEReuseQueryStageExec]] stages reusing one
+ * CTE point to the same inner AdaptiveSparkPlanExec by identity, so a CTE
referenced N times
+ * creates N stage wrappers but is materialized exactly once.
`expectedDistinctCTEs` is the number
+ * of distinct CTE definitions (default 1).
+ */
+ private def assertInnerAQEShared(
+ df: DataFrame,
+ expectedDistinctCTEs: Int = 1,
+ allowEmpty: Boolean = false): Unit = {
+ val executedPlan = df.queryExecution.executedPlan
+ val stages = collectWithSubqueries(executedPlan) {
+ case s: CTEReuseQueryStageExec => s
+ }
+ if (stages.isEmpty) {
+ // Only callers that legitimately prune all CTE stages (e.g.
empty-relation propagation)
+ // may pass here; otherwise a missing-stages regression must fail, not
pass vacuously.
+ assert(allowEmpty,
+ s"Expected CTEReuseQueryStageExec stages but found
none:\n${executedPlan.treeString}")
+ return
+ }
+ val distinctAQEs = stages.map(s =>
+ System.identityHashCode(s.innerAQE)).toSet
+ assert(distinctAQEs.size == expectedDistinctCTEs,
+ s"Expected $expectedDistinctCTEs distinct inner AQE(s) " +
+ s"for ${stages.size} CTEReuseQueryStageExec, " +
+ s"but found ${distinctAQEs.size}.\n" +
+ s"Plan:\n${executedPlan.treeString}")
+ }
+
+ private def collectCTEReuseRelations(
+ df: DataFrame): Seq[CTEReuseRelation] = {
+ df.queryExecution.optimizedPlan.collectWithSubqueries {
+ case r: CTEReuseRelation => r
+ }
+ }
+
+ /**
+ * Runs `text` with every CTE forced to materialize (`forceSkipInline =
true`) so it opts into
+ * guaranteed CTE shuffle reuse. `forceSkipInline` has no SQL syntax, so tag
the analyzed plan's
+ * CTE definitions and re-run the tagged plan.
+ */
+ private def sqlWithForcedCTEReuse(text: String): DataFrame = {
+ val analyzed = spark.sql(text).queryExecution.analyzed
+ val tagged = analyzed.transformWithSubqueries {
+ case d: CTERelationDef => d.copy(forceSkipInline = true)
+ }
+ classic.Dataset.ofRows(spark, tagged)
+ }
+
+ // -----------------------------------------------------------------
+ // Inner-AQE sharing tests
+ // -----------------------------------------------------------------
+
+ test("CTE reuse: 2 refs share one inner AQE") {
+ withCTEReuseEnabled {
+ withTable("metric_src") {
+ sql("CREATE TABLE metric_src (id INT) USING parquet")
+ sql(
+ "INSERT INTO metric_src VALUES (1), (2), (3), (4), (5)")
+ val df = sqlWithForcedCTEReuse(
+ """WITH cte AS (
+ | SELECT id, rand() as r FROM metric_src
+ |)
+ |SELECT * FROM cte c1 JOIN cte c2 ON c1.id = c2.id
+ |""".stripMargin)
+ df.collect()
+ // Two refs create two CTEReuseQueryStageExec wrappers, both sharing
one inner AQE.
+ assert(collectWithSubqueries(df.queryExecution.executedPlan) {
+ case s: CTEReuseQueryStageExec => s
+ }.size == 2)
+ assertInnerAQEShared(df)
+ }
+ }
+ }
+
+ test("CTE reuse when subplan produces empty result") {
+ withCTEReuseEnabled {
+ withTable("metric_empty_src") {
+ sql("CREATE TABLE metric_empty_src (id INT) USING parquet")
+ sql("INSERT INTO metric_empty_src VALUES (1), (2), (3)")
+ val df = sqlWithForcedCTEReuse(
+ """WITH cte AS (
+ | SELECT id, rand() as r
+ | FROM metric_empty_src WHERE id > 100
+ |)
+ |SELECT * FROM cte c1 JOIN cte c2 ON c1.id = c2.id
+ |""".stripMargin)
+ checkAnswer(df, Seq.empty)
+ // Any surviving CTE stages (empty-relation propagation may prune
some) share one inner AQE.
+ assertInnerAQEShared(df, allowEmpty = true)
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // AQE empty relation propagation
+ // -----------------------------------------------------------------
+
+ test("AQE empty relation propagation eliminates CTE consumers") {
+ withCTEReuseEnabled {
+ withSQLConf(
+ SQLConf.OPTIMIZER_EXCLUDED_RULES.key ->
+ ("org.apache.spark.sql.catalyst.optimizer" +
+ ".PropagateEmptyRelation")
+ ) {
+ withTable("cte_nonempty", "join_side") {
+ sql(
+ "CREATE TABLE cte_nonempty (id INT) USING parquet")
+ sql(
+ "INSERT INTO cte_nonempty VALUES (1), (2), (3), (4), (5)")
+ sql("CREATE TABLE join_side (id INT) USING parquet")
+ sql("INSERT INTO join_side VALUES (1), (2), (3)")
+ val df = sqlWithForcedCTEReuse(
+ """WITH cte AS (
+ | SELECT id, rand() as r FROM cte_nonempty
+ |)
+ |SELECT c1.id FROM cte c1
+ |INNER JOIN (
+ | SELECT id FROM join_side WHERE id > 100
+ |) e1 ON c1.id = e1.id
+ |UNION ALL
+ |SELECT c2.id FROM cte c2
+ |INNER JOIN (
+ | SELECT id FROM join_side WHERE id > 100
+ |) e2 ON c2.id = e2.id
+ |""".stripMargin)
+ checkAnswer(df, Seq.empty)
+ val aqe = df.queryExecution.executedPlan
+ .asInstanceOf[AdaptiveSparkPlanExec]
+ val finalPlan = aqe.executedPlan
+
+ // 1. All CTEReuseQueryStageExec removed from final plan.
+ val remaining = finalPlan.collect {
+ case s: CTEReuseQueryStageExec => s
+ }
+ assert(remaining.isEmpty,
+ "Expected all CTEReuseQueryStageExec removed " +
+ "after empty relation propagation, " +
+ s"but found ${remaining.size}." +
+ s"\nFinal plan:\n${finalPlan.treeString}")
+
+ // 2. Inner AQE was created (registry entry exists).
+ val registry = aqe.context.cteAQERegistry
+ assert(registry.nonEmpty,
+ "Expected cteAQERegistry to contain inner AQE")
+ }
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // q24-style: CTE in main query + subquery
+ // -----------------------------------------------------------------
+
+ test("q24-style: main query + HAVING subquery") {
+ withCTEReuseEnabled {
+ withTable("sales", "items") {
+ sql(
+ "CREATE TABLE sales (item_id INT, amount INT) USING parquet")
+ sql(
+ """INSERT INTO sales VALUES
+ |(1, 10), (1, 20), (2, 30), (2, 40),
+ |(3, 50), (3, 60)""".stripMargin)
+ sql(
+ "CREATE TABLE items (id INT, color STRING) USING parquet")
+ sql(
+ """INSERT INTO items VALUES
+ |(1, 'red'), (2, 'blue'), (3, 'red')""".stripMargin)
+
+ val df = sqlWithForcedCTEReuse(
+ """WITH ssales AS (
+ | SELECT s.item_id, i.color,
+ | sum(s.amount) as total, rand() as r
+ | FROM sales s JOIN items i ON s.item_id = i.id
+ | GROUP BY s.item_id, i.color
+ |)
+ |SELECT item_id, sum(total) as paid
+ |FROM ssales
+ |WHERE color = 'red'
+ |GROUP BY item_id
+ |HAVING sum(total) > (
+ | SELECT 0.05 * avg(total) FROM ssales
+ |)
+ |""".stripMargin)
+
+ val reuses = collectCTEReuseRelations(df)
+ assert(reuses.size >= 2,
+ s"Expected >= 2 CTEReuseRelation (main + subquery), " +
+ s"got ${reuses.size}.\nPlan:\n" +
+ df.queryExecution.optimizedPlan.treeString)
+ df.collect()
+ assertInnerAQEShared(df)
+ }
+ }
+ }
+
+ test("q24-style: main query + scalar subquery") {
+ withCTEReuseEnabled {
+ withTable("orders", "products") {
+ sql(
+ "CREATE TABLE orders (pid INT, qty INT) USING parquet")
+ sql(
+ """INSERT INTO orders VALUES
+ |(1, 5), (2, 10), (3, 15),
+ |(1, 20), (2, 25)""".stripMargin)
+ sql(
+ "CREATE TABLE products (id INT, cat STRING) USING parquet")
+ sql(
+ """INSERT INTO products VALUES
+ |(1, 'A'), (2, 'B'), (3, 'A')""".stripMargin)
+
+ val df = sqlWithForcedCTEReuse(
+ """WITH agg AS (
+ | SELECT o.pid, p.cat,
+ | sum(o.qty) as total, rand() as r
+ | FROM orders o JOIN products p ON o.pid = p.id
+ | GROUP BY o.pid, p.cat
+ |)
+ |SELECT pid, total FROM agg a1
+ |WHERE cat = 'A'
+ |AND total > (SELECT avg(total) FROM agg)
+ |""".stripMargin)
+
+ val reuses = collectCTEReuseRelations(df)
+ assert(reuses.size >= 2,
+ s"Expected >= 2 CTEReuseRelation (main + subquery), " +
+ s"got ${reuses.size}.\nPlan:\n" +
+ df.queryExecution.optimizedPlan.treeString)
+ df.collect()
+ assertInnerAQEShared(df)
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // AQE multi-iteration replan
+ // -----------------------------------------------------------------
+
+ test("AQE multi-iteration replan preserves CTE reuse") {
+ // Multiple CTE refs across joins force multiple AQE iterations.
+ // Disable broadcast to force shuffle joins. Verifies that
+ // re-optimization across iterations does not break sharing.
+ //
+ // Iter 0 logical plan -- all leaves are CTEReuseRelation:
+ // Project [a, b, a2, b2]
+ // +- Join Inner, (a = b2)
+ // :- Join Inner, (a = a2)
+ // : :- Join Inner, (a = b)
+ // : : :- CTEReuseRelation cteId=1 (cte_a ref 1)
+ // : : +- CTEReuseRelation cteId=2 (cte_b ref 1)
+ // : +- CTEReuseRelation cteId=1 (cte_a ref 2)
+ // +- CTEReuseRelation cteId=2 (cte_b ref 2)
+ //
+ // Iter 0 physical plan -- all CTEReuseRelation converted to
+ // CTEReuseQueryStageExec in createQueryStages:
+ // SortMergeJoin [a], [b2], Inner
+ // :- SortMergeJoin [a], [a2], Inner
+ // : :- SortMergeJoin [a], [b], Inner
+ // : : :- Exchange ENSURE_REQUIREMENTS
+ // : : : +- CTEReuseQueryStage 0 (innerAQE-1)
+ // : : +- Exchange ENSURE_REQUIREMENTS
+ // : : +- CTEReuseQueryStage 1 (innerAQE-2)
+ // : +- Exchange ENSURE_REQUIREMENTS
+ // : +- CTEReuseQueryStage 2 (innerAQE-1)
+ // +- Exchange ENSURE_REQUIREMENTS
+ // +- CTEReuseQueryStage 3 (innerAQE-2)
+ //
+ // Iter 0 re-optimize input -- all CTEReuseRelation wrapped in
+ // LogicalQueryStage with runtime stats visible:
+ // Project [a, b, a2, b2]
+ // +- Join Inner, (a = b2)
+ // :- Join Inner, (a = a2)
+ // : :- Join Inner, (a = b)
+ // : : :- LogicalQueryStage CTEReuseRelation cteId=1,
+ // : : : CTEReuseQueryStage 0
+ // : : +- LogicalQueryStage CTEReuseRelation cteId=2,
+ // : : CTEReuseQueryStage 1
+ // : +- LogicalQueryStage CTEReuseRelation cteId=1,
+ // : CTEReuseQueryStage 2
+ // +- LogicalQueryStage CTEReuseRelation cteId=2,
+ // CTEReuseQueryStage 3
+ withCTEReuseEnabled {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_LOG_LEVEL.key -> "INFO",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "5"
+ ) {
+ withTable("replan_a", "replan_b") {
+ sql(
+ "CREATE TABLE replan_a (a INT, ka INT) USING parquet")
+ sql(
+ "CREATE TABLE replan_b (b INT, kb INT) USING parquet")
+ (1 to 50).foreach { i =>
+ sql(s"INSERT INTO replan_a VALUES ($i, $i)")
+ }
+ (1 to 30).foreach { i =>
+ sql(s"INSERT INTO replan_b VALUES ($i, $i)")
+ }
+
+ // CTE A referenced twice (c1 + c3), CTE B twice (c2 + c4).
+ // Join c1-c2 on a=b, then join with c3 on a=a, then join
+ // with c4 on a=b. Forces 2+ AQE iterations.
+ val df = sqlWithForcedCTEReuse(
+ """WITH
+ | cte_a AS (
+ | SELECT a, ka, rand() as r FROM replan_a
+ | ),
+ | cte_b AS (
+ | SELECT b, kb, rand() as r FROM replan_b
+ | )
+ |SELECT c1.a, c2.b, c3.a as a2, c4.b as b2
+ |FROM cte_a c1
+ |JOIN cte_b c2 ON c1.a = c2.b
+ |JOIN cte_a c3 ON c1.a = c3.a
+ |JOIN cte_b c4 ON c1.a = c4.b
+ |""".stripMargin)
+
+ val reuses = collectCTEReuseRelations(df)
+ assert(reuses.size >= 4,
+ s"Expected >= 4 CTEReuseRelation, " +
+ s"got ${reuses.size}.\nPlan:\n" +
+ df.queryExecution.optimizedPlan.treeString)
+
+ df.collect()
+ assertInnerAQEShared(df, expectedDistinctCTEs = 2)
+ }
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // Nested CTEs
+ // -----------------------------------------------------------------
+
+ test("nested CTEs: outer CTE body references inner CTE") {
+ // Inner CTE (cte_inner) is defined inside outer CTE (cte_outer)'s
+ // body. Both have rand() to prevent inlining. The outer CTE is
+ // referenced twice in the main query; the inner CTE is referenced
+ // twice inside the outer CTE's definition. This produces 2
+ // distinct CTE materializations in cteAQERegistry.
+ withCTEReuseEnabled {
+ withTable("nested_src") {
+ sql(
+ "CREATE TABLE nested_src (id INT, v INT) USING parquet")
+ sql(
+ """INSERT INTO nested_src VALUES
+ |(1, 10), (2, 20), (3, 30),
+ |(4, 40), (5, 50)""".stripMargin)
+
+ val df = sqlWithForcedCTEReuse(
+ """WITH
+ | cte_inner AS (
+ | SELECT id, v, rand() as ri FROM nested_src
+ | ),
+ | cte_outer AS (
+ | SELECT a.id, a.v + b.v as total, rand() as ro
+ | FROM cte_inner a JOIN cte_inner b ON a.id = b.id
+ | )
+ |SELECT c1.id, c2.id as id2
+ |FROM cte_outer c1
+ |JOIN cte_outer c2 ON c1.id = c2.id
+ |""".stripMargin)
+
+ val reuses = collectCTEReuseRelations(df)
+ // Only the outer CTE's 2 refs are visible at the top level.
+ // The inner CTE's refs live inside the outer CTE's
+ // sharedSubplan and materialize inside the outer CTE's
+ // inner AQE.
+ assert(reuses.size >= 2,
+ s"Expected >= 2 CTEReuseRelation (outer CTE refs)" +
+ s", got ${reuses.size}.\nPlan:\n" +
+ df.queryExecution.optimizedPlan.treeString)
+
+ df.collect()
+ // collectWithSubqueries descends into QueryStageExec.plan
+ // and AdaptiveSparkPlanExec.executedPlan, so it finds both
+ // outer CTE stages (2) and inner CTE stages inside the
+ // outer's inner AQE. 2 distinct inner AQEs total.
+ assertInnerAQEShared(df, expectedDistinctCTEs = 2)
+ // Both outer and inner CTE are in the shared registry.
+ val aqe = df.queryExecution.executedPlan
+ .asInstanceOf[AdaptiveSparkPlanExec]
+ val registry = aqe.context.cteAQERegistry
+ assert(registry.size == 2,
+ s"Expected 2 entries in cteAQERegistry " +
+ s"(outer + inner CTE), got ${registry.size}")
+ }
+ }
+ }
+
+ // -----------------------------------------------------------------
+ // Deeply nested CTEs (6 levels)
+ // -----------------------------------------------------------------
+
+ // -----------------------------------------------------------------
+ // LocalPartition CTE feeding a global aggregate
+ // -----------------------------------------------------------------
+
+ test("global aggregate directly over a LocalPartition CTE") {
Review Comment:
**Nit (P3):** This test is built on a `LocalPartition` partitioning and a
`RepartitionForCTEStage` hook, and neither exists in this codebase. Here an
unpinned forced CTE seals with
`RoundRobinPartitioning(spark.sql.shuffle.partitions)`. With the default 200
partitions, that never satisfies `AllTuples`, so `EnsureRequirements` always
adds an exchange above the CTE stage, and `localReads.isEmpty` cannot fail for
the regression the comment describes. `checkAnswer` is the only live check.
**Verification:**
- **Inspection:** The test mentions no LocalPartition,
RepartitionForCTEStage or internal tracker, and its plan assertion would fail
if the guarded plan property changed.
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -6869,6 +6869,33 @@ object SQLConf {
.booleanConf
.createWithDefault(false)
+ val REPLACE_CTE_REF_WITH_CTE_REUSE =
+ buildConf("spark.sql.optimizer.replaceCTERefWithCTEReuse.enabled")
+ .internal()
+ .doc("When true, plan-reuse repartitions (those carrying a nonzero
repartition id, " +
+ "currently produced only for CTE definitions marked forceSkipInline)
are converted to " +
+ "CTEReuse nodes instead of plain Repartition nodes; ordinary retained
or user " +
+ "MATERIALIZED CTEs keep plain repartitions and are unaffected.
CTEReuse enables " +
+ "guaranteed exchange reuse in AQE by sharing a single inner
AdaptiveSparkPlanExec " +
+ "across all references.")
+ .version("4.4.0")
+ .withBindingPolicy(ConfigBindingPolicy.SESSION)
+ .booleanConf
+ .createWithDefault(true)
Review Comment:
Thanks for updating the description. It now matches the default-on setting,
which resolves the inconsistency raised here. Some LocalPartition and Photon
references remain in code comments and tests; this review raises them
separately.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4237377076","thread_id":"inline:4237377076","verdict_sha256":"7971a94f9214bdf1857a39473550a560c323a2594d2e01d88f4a127ddd3021af"}
-->
##########
sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala:
##########
@@ -1196,6 +1196,34 @@ abstract class SparkStrategies extends
QueryPlanner[SparkPlan] {
f, key, lObj, rObj, lGroup, rGroup, lAttr, rAttr, lOrder, rOrder,
oAttr,
planLater(left), planLater(right)) :: Nil
+ case r: logical.CTEReuseRelation =>
+ // Plan the shared subplan eagerly rather than using planLater, because
+ // CTEReuseExchange is a LeafExecNode whose subplan is not visible via
+ // children. QueryPlanner.collectPlaceholders would never find a
PlanLater
+ // inside the subplan, leaving it unresolved.
+ //
+ // sharedSubplan is a RepartitionByExpression wrapping the CTE body.
Planning it produces a
+ // plain ShuffleExchangeExec; we re-stamp its origin as
LOCAL_SHUFFLE_FOR_CTE(cteId) so the
+ // shuffle is recognized as a reuse boundary
(`isCreatedForSubplanReuse`) by
+ // EnsureRequirements, the AQE-off unwrap path, and VerifyCTEReuse. We
use this shuffle
+ // directly instead of adding a separate exchange -- the Repartition
serves as the logical
+ // plan for the inner AQE, enabling correct re-optimization (broadcast
stages are findable
+ // as descendants, and re-planning preserves the root exchange).
+ val physicalSubplan = SparkStrategies.this.plan(r.sharedSubplan).next()
+ val shuffle = physicalSubplan match {
+ case s: ShuffleExchangeExec => s.copy(shuffleOrigin =
LOCAL_SHUFFLE_FOR_CTE(r.cteId))
Review Comment:
Thanks. The merge target also planned each inlined copy of the CTE body
separately, so per-reference planning is not a regression from this PR, and
handling the planning memo in a follow-up makes sense.
<!-- SPARK_DEV_REVIEW_REPLY
{"feedback_id":"inline:4237377080","thread_id":"inline:4237377080","verdict_sha256":"7971a94f9214bdf1857a39473550a560c323a2594d2e01d88f4a127ddd3021af"}
-->
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]