AveryQi115 opened a new pull request, #58924:
URL: https://github.com/apache/spark/pull/58924

   ## TL;DR
   
   Guarantees that a CTE marked non-inlinable (`CTERelationDef.forceSkipInline 
= true`) is **materialized exactly once and physically reused** across all of 
its references. Each such CTE reference is turned into a `CTEReuseExchange` 
node wrapped over a single shuffle of the CTE body. Whether or not Adaptive 
Query Execution (AQE) is enabled, execution respects the `CTEReuseExchange`: 
every reference reads the **same shuffle files** rather than recomputing the 
CTE. This closes the gap where stock `ReuseExchangeAndSubquery` cannot 
guarantee reuse for a non-deterministic CTE body — non-determinism breaks 
canonical equality, so the stock rule may fail to dedup the copies and silently 
recompute.
   
   ### Why it is needed
   
   A CTE referenced multiple times can be either inlined (recomputed per 
reference) or materialized once. For a **non-deterministic** body (e.g. 
`rand()`, `monotonically_increasing_id()`, non-deterministic UDFs), 
recomputation is not just wasteful but **semantically wrong** — each reference 
would observe different rows. Stock reuse keys on canonical equality of the 
physical subtrees, which non-determinism and per-reference `exprId` remapping 
defeat. This change introduces a `cteId`-keyed mechanism so all references 
provably share one materialized exchange.
   
   ---
   
   ## Life cycle
   
   The feature threads one identity — the `cteId` — from the logical plan down 
to a single physical shuffle:
   
   ```
   CTERelationDef / CTERelationRef            (analyzer; forceSkipInline = true)
           │  ReplaceCTERefWithRepartition
           ▼
   RepartitionByExpression (plan-reuse id)    (optimizer; body inlined + 
deduplicated)
           │  ReplaceRepartitionWithCTEReuse
           ▼
   CTEReuseRelation(cteId, partitioning, …)   (optimizer; only when refs > 1)
           │  SparkStrategies
           ▼
   CTEReuseExchange(cteId, shuffle)           (physical leaf; shuffle held as 
metadata)
           │
           ├── AQE on  ─▶  CTEReuseQueryStageExec ─▶ inner 
AdaptiveSparkPlanExec ─▶ Shuffle
           └── AQE off ─▶  LOCAL_SHUFFLE_FOR_CTE shuffle ─▶ ReusedExchangeExec 
(canonical reuse)
   ```
   
   ### Logical → physical (common to both modes)
   
   - **`ReplaceCTERefWithRepartition`** replaces each reference to a 
`forceSkipInline` CTE with a *plan-reuse* `RepartitionByExpression` (a non-zero 
`repartitionId`, assigned by `RepartitionIdGenerator`) wrapping the inlined CTE 
body. If the def carries `forcePartitioning = Some(HashPartitioning)`, the 
reference is materialized with that pinned partitioning; otherwise it falls 
back to the normal path (`RepartitionByExpression(Seq.empty, …)`, unless the 
body already satisfies the partitioning or the reference is under a subquery). 
The inlined body is run through `DeduplicateRelations` so each copy gets fresh 
`exprId`s — the `repartitionId`, not `exprId` equality, is what ties the copies 
together.
   - **`ReplaceRepartitionWithCTEReuse`** (main-query pass only) counts 
plan-reuse repartitions by `repartitionId` across the whole plan including 
subqueries. Every id with **more than one** consumer is sealed into a 
`CTEReuseRelation(cteId, partitioning, sharedSubplan)`; single-consumer ids 
stay as ordinary repartitions and simply inline. `sharedSubplan` is 
deliberately **not a child** of `CTEReuseRelation`, so outer-plan 
re-optimization treats the reference as a leaf and never descends into (or 
diverges) the shared body.
   - **`SparkStrategies`** plans `CTEReuseRelation` eagerly (not via 
`planLater`, since the shuffle lives in metadata that the placeholder collector 
cannot see) into a `ShuffleExchangeExec` over the body, re-stamps its origin as 
`LOCAL_SHUFFLE_FOR_CTE(cteId)`, and wraps it as a `CTEReuseExchange(cteId, 
shuffle)` leaf. The shuffle is stored in `innerChildren`, not `children`, so 
AQE's bottom-up stage creation does not redundantly plan its substages.
   
   ### AQE on: `CTEReuseExchange → CTEReuseQueryStageExec → inner 
AdaptiveSparkPlanExec → Shuffle`
   
   - **`PlanCTEReuse`** (a preprocessing rule alongside 
`PlanAdaptiveSubqueries`) builds **one shared inner `AdaptiveSparkPlanExec` per 
`cteId`**, registered in `AdaptiveExecutionContext.cteAQERegistry`. The 
registry lives on the shared execution context, so the identical inner AQE 
instance is reused across every reference — main query and subqueries — and 
across re-plans, preventing duplicate replanning or plan divergence of the 
shared body.
   - **`createNonResultQueryStages`** replaces each `CTEReuseExchange` with a 
`CTEReuseQueryStageExec(id, innerAQE, output)`. All references' stages point at 
the same inner AQE; each stage remaps the inner AQE's (primary reference's) 
attribute ids into its own reference's id space, mirroring 
`ReusedExchangeExec`. Execution and statistics delegate to the inner AQE's 
post-iteration `executedPlan`, so the shared body is iterated once.
   
   ### AQE off: `CTEReuseExchange → LOCAL_SHUFFLE_FOR_CTE shuffle → 
ReusedExchangeExec`
   
   - **`UnwrapCTEReuseExchange`** (early in the preparation pipeline, in both 
the main and subquery batches) replaces each `CTEReuseExchange` with its 
underlying `LOCAL_SHUFFLE_FOR_CTE(cteId)` shuffle. From there all normal 
preparation rules run per copy.
   - Because every copy of a `cteId` is planned from the same canonicalized 
`sharedSubplan` and the shuffle is protected from `EnsureRequirements`, the 
copies stay **canonically equal**, and the stock **`ReuseExchangeAndSubquery`** 
deduplicates them into `ReusedExchangeExec`.
   - **`VerifyCTEReuse`** runs at the very end (final main-query pass only) and 
asserts at most one live shuffle per `cteId`. Under 
`spark.sql.optimizer.failOnCteReuseWithoutAqe` (test default) a violation 
throws; in production it logs. Reuse is a best-effort optimization here, not a 
correctness invariant.
   
   ---
   
   ## Design considerations and details
   
   ### When AQE is on
   
   **1. Deadlock avoidance (dedicated thread pool).** The inner AQE is 
materialized via `materialize()` / lazy `materializeFuture`, which calls 
`withFinalPlanUpdate(skipResultStage = true)` on a **dedicated** 
`cteExecutionContext` thread pool (sized by 
`spark.sql.cteMaterialization.maxThreadThreshold`, default 1024). The outer 
AQE's stage-materialization threads block waiting on the CTE stage to finish; 
if the inner AQE materialized on the same shared `QueryStageCreator` pool, 
nested materialization could exhaust that pool while outer threads hold slots 
waiting on it — a classic thread-pool deadlock. A separate pool breaks the 
dependency cycle. `skipResultStage = true` materializes the shared body's 
stages without wrapping a `ResultQueryStageExec`, since the CTE stage is an 
input to other stages, not a query result.
   
   **2. Runtime filters.** The CTE body is materialized once and shared across 
all consumers, so consumer-specific runtime filters (dynamic partition pruning, 
bloom filters) must **not** be pushed below the reuse boundary — doing so would 
specialize the single shared result to one consumer and corrupt the others. 
Runtime filters are applied per consumer *above* the `CTEReuseExchange`; the 
shared subplan itself is never specialized. This is a deliberate limitation of 
guaranteed reuse: correctness of the shared materialization takes precedence 
over consumer-local filter pushdown.
   
   **3. Shuffle planning (`EnsureRequirements`).** The reuse shuffle's 
partitioning is fixed at the logical level (`CTEReuseRelation.partitioning`, 
derived from the common distribution of all consumers, or a safe fallback). 
`EnsureRequirements` must not rewrite it, because re-deriving partitioning per 
consumer would make the copies diverge and defeat reuse. The guard is `case s: 
ShuffleExchangeExec if !s.isCreatedForSubplanReuse => s.copy(outputPartitioning 
= …)`, where `isCreatedForSubplanReuse` is true for the `LOCAL_SHUFFLE_FOR_CTE` 
origin. The protected shuffle is an immutable boundary: consumer-dependent 
operators are added *above* it, keeping the subtree below canonically identical 
across copies. The same origin also keeps the shuffle out of the AQE 
`supportedShuffleOrigins` allowlists (coalesce / local read / skew join), so 
its partition layout stays frozen.
   
   ### When AQE is off
   
   **1. Interaction with `planSubqueries`.** With AQE off, subqueries are 
prepared **separately** — `PlanSubqueries` calls 
`QueryExecution.prepareExecutedPlan → preparations(subquery = true)`, applying 
the preparation rules recursively to each subquery plan on its own. So 
`UnwrapCTEReuseExchange` is registered in **both** the main and subquery 
preparation batches, ensuring CTE references *inside subqueries* are unwrapped 
too. Conversely, `VerifyCTEReuse` runs **only on the final main-query pass**: 
during the per-subquery `PlanSubqueries` invocations, reuse across the whole 
plan is not yet complete, so verifying there would produce false negatives. 
Reuse itself is achieved entirely through the stock `ReuseExchangeAndSubquery` 
(canonical matching) — this change adds no new dedup engine for the AQE-off 
path, only the protected `LOCAL_SHUFFLE_FOR_CTE` boundary that keeps the copies 
canonically equal for the stock rule to find.
   
   ## Testing
   
   Adds `CTEReuseWithAQESuite`, `CTEReuseWithoutAQESuite`, and 
`ReplaceCTERefAndRepartitionWithCTEReuseSuite` covering the optimizer rewrites, 
the AQE-on inner-AQE path, and the AQE-off canonical-reuse path (including 
reuse across subqueries). All new confs default to off, so existing behavior is 
unchanged.
   
   ---
   
   Co-authored-by: Maryann Xue <[email protected]>
   Co-authored-by: Juliusz Sompolski <[email protected]>
   
   This pull request and its description were written by Isaac.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to