This is an automated email from the ASF dual-hosted git repository.

peter-toth pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git


The following commit(s) were added to refs/heads/master by this push:
     new 098057d96349 [SPARK-57212][SQL] Track preparation and AQE rule timing 
in `QueryPlanningTracker`
098057d96349 is described below

commit 098057d963495e91e5093371ee8a3543f709df55
Author: Peter Toth <[email protected]>
AuthorDate: Tue Jun 9 15:25:59 2026 +0200

    [SPARK-57212][SQL] Track preparation and AQE rule timing in 
`QueryPlanningTracker`
    
    ### What changes were proposed in this pull request?
    
    Physical preparation rules and AQE physical rules run through plain 
`foldLeft` loops that bypass `RuleExecutor.execute`, so their per-rule timing 
is never recorded by `QueryPlanningTracker` and therefore not reported by 
`topRulesByTime` or `RuleExecutor.dumpTimeSpent()`. This PR records that timing 
by routing those rules through `RuleExecutor`, the same machinery the analyzer 
and optimizer already use to track theirs.
    
    - Add `QueryExecution.PhysicalRuleExecutor`, a small 
`RuleExecutor[SparkPlan]` that runs a given rule list as a single 
`FixedPoint(1)` batch, and route `QueryExecution.prepareForExecution` and 
`AdaptiveSparkPlanExec.applyPhysicalRules` through it. `RuleExecutor.execute` 
already times each rule, calls `recordRuleInvocation` on the 
`QueryPlanningTracker` in scope, and runs `PlanChangeLogger`. `FixedPoint(1)` 
runs each rule exactly once and skips the idempotence re-check, since preparati 
[...]
    - Supply the tracker through `QueryPlanningTracker.withTracker` (a 
`ThreadLocal`): `QueryExecution` wraps the `PLANNING` phase, and each 
`AdaptiveSparkPlanExec` wraps `initialPlan` and the body of 
`withFinalPlanUpdate` with its own tracker. `reOptimize` calls 
`optimizer.execute`, which reads the same `ThreadLocal`.
    - Give each `AdaptiveSparkPlanExec` -- the main query plan and the plan 
built for every scalar / IN / DPP subquery -- its own `QueryPlanningTracker`, 
registered in `AdaptiveExecutionContext.planningTrackers`. Sub-AQEs plan on 
separate threads (`SubqueryExec` / `BroadcastExchangeExec` pools), but since 
each one writes only its own tracker, the per-rule recording path needs no 
synchronization. The main (non-subquery) node merges all of these trackers into 
the query's `QueryPlanningTrack [...]
    
    With this, `QueryPlanningTracker.rules` and `topRulesByTime` cover physical 
preparation rules (`EnsureRequirements`, `CollapseCodegenStages`, 
`ReuseExchangeAndSubquery`, ...) and AQE rules of both the main query and its 
subqueries (`AdjustShuffleExchangePosition`, `ValidateSparkPlan`, 
`OptimizeSkewedJoin`, the AQE re-optimizer rules, ...), alongside the analyzer 
and optimizer rules they already track. These rules also appear in 
`RuleExecutor.dumpTimeSpent()` / `QueryExecutionMetering` [...]
    
    `AdaptiveSparkPlanExec.optimizeQueryStage`'s per-stage 
`queryStageOptimizerRules` (`CoalesceShufflePartitions`, 
`OptimizeShuffleWithLocalRead`, ...) are out of scope here: that loop has its 
own `AQEShuffleReadRule` validate/rollback handling and does not fit a plain 
`RuleExecutor`.
    
    ### Why are the changes needed?
    
    A long-running preparation or AQE rule -- for example `EnsureRequirements` 
over a key-grouped join with many partitions, or AQE rules applied per stage or 
per subquery -- shows up only inside the `planning` phase total, never per 
rule, which makes it hard to diagnose where planning time goes. 
`QueryExecution.normalize` already records per-rule timing through 
`QueryPlanningTracker`; this brings the same per-rule visibility to physical 
preparation and AQE. Routing through `RuleExecutor` [...]
    
    ### Does this PR introduce _any_ user-facing change?
    
    No query-result or public-API change -- `tracker.rules`, 
`tracker.topRulesByTime(...)`, and `RuleExecutor.dumpTimeSpent()` simply gain 
entries for physical preparation and AQE rules.
    
    One side effect is worth calling out, as it goes slightly beyond 
observability: `RuleExecutor.execute` runs plan-change validation, which the 
old `foldLeft` loops did not.
    With `spark.sql.lightweightPlanChangeValidation` enabled by default, a 
lightweight validation now runs after each effective preparation/AQE rule -- a 
small, always-on cost on the planning path, and a new potential 
`PLAN_VALIDATION_FAILED_RULE_IN_BATCH` site should a rule ever produce an 
invalid plan. Preparation and AQE rules are expected to produce valid plans, so 
this is arguably a latent correctness improvement -- but it is more than pure 
observability.
    
    ### How was this patch tested?
    
    New tests in `QueryPlanningTrackerEndToEndSuite`:
    - `SPARK-57212: Track preparation rules` -- a non-AQE query asserts 
`EnsureRequirements` and `CollapseCodegenStages` appear in `tracker.rules`.
    - `SPARK-57212: Track AQE-internal preparation rules` -- a shuffle query 
asserts the AQE-only rules `AdjustShuffleExchangePosition` and 
`ValidateSparkPlan` appear.
    - `SPARK-57212: Track sub-query AQE rules` -- a query whose only shuffle is 
inside a scalar subquery asserts that `DynamicJoinSelection` (an AQE 
re-optimizer rule the shuffle-free main plan never runs) appears, confirming 
the subquery plan's tracker is merged into the
    query tracker.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.8
    
    Closes #56275 from peter-toth/SPARK-57212-track-preparation-rules.
    
    Authored-by: Peter Toth <[email protected]>
    Signed-off-by: Peter Toth <[email protected]>
---
 .../spark/sql/catalyst/QueryPlanningTracker.scala  |  13 +
 .../spark/sql/execution/QueryExecution.scala       |  29 ++-
 .../execution/adaptive/AdaptiveSparkPlanExec.scala | 266 +++++++++++----------
 .../adaptive/InsertAdaptiveSparkPlan.scala         |   5 +-
 .../QueryPlanningTrackerEndToEndSuite.scala        |  38 +++
 5 files changed, 215 insertions(+), 136 deletions(-)

diff --git 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/QueryPlanningTracker.scala
 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/QueryPlanningTracker.scala
index d1007404158f..9915c03eaa33 100644
--- 
a/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/QueryPlanningTracker.scala
+++ 
b/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/QueryPlanningTracker.scala
@@ -210,6 +210,19 @@ class QueryPlanningTracker(
     s.numEffectiveInvocations += (if (effective) 1 else 0)
   }
 
+  /**
+   * Merge the per-rule statistics from `other` into this tracker. 
[[RuleSummary]]'s fields are
+   * additive, so the totals are simply accumulated.
+   */
+  def merge(other: QueryPlanningTracker): Unit = {
+    other.rulesMap.forEach { (rule, otherSummary) =>
+      val s = rulesMap.computeIfAbsent(rule, _ => new RuleSummary)
+      s.totalTimeNs += otherSummary.totalTimeNs
+      s.numInvocations += otherSummary.numInvocations
+      s.numEffectiveInvocations += otherSummary.numEffectiveInvocations
+    }
+  }
+
   // ------------ reporting functions below ------------
 
   def rules: Map[String, RuleSummary] = rulesMap.asScala.toMap
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 d843af844d94..e37588faf8fb 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
@@ -35,7 +35,7 @@ import org.apache.spark.sql.catalyst.analysis.{Analyzer, 
LazyExpression, NamePar
 import org.apache.spark.sql.catalyst.expressions.codegen.ByteCodeStats
 import org.apache.spark.sql.catalyst.plans.QueryPlan
 import org.apache.spark.sql.catalyst.plans.logical.{AppendData, Command, 
CommandResult, CompoundBody, CreateTableAsSelect, LogicalPlan, 
OverwriteByExpression, OverwritePartitionsDynamic, ReplaceTableAsSelect, 
ReturnAnswer, Union, UnresolvedWith, WithCTE}
-import org.apache.spark.sql.catalyst.rules.{PlanChangeLogger, Rule}
+import org.apache.spark.sql.catalyst.rules.{PlanChangeLogger, Rule, 
RuleExecutor}
 import org.apache.spark.sql.catalyst.transactions.TransactionUtils
 import org.apache.spark.sql.catalyst.util.StringUtils.PlanStringConcat
 import org.apache.spark.sql.catalyst.util.truncatedString
@@ -357,7 +357,9 @@ class QueryExecution(
     val plan = executePhase(QueryPlanningTracker.PLANNING) {
       // clone the plan to avoid sharing the plan instance between different 
stages like analyzing,
       // optimizing and planning.
-      QueryExecution.prepareForExecution(preparations, sparkPlan.clone())
+      QueryPlanningTracker.withTracker(tracker) {
+        QueryExecution.prepareForExecution(preparations, sparkPlan.clone())
+      }
     }
     // Note: For eagerly executed command it might have already been called in
     // `eagerlyExecutedCommand` and is a noop here.
@@ -788,14 +790,21 @@ object QueryExecution {
   private[execution] def prepareForExecution(
       preparations: Seq[Rule[SparkPlan]],
       plan: SparkPlan): SparkPlan = {
-    val planChangeLogger = new PlanChangeLogger[SparkPlan]()
-    val preparedPlan = preparations.foldLeft(plan) { case (sp, rule) =>
-      val result = rule.apply(sp)
-      planChangeLogger.logRule(rule.ruleName, sp, result)
-      result
-    }
-    planChangeLogger.logBatch("Preparations", plan, preparedPlan)
-    preparedPlan
+    new PhysicalRuleExecutor("Preparations", preparations).execute(plan)
+  }
+
+  /**
+   * A [[RuleExecutor]] that applies a fixed list of physical-plan rules once 
each (a single
+   * `FixedPoint(1)` batch). Routing physical preparation and AQE rules 
through a `RuleExecutor`
+   * reuses its built-in per-rule timing, which records into the active 
[[QueryPlanningTracker]]
+   * exactly like the analyzer and optimizer, instead of re-implementing the 
timing loop. A
+   * `FixedPoint(1)` strategy (rather than `Once`) is used so each rule runs 
exactly once without
+   * triggering the idempotence re-check, as preparation rules are not 
necessarily idempotent.
+   */
+  private[execution] class PhysicalRuleExecutor(
+      batchName: String,
+      rules: Seq[Rule[SparkPlan]]) extends RuleExecutor[SparkPlan] {
+    override protected def batches: Seq[Batch] = Seq(Batch(batchName, 
FixedPoint(1), rules: _*))
   }
 
   /**
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 112ee82314c4..f98eaefc8cf7 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
@@ -18,7 +18,7 @@
 package org.apache.spark.sql.execution.adaptive
 
 import java.util
-import java.util.concurrent.{ConcurrentHashMap, LinkedBlockingQueue}
+import java.util.concurrent.{ConcurrentHashMap, ConcurrentLinkedQueue, 
LinkedBlockingQueue}
 
 import scala.collection.concurrent.TrieMap
 import scala.collection.mutable
@@ -32,6 +32,7 @@ import org.apache.spark.internal.LogKeys._
 import org.apache.spark.internal.MessageWithContext
 import org.apache.spark.rdd.RDD
 import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.catalyst.QueryPlanningTracker
 import org.apache.spark.sql.catalyst.expressions.Attribute
 import org.apache.spark.sql.catalyst.plans.logical.{LogicalPlan, ReturnAnswer}
 import org.apache.spark.sql.catalyst.plans.physical.{Distribution, 
UnspecifiedDistribution}
@@ -85,6 +86,11 @@ case class AdaptiveSparkPlanExec(
   @transient private val optimizer = new AQEOptimizer(conf,
     context.session.sessionState.adaptiveRulesHolder.runtimeOptimizerRules)
 
+  // Each `AdaptiveSparkPlanExec` records the physical-planning rules it runs 
into its own tracker.
+  // The main node folds them all into the shared `context.qe.tracker` once 
the final plan is ready.
+  @transient private val tracker = new QueryPlanningTracker()
+  context.planningTrackers.add(tracker)
+
   // `EnsureRequirements` may remove user-specified repartition and assume the 
query plan won't
   // change its output partitioning. This assumption is not true in AQE. Here 
we check the
   // `inputPlan` which has not been processed by `EnsureRequirements` yet, to 
find out the
@@ -196,15 +202,17 @@ case class AdaptiveSparkPlanExec(
     applyPhysicalRules(
       plan,
       
context.session.sessionState.adaptiveRulesHolder.queryPostPlannerStrategyRules,
-      Some((planChangeLogger, "AQE Query Post Planner Strategy Rules"))
+      "AQE Query Post Planner Strategy Rules"
     )
   }
 
   @transient val initialPlan = context.session.withActive {
-    applyPhysicalRules(
-      applyQueryPostPlannerStrategyRules(inputPlan),
-      queryStagePreparationRules,
-      Some((planChangeLogger, "AQE Preparations")))
+    QueryPlanningTracker.withTracker(tracker) {
+      applyPhysicalRules(
+        applyQueryPostPlannerStrategyRules(inputPlan),
+        queryStagePreparationRules,
+        "AQE Preparations")
+    }
   }
 
   @volatile private var currentPhysicalPlan = initialPlan
@@ -271,119 +279,124 @@ case class AdaptiveSparkPlanExec(
     // `plan.queryExecution.rdd`, we need to set active session here as new 
plan nodes can be
     // created in the middle of the execution.
     context.session.withActive {
-      val executionId = getExecutionId
-      // Use inputPlan logicalLink here in case some top level physical nodes 
may be removed
-      // during `initialPlan`
-      var currentLogicalPlan = inputPlan.logicalLink.get
-      var result = createQueryStages(fun, currentPhysicalPlan, firstRun = true)
-      val events = new LinkedBlockingQueue[StageMaterializationEvent]()
-      val errors = new mutable.ArrayBuffer[Throwable]()
-      var stagesToReplace = Seq.empty[QueryStageExec]
-      while (!result.allChildStagesMaterialized) {
-        currentPhysicalPlan = result.newPlan
-        if (result.newStages.nonEmpty) {
-          stagesToReplace = result.newStages ++ stagesToReplace
-          executionId.foreach(onUpdatePlan(_, result.newStages.map(_.plan)))
-
-          // SPARK-33933: we should submit tasks of broadcast stages first, to 
avoid waiting
-          // for tasks to be scheduled and leading to broadcast timeout.
-          // This partial fix only guarantees the start of materialization for 
BroadcastQueryStage
-          // is prior to others, but because the submission of collect job for 
broadcasting is
-          // running in another thread, the issue is not completely resolved.
-          val reorderedNewStages = result.newStages
-            .sortWith {
-              case (_: BroadcastQueryStageExec, _: BroadcastQueryStageExec) => 
false
-              case (_: BroadcastQueryStageExec, _) => true
-              case _ => false
-            }
+      // Record this node's planning rules into its own tracker; merged into 
the query's tracker by
+      // the main node in `finalPlanUpdate`.
+      QueryPlanningTracker.withTracker(tracker) {
+        val executionId = getExecutionId
+        // Use inputPlan logicalLink here in case some top level physical 
nodes may be removed
+        // during `initialPlan`
+        var currentLogicalPlan = inputPlan.logicalLink.get
+        var result = createQueryStages(fun, currentPhysicalPlan, firstRun = 
true)
+        val events = new LinkedBlockingQueue[StageMaterializationEvent]()
+        val errors = new mutable.ArrayBuffer[Throwable]()
+        var stagesToReplace = Seq.empty[QueryStageExec]
+        while (!result.allChildStagesMaterialized) {
+          currentPhysicalPlan = result.newPlan
+          if (result.newStages.nonEmpty) {
+            stagesToReplace = result.newStages ++ stagesToReplace
+            executionId.foreach(onUpdatePlan(_, result.newStages.map(_.plan)))
+
+            // SPARK-33933: we should submit tasks of broadcast stages first, 
to avoid waiting
+            // for tasks to be scheduled and leading to broadcast timeout.
+            // This partial fix only guarantees the start of materialization 
for BroadcastQueryStage
+            // is prior to others, but because the submission of collect job 
for broadcasting is
+            // running in another thread, the issue is not completely resolved.
+            val reorderedNewStages = result.newStages
+              .sortWith {
+                case (_: BroadcastQueryStageExec, _: BroadcastQueryStageExec) 
=> false
+                case (_: BroadcastQueryStageExec, _) => true
+                case _ => false
+              }
 
-          // Start materialization of all new stages and fail fast if any 
stages failed eagerly
-          reorderedNewStages.foreach { stage =>
-            try {
-              stage.materialize().onComplete { res =>
-                if (res.isSuccess) {
-                  // record shuffle IDs for successful stages for cleanup
-                  stage.plan.collect {
-                    case s: ShuffleExchangeLike =>
-                      context.shuffleIds.put(s.shuffleId, true)
+            // Start materialization of all new stages and fail fast if any 
stages failed eagerly
+            reorderedNewStages.foreach { stage =>
+              try {
+                stage.materialize().onComplete { res =>
+                  if (res.isSuccess) {
+                    // record shuffle IDs for successful stages for cleanup
+                    stage.plan.collect {
+                      case s: ShuffleExchangeLike =>
+                        context.shuffleIds.put(s.shuffleId, true)
+                    }
+                    events.offer(StageSuccess(stage, res.get))
+                  } else {
+                    events.offer(StageFailure(stage, res.failed.get))
                   }
-                  events.offer(StageSuccess(stage, res.get))
-                } else {
-                  events.offer(StageFailure(stage, res.failed.get))
-                }
-                // explicitly clean up the resources in this stage
-                stage.cleanupResources()
-              }(AdaptiveSparkPlanExec.executionContext)
-            } catch {
-              case e: Throwable =>
-                stage.error.set(Some(e))
-                cleanUpAndThrowException(Seq(e), Some(stage.id))
+                  // explicitly clean up the resources in this stage
+                  stage.cleanupResources()
+                }(AdaptiveSparkPlanExec.executionContext)
+              } catch {
+                case e: Throwable =>
+                  stage.error.set(Some(e))
+                  cleanUpAndThrowException(Seq(e), Some(stage.id))
+              }
             }
           }
-        }
 
-        // Wait on the next completed stage, which indicates new stats are 
available and probably
-        // new stages can be created. There might be other stages that finish 
at around the same
-        // time, so we process those stages too in order to reduce re-planning.
-        val nextMsg = events.take()
-        val rem = new util.ArrayList[StageMaterializationEvent]()
-        events.drainTo(rem)
-        (Seq(nextMsg) ++ rem.asScala).foreach {
-          case StageSuccess(stage, res) =>
-            stage.resultOption.set(Some(res))
-          case StageFailure(stage, ex) =>
-            stage.error.set(Some(ex))
-            errors.append(ex)
-        }
+          // Wait on the next completed stage, which indicates new stats are 
available and probably
+          // new stages can be created. There might be other stages that 
finish at around the same
+          // time, so we process those stages too in order to reduce 
re-planning.
+          val nextMsg = events.take()
+          val rem = new util.ArrayList[StageMaterializationEvent]()
+          events.drainTo(rem)
+          (Seq(nextMsg) ++ rem.asScala).foreach {
+            case StageSuccess(stage, res) =>
+              stage.resultOption.set(Some(res))
+            case StageFailure(stage, ex) =>
+              stage.error.set(Some(ex))
+              errors.append(ex)
+          }
 
-        // In case of errors, we cancel all running stages and throw exception.
-        if (errors.nonEmpty) {
-          cleanUpAndThrowException(errors.toSeq, None)
-        }
-        val testTriggerForceCancellation = 
AQETestHelper.shouldForceCancellation(this)
-
-        if (!currentPhysicalPlan.isInstanceOf[ResultQueryStageExec]) {
-          // Try re-optimizing and re-planning. Adopt the new plan if its cost 
is equal to or less
-          // than that of the current plan; otherwise keep the current 
physical plan together with
-          // the current logical plan since the physical plan's logical links 
point to the logical
-          // plan it has originated from.
-          // Meanwhile, we keep a list of the query stages that have been 
created since last plan
-          // update, which stands for the "semantic gap" between the current 
logical and physical
-          // plans. And each time before re-planning, we replace the 
corresponding nodes in the
-          // current logical plan with logical query stages to make it 
semantically in sync with
-          // the current physical plan. Once a new plan is adopted and both 
logical and physical
-          // plans are updated, we can clear the query stage list because at 
this point the two
-          // plans are semantically and physically in sync again.
-          var logicalPlan = 
replaceWithQueryStagesInLogicalPlan(currentLogicalPlan, stagesToReplace)
-          if (testTriggerForceCancellation) {
-            // Force unwrap all LogicalQueryStage so they get replanned.
-            logicalPlan = logicalPlan.transformDown {
-              case LogicalQueryStage(logical, _) => logical
-            }
+          // In case of errors, we cancel all running stages and throw 
exception.
+          if (errors.nonEmpty) {
+            cleanUpAndThrowException(errors.toSeq, None)
           }
-          val afterReOptimize = reOptimize(logicalPlan)
-          if (afterReOptimize.isDefined) {
-            val (newPhysicalPlan, newLogicalPlan) = afterReOptimize.get
-            val origCost = costEvaluator.evaluateCost(currentPhysicalPlan)
-            val newCost = costEvaluator.evaluateCost(newPhysicalPlan)
-            if (newCost < origCost ||
-              (newCost == origCost && currentPhysicalPlan != newPhysicalPlan) 
||
-              testTriggerForceCancellation) {
-              lazy val plans = sideBySide(
-                currentPhysicalPlan.treeString, 
newPhysicalPlan.treeString).mkString("\n")
-              logOnLevel(log"Plan changed:\n${MDC(QUERY_PLAN, plans)}")
-              cleanUpTempTags(newPhysicalPlan)
-              currentPhysicalPlan = newPhysicalPlan
-              currentLogicalPlan = newLogicalPlan
-              stagesToReplace = Seq.empty[QueryStageExec]
+          val testTriggerForceCancellation = 
AQETestHelper.shouldForceCancellation(this)
+
+          if (!currentPhysicalPlan.isInstanceOf[ResultQueryStageExec]) {
+            // Try re-optimizing and re-planning. Adopt the new plan if its 
cost is equal to or less
+            // than that of the current plan; otherwise keep the current 
physical plan together with
+            // the current logical plan since the physical plan's logical 
links point to the logical
+            // plan it has originated from.
+            // Meanwhile, we keep a list of the query stages that have been 
created since last plan
+            // update, which stands for the "semantic gap" between the current 
logical and physical
+            // plans. And each time before re-planning, we replace the 
corresponding nodes in the
+            // current logical plan with logical query stages to make it 
semantically in sync with
+            // the current physical plan. Once a new plan is adopted and both 
logical and physical
+            // plans are updated, we can clear the query stage list because at 
this point the two
+            // plans are semantically and physically in sync again.
+            var logicalPlan =
+              replaceWithQueryStagesInLogicalPlan(currentLogicalPlan, 
stagesToReplace)
+            if (testTriggerForceCancellation) {
+              // Force unwrap all LogicalQueryStage so they get replanned.
+              logicalPlan = logicalPlan.transformDown {
+                case LogicalQueryStage(logical, _) => logical
+              }
+            }
+            val afterReOptimize = reOptimize(logicalPlan)
+            if (afterReOptimize.isDefined) {
+              val (newPhysicalPlan, newLogicalPlan) = afterReOptimize.get
+              val origCost = costEvaluator.evaluateCost(currentPhysicalPlan)
+              val newCost = costEvaluator.evaluateCost(newPhysicalPlan)
+              if (newCost < origCost ||
+                (newCost == origCost && currentPhysicalPlan != 
newPhysicalPlan) ||
+                testTriggerForceCancellation) {
+                lazy val plans = sideBySide(
+                  currentPhysicalPlan.treeString, 
newPhysicalPlan.treeString).mkString("\n")
+                logOnLevel(log"Plan changed:\n${MDC(QUERY_PLAN, plans)}")
+                cleanUpTempTags(newPhysicalPlan)
+                currentPhysicalPlan = newPhysicalPlan
+                currentLogicalPlan = newLogicalPlan
+                stagesToReplace = Seq.empty[QueryStageExec]
+              }
+            }
+            if (testTriggerForceCancellation) {
+              AQETestHelper.markForcedCancellationTriggeredForPlan(this)
             }
           }
-          if (testTriggerForceCancellation) {
-            AQETestHelper.markForcedCancellationTriggeredForPlan(this)
-          }
+          // Now that some stages have finished, we can try creating new 
stages.
+          result = createQueryStages(fun, currentPhysicalPlan, firstRun = 
false)
         }
-        // Now that some stages have finished, we can try creating new stages.
-        result = createQueryStages(fun, currentPhysicalPlan, firstRun = false)
       }
     }
     _isFinalPlan = true
@@ -401,6 +414,12 @@ case class AdaptiveSparkPlanExec(
     if (shouldUpdatePlan) {
       getExecutionId.foreach(onUpdatePlan(_, Seq.empty))
     }
+    // The main query node folds every `AdaptiveSparkPlanExec`'s tracker (its 
own and all sub-query
+    // plans, which were planned on separate threads) into the shared query 
tracker. This runs on
+    // the main query thread once the final plan is ready and all sub-queries 
have completed.
+    if (!isSubquery) {
+      context.planningTrackers.asScala.foreach(context.qe.tracker.merge)
+    }
     logOnLevel(log"Final plan:\n${MDC(QUERY_PLAN, currentPhysicalPlan)}")
   }
 
@@ -667,7 +686,7 @@ case class AdaptiveSparkPlanExec(
     val optimizedRootPlan = applyPhysicalRules(
       optimizeQueryStage(plan, isFinalStage = true),
       postStageCreationRules(supportsColumnar),
-      Some((planChangeLogger, "AQE Post Stage Creation")))
+      "AQE Post Stage Creation")
     val resultStage = ResultQueryStageExec(currentStageId, optimizedRootPlan, 
resultHandler)
     currentStageId += 1
     setLogicalLinkForNewQueryStage(resultStage, plan)
@@ -681,7 +700,7 @@ case class AdaptiveSparkPlanExec(
         val newPlan = applyPhysicalRules(
           optimized,
           postStageCreationRules(outputsColumnar = plan.supportsColumnar),
-          Some((planChangeLogger, "AQE Post Stage Creation")))
+          "AQE Post Stage Creation")
         if (e.isInstanceOf[ShuffleExchangeLike]) {
           if (!newPlan.isInstanceOf[ShuffleExchangeLike]) {
             throw SparkException.internalError(
@@ -810,7 +829,7 @@ case class AdaptiveSparkPlanExec(
       val newPlan = applyPhysicalRules(
         applyQueryPostPlannerStrategyRules(sparkPlan),
         preprocessingRules ++ queryStagePreparationRules,
-        Some((planChangeLogger, "AQE Replanning")))
+        "AQE Replanning")
 
       // When both enabling AQE and DPP, `PlanAdaptiveDynamicPruningFilters` 
rule will
       // add the `BroadcastExchangeExec` node manually in the DPP subquery,
@@ -939,19 +958,8 @@ object AdaptiveSparkPlanExec {
   def applyPhysicalRules(
       plan: SparkPlan,
       rules: Seq[Rule[SparkPlan]],
-      loggerAndBatchName: Option[(PlanChangeLogger[SparkPlan], String)] = 
None): SparkPlan = {
-    if (loggerAndBatchName.isEmpty) {
-      rules.foldLeft(plan) { case (sp, rule) => rule.apply(sp) }
-    } else {
-      val (logger, batchName) = loggerAndBatchName.get
-      val newPlan = rules.foldLeft(plan) { case (sp, rule) =>
-        val result = rule.apply(sp)
-        logger.logRule(rule.ruleName, sp, result)
-        result
-      }
-      logger.logBatch(batchName, plan, newPlan)
-      newPlan
-    }
+      batchName: String): SparkPlan = {
+    new QueryExecution.PhysicalRuleExecutor(batchName, rules).execute(plan)
   }
 }
 
@@ -973,6 +981,14 @@ case class AdaptiveExecutionContext(session: SparkSession, 
qe: QueryExecution) {
     new TrieMap[SparkPlan, ExchangeQueryStageExec]()
 
   val shuffleIds: ConcurrentHashMap[Int, Boolean] = new ConcurrentHashMap[Int, 
Boolean]()
+
+  /**
+   * The per-`AdaptiveSparkPlanExec` planning trackers for this query: the 
main query plan and every
+   * sub-query plan, which are planned on separate threads. The main node 
merges these into
+   * `qe.tracker` once its final plan is ready (see 
`AdaptiveSparkPlanExec.finalPlanUpdate`).
+   */
+  val planningTrackers: ConcurrentLinkedQueue[QueryPlanningTracker] =
+    new ConcurrentLinkedQueue[QueryPlanningTracker]()
 }
 
 /**
diff --git 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/InsertAdaptiveSparkPlan.scala
 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/InsertAdaptiveSparkPlan.scala
index d8f850ab2189..835505a5eaec 100644
--- 
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/InsertAdaptiveSparkPlan.scala
+++ 
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/InsertAdaptiveSparkPlan.scala
@@ -78,7 +78,10 @@ case class InsertAdaptiveSparkPlan(
           val preprocessingRules = Seq(
             planSubqueriesRule)
           // Run pre-processing rules.
-          val newPlan = AdaptiveSparkPlanExec.applyPhysicalRules(plan, 
preprocessingRules)
+          val newPlan = AdaptiveSparkPlanExec.applyPhysicalRules(
+            plan,
+            preprocessingRules,
+            "AQE Preprocessing")
           logDebug(s"Adaptive execution enabled for plan: $plan")
           AdaptiveSparkPlanExec(newPlan, adaptiveExecutionContext, 
preprocessingRules, isSubquery)
         } catch {
diff --git 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
index aac68e638980..40accf0df4a0 100644
--- 
a/sql/core/src/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
+++ 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
@@ -18,6 +18,7 @@
 package org.apache.spark.sql.execution
 
 import org.apache.spark.sql.execution.streaming.runtime.{MemoryStream, 
StreamExecution}
+import org.apache.spark.sql.internal.SQLConf
 import org.apache.spark.sql.streaming.StreamTest
 
 class QueryPlanningTrackerEndToEndSuite extends StreamTest {
@@ -58,6 +59,43 @@ class QueryPlanningTrackerEndToEndSuite extends StreamTest {
       StopStream)
   }
 
+  test("SPARK-57212: Track preparation rules") {
+    withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "false") {
+      val df = spark.range(1000).selectExpr("count(*)")
+      df.collect()
+      val ruleNames = df.queryExecution.tracker.rules.keySet
+      assert(ruleNames.contains(classOf[
+        org.apache.spark.sql.execution.exchange.EnsureRequirements].getName))
+      assert(ruleNames.contains(classOf[
+        org.apache.spark.sql.execution.CollapseCodegenStages].getName))
+    }
+  }
+
+  test("SPARK-57212: Track AQE-internal preparation rules") {
+    // Run a query with a shuffle so AQE creates a query stage and runs 
preparation rules.
+    val df = spark.range(1000).repartition(4).selectExpr("count(*)")
+    df.collect()
+    val ruleNames = df.queryExecution.tracker.rules.keySet
+    // AQE-only rules in queryStagePreparationRules; only reachable via
+    // AdaptiveSparkPlanExec.applyPhysicalRules.
+    assert(ruleNames.contains(
+      "org.apache.spark.sql.execution.adaptive.AdjustShuffleExchangePosition"))
+    assert(ruleNames.contains(
+      "org.apache.spark.sql.execution.adaptive.ValidateSparkPlan"))
+  }
+
+  test("SPARK-57212: Track sub-query AQE rules") {
+    // The main query has no shuffle, so its AQE never re-optimizes and thus 
never runs the AQE
+    // logical optimizer. The scalar sub-query does have a shuffle, so its 
sub-AQE (planned on a
+    // separate thread) re-optimizes and runs `DynamicJoinSelection`. That 
rule is only visible in
+    // the query tracker if every `AdaptiveSparkPlanExec`'s tracker is merged 
back into it.
+    val df = spark.sql("SELECT id FROM range(10) WHERE id > (SELECT count(*) 
FROM range(1000))")
+    df.collect()
+    val ruleNames = df.queryExecution.tracker.rules.keySet
+    assert(ruleNames.contains(
+      "org.apache.spark.sql.execution.adaptive.DynamicJoinSelection"))
+  }
+
   test("The start times should be in order: parsing <= analysis <= 
optimization <= planning") {
     val df = spark.sql("select count(*) from range(1)")
     df.queryExecution.executedPlan


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

Reply via email to