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

cloud-fan 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 522736869aae [SPARK-57212][SQL][FOLLOWUP] Record AQE rule timing into 
the shared tracker via a lock instead of per-node trackers
522736869aae is described below

commit 522736869aae4da5a8a1c6605b15d7d6f51f0d6c
Author: Wenchen Fan <[email protected]>
AuthorDate: Tue Jun 9 13:03:32 2026 -0700

    [SPARK-57212][SQL][FOLLOWUP] Record AQE rule timing into the shared tracker 
via a lock instead of per-node trackers
    
    ### What changes were proposed in this pull request?
    
    This is a follow-up of #56275, addressing 
https://github.com/apache/spark/pull/56275#discussion_r3376088031.
    
    The original PR routes physical preparation and AQE rules through 
`RuleExecutor` so their per-rule timing lands in `QueryPlanningTracker`. To 
avoid concurrent writes to a single tracker from the threads that plan scalar / 
IN / DPP subqueries, it gave each `AdaptiveSparkPlanExec` its own 
`QueryPlanningTracker`, registered them in 
`AdaptiveExecutionContext.planningTrackers`, and folded them all into the 
query's shared tracker (`context.qe.tracker`) in `finalPlanUpdate` via a new 
`QueryP [...]
    
    This PR replaces that machinery with a lock on the single shared tracker. 
The only mutation point is `QueryPlanningTracker.recordRuleInvocation`; making 
it (and the `rules` / `topRulesByTime` read accessors) `synchronized` lets 
every AQE node -- main and subquery -- record straight into the single 
`context.qe.tracker`, which is already reachable from every node (sub-AQEs 
reuse the same `AdaptiveExecutionContext`).
    
    That lets the following pieces, which existed solely to avoid concurrent 
writes, be deleted:
    - the per-`AdaptiveSparkPlanExec` `tracker` field,
    - `AdaptiveExecutionContext.planningTrackers`,
    - `QueryPlanningTracker.merge`,
    - the merge loop in `finalPlanUpdate`, and
    - the `if (!isSubquery)` special-case there.
    
    `PhysicalRuleExecutor`, the `applyPhysicalRules` signature change, and the 
`withTracker` wrapping from the original PR are unchanged.
    
    ### Why are the changes needed?
    
    Beyond the reduced surface area, the lock makes correctness local:
    - As written, the merge is safe only because sub-AQEs complete (via 
`waitForSubqueries` -> `awaitResult`) before `finalPlanUpdate` reads their 
trackers, and because `finalPlanUpdate` is a `lazy val` that runs once. That is 
a non-obvious chain. A lock makes the guarantee local to the one mutating 
method and independent of when subqueries complete or when `finalPlanUpdate` 
runs, so a future change to subquery scheduling or plan finalization cannot 
silently reintroduce a race.
    - Any future code that records a rule from another thread is automatically 
covered by the lock; with per-node trackers each new path would have to wire up 
its own tracker plus merge.
    - The cost is negligible: recording is one `HashMap` put per rule 
invocation, so an uncontended monitor on the single-threaded analyzer / 
optimizer path is tens of ns against ms-scale planning, and it is contended 
only by the rare concurrent subquery-planning threads.
    - A shared tracker reflects AQE rules as they run, rather than only after 
`finalPlanUpdate` at execution time.
    
    ### Does this PR introduce _any_ user-facing change?
    
    No. This is an internal refactor of how the same per-rule timing is 
recorded; `tracker.rules`, `tracker.topRulesByTime(...)`, and 
`RuleExecutor.dumpTimeSpent()` cover the same rules as after #56275.
    
    ### How was this patch tested?
    
    Existing tests in `QueryPlanningTrackerEndToEndSuite` (added by #56275), 
including `SPARK-57212: Track sub-query AQE rules`, which exercises the 
cross-thread subquery-recording path, continue to pass.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 4.8
    
    Closes #56410 from cloud-fan/SPARK-57212-tracker-lock-followup.
    
    Authored-by: Wenchen Fan <[email protected]>
    Signed-off-by: Wenchen Fan <[email protected]>
---
 .../spark/sql/catalyst/QueryPlanningTracker.scala  | 36 ++++++++++------------
 .../execution/adaptive/AdaptiveSparkPlanExec.scala | 29 +++--------------
 .../QueryPlanningTrackerEndToEndSuite.scala        |  2 +-
 3 files changed, 23 insertions(+), 44 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 9915c03eaa33..2595eebceb9e 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
@@ -72,8 +72,11 @@ object QueryPlanningTracker {
   }
 
   /**
-   * A thread local variable to implicitly pass the tracker around. This 
assumes the query planner
-   * is single-threaded, and avoids passing the same tracker context in every 
function call.
+   * A thread local variable to implicitly pass the tracker around, avoiding 
passing the same
+   * tracker context in every function call. The same tracker may be shared 
across threads -- e.g.
+   * AQE plans scalar / IN / DPP subqueries on separate threads, all recording 
into the query's
+   * tracker -- so the recording path 
([[QueryPlanningTracker.recordRuleInvocation]] and the
+   * reporting accessors) is synchronized.
    */
   private val localTracker = new ThreadLocal[QueryPlanningTracker]() {
     override def initialValue: QueryPlanningTracker = null
@@ -82,7 +85,7 @@ object QueryPlanningTracker {
   /** Returns the current tracker in scope, based on the thread local 
variable. */
   def get: Option[QueryPlanningTracker] = Option(localTracker.get())
 
-  /** Sets the current tracker for the execution of function f. We assume f is 
single-threaded. */
+  /** Sets the current tracker for the execution of function f. */
   def withTracker[T](tracker: QueryPlanningTracker)(f: => T): T = {
     val originalTracker = localTracker.get()
     localTracker.set(tracker)
@@ -194,11 +197,17 @@ class QueryPlanningTracker(
   /**
    * Record a specific invocation of a rule.
    *
+   * `synchronized` because the same tracker can be written from multiple 
threads: AQE plans
+   * scalar / IN / DPP subqueries on separate threads, all recording into the 
query's tracker.
+   * Recording is one `HashMap` put against ms-scale planning, so the lock is 
uncontended on the
+   * single-threaded analyzer / optimizer path and contended only by the rare 
concurrent
+   * subquery-planning threads.
+   *
    * @param rule name of the rule
    * @param timeNs time taken to run this invocation
    * @param effective whether the invocation has resulted in a plan change
    */
-  def recordRuleInvocation(rule: String, timeNs: Long, effective: Boolean): 
Unit = {
+  def recordRuleInvocation(rule: String, timeNs: Long, effective: Boolean): 
Unit = synchronized {
     var s = rulesMap.get(rule)
     if (s eq null) {
       s = new RuleSummary
@@ -210,22 +219,11 @@ 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
+  // `rules` and `topRulesByTime` are `synchronized` to read a consistent 
snapshot of `rulesMap`
+  // while concurrent subquery-planning threads may still be recording (see 
`recordRuleInvocation`).
+  def rules: Map[String, RuleSummary] = synchronized { rulesMap.asScala.toMap }
 
   def phases: Map[String, PhaseSummary] = phasesMap.asScala.toMap
 
@@ -233,7 +231,7 @@ class QueryPlanningTracker(
    * Returns the top k most expensive rules (as measured by time). If k is 
larger than the rules
    * seen so far, return all the rules. If there is no rule seen so far or k 
<= 0, return empty seq.
    */
-  def topRulesByTime(k: Int): Seq[(String, RuleSummary)] = {
+  def topRulesByTime(k: Int): Seq[(String, RuleSummary)] = synchronized {
     if (k <= 0) {
       Seq.empty
     } else {
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 f98eaefc8cf7..9a483076ff56 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, ConcurrentLinkedQueue, 
LinkedBlockingQueue}
+import java.util.concurrent.{ConcurrentHashMap, LinkedBlockingQueue}
 
 import scala.collection.concurrent.TrieMap
 import scala.collection.mutable
@@ -86,11 +86,6 @@ 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
@@ -207,7 +202,7 @@ case class AdaptiveSparkPlanExec(
   }
 
   @transient val initialPlan = context.session.withActive {
-    QueryPlanningTracker.withTracker(tracker) {
+    QueryPlanningTracker.withTracker(context.qe.tracker) {
       applyPhysicalRules(
         applyQueryPostPlannerStrategyRules(inputPlan),
         queryStagePreparationRules,
@@ -279,9 +274,9 @@ 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 {
-      // 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) {
+      // Record this node's planning rules into the query's shared tracker. 
Subquery plans are
+      // planned on separate threads, so `recordRuleInvocation` is 
synchronized.
+      QueryPlanningTracker.withTracker(context.qe.tracker) {
         val executionId = getExecutionId
         // Use inputPlan logicalLink here in case some top level physical 
nodes may be removed
         // during `initialPlan`
@@ -414,12 +409,6 @@ 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)}")
   }
 
@@ -981,14 +970,6 @@ 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/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
 
b/sql/core/src/test/scala/org/apache/spark/sql/execution/QueryPlanningTrackerEndToEndSuite.scala
index 40accf0df4a0..c23d4e354c8a 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
@@ -88,7 +88,7 @@ class QueryPlanningTrackerEndToEndSuite extends StreamTest {
     // 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.
+    // the query tracker if every `AdaptiveSparkPlanExec` records into the 
shared query tracker.
     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


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

Reply via email to