peter-toth commented on code in PR #55839:
URL: https://github.com/apache/spark/pull/55839#discussion_r3702602173


##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala:
##########
@@ -420,6 +426,45 @@ case class AdaptiveSparkPlanExec(
       .get.asInstanceOf[T]
   }
 
+  private def cancelObsoleteStages(

Review Comment:
   **Finding 1.** The description (and the JIRA) describe a narrower change 
than this diff.
   
   - The wrapper list under "What changes were proposed" names 
`AQEShuffleReadExec`, `SortExec`, `ProjectExec`, `ColumnarToRowExec`, but the 
rule also gained a `BaseLimitExec` branch 
(`AQEPropagateEmptyRelation.scala:82`).
   - "cancellation failures from those obsolete stages are ignored instead of 
being treated as query failures" reads as though AQE already cancelled obsolete 
stages. It doesn't. On the merge base, `git grep '\.cancel(' -- 
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/` returns 
exactly one hit, in `cleanUpAndThrowException`. This method is what makes AQE 
proactively kill exchange stages that re-planning dropped, and that is a 
user-visible change: jobs get cancelled mid-flight and 
`SparkListenerJobEnd(JobFailed)` events show up where none did before -- your 
own test asserts one.
   - The `stageLifecycleLock` that now serialises exchange reuse against 
cancellation, and the `markSharedStageResult` / `isSharedStageResult` tracking, 
aren't mentioned at all.
   - "How was this patch tested" lists 2 tests; the PR adds 5.
   
   Since the cancellation is the part with a real behaviour change, please 
spell it out under "Does this PR introduce _any_ user-facing change?" so it's 
findable from the commit log.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala:
##########
@@ -363,6 +368,314 @@ class AdaptiveQueryExecSuite
     }
   }
 
+  test("empty materialized stage short-circuits AQE through sort wrappers") {

Review Comment:
   **Finding 2.** Neither end-to-end test fails on the unfixed rule. Reproduced 
in a worktree at `5a4b32a`:
   
   ```
   git checkout cce435599ed3 -- 
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEPropagateEmptyRelation.scala
   build/sbt 'sql/testOnly *AdaptiveQueryExecSuite -- -z "empty materialized 
stage short-circuits AQE through sort wrappers" -z "empty filtered global 
aggregate stage preserves its single output row"'
   ```
   
   Both pass, `JobFailed`/"cancel" assertion included.
   
   The reason is that there is no sort wrapper in play. `EnsureRequirements` 
builds its `SortExec` with the plain constructor, so it carries neither 
`LOGICAL_PLAN_TAG` nor `LOGICAL_PLAN_INHERITED_TAG` and `SparkPlan.logicalLink` 
is `None` for it (`SparkPlan.scala:112`). 
`replaceWithQueryStagesInLogicalPlan`'s `collectFirst` therefore resolves 
`physicalNode` to the `ShuffleQueryStageExec` itself, 
`LogicalQueryStage.isDirectStage` is `true`, and the pre-existing `case stage: 
QueryStageExec if stage.isMaterialized` branch is what collapses the join -- on 
base exactly as here. The test name promises the new path and measures the old 
one.
   
   (The second test is a legitimate guard against the new `case aggregate: 
BaseAggregateExec => getEstimatedRowCount(aggregate.child)` fall-through, which 
is why it also passes on base -- base never had that fall-through. No complaint 
there, it just doesn't cover the new capability either.)
   
   The `nonEmpty` side of the global-aggregate case is the cheapest thing to 
pin, since it's the one place where the new `Some(1)` changes an outcome: 
`PropagateEmptyRelationBase` only consults `nonEmpty(p.right)` for a 
`LeftSemi`/`LeftAnti` join with **no** join condition. On base 
`getEstimatedRowCount` returns `None` for `LogicalQueryStage(_, 
HashAggregateExec(no grouping) -> stage)` and the join stays; here it returns 
`Some(1)` and the join is rewritten to its left side. Something like:
   
   ```scala
   val agg = spark.range(1).where("id < 0").agg(count("*").as("c"))
   val df = testData.join(agg, Seq.empty[String], "left_semi")
   checkAnswer(df, testData.collect().toSeq)
   assert(collect(stripAQEPlan(df.queryExecution.executedPlan)) { case j: 
BaseJoinExec => j }.isEmpty)
   ```
   
   I haven't run that exact query, so please confirm the join really arrives 
with `condition = None` and that the assertion fails with the rule at base 
before committing it -- the SQL `EXISTS` spelling is the obvious alternative 
but `RewriteNonCorrelatedExists` may rewrite it out from under you.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala:
##########
@@ -986,6 +1042,23 @@ case class AdaptiveExecutionContext(session: 
SparkSession, qe: QueryExecution) {
   val stageCache: TrieMap[SparkPlan, ExchangeQueryStageExec] =
     new TrieMap[SparkPlan, ExchangeQueryStageExec]()
 
+  private val stageLifecycleLock = new Object
+
+  private[adaptive] def withStageLifecycleLock[T](body: => T): T = {
+    stageLifecycleLock.synchronized(body)
+  }
+
+  private val sharedStageResults =
+    new ConcurrentHashMap[AtomicReference[Option[Any]], Boolean]()
+
+  def markSharedStageResult(resultOption: AtomicReference[Option[Any]]): Unit 
= {

Review Comment:
   **Finding 6.** None of the new stage-lifecycle machinery is documented, and 
this is the corner of AQE where the invariants are hardest to re-derive. 
`withStageLifecycleLock`, `markSharedStageResult`, `isSharedStageResult`, 
`cancelObsoleteStages` and `removeStageFromCache` have no scaladoc at all, so 
the safety argument lives only in the reviewer's head. Three parts of it are 
worth writing down, because I had to reconstruct each one:
   
   - why `!stage.isMaterialized` is sufficient for a stage that is nested 
inside a *later* stage's `plan` and therefore invisible to 
`newPhysicalPlan.collect` (it can only be nested there once materialized, since 
`createNonResultQueryStages` creates a stage only when 
`allChildStagesMaterialized`);
   - why `isSharedStageResult` is needed on top of the `resultOption.eq` 
retention check (it covers reuse by a *different* `AdaptiveSparkPlanExec` 
sharing this `context` -- a subquery -- whose reuse instance isn't in this plan 
at all);
   - why the lock has to span the `stageCache` lookup and `reuseQueryStage` 
together (`markSharedStageResult` must be visible before a concurrent 
cancellation reads it).
   
   One consequence also deserves stating outright: `sharedStageResults` is 
never cleared, so once an exchange has been reused anywhere in the query, 
neither it nor any of its reuse instances can ever be cancelled as obsolete 
again. That's a fine trade, but it means the optimization silently stops 
applying to reuse-heavy plans, which a future reader shouldn't have to discover 
by experiment.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala:
##########
@@ -397,6 +401,8 @@ case class AdaptiveSparkPlanExec(
                   currentPhysicalPlan.treeString, 
newPhysicalPlan.treeString).mkString("\n")
                 logOnLevel(log"Plan changed:\n${MDC(QUERY_PLAN, plans)}")
                 cleanUpTempTags(newPhysicalPlan)
+                obsoleteCancelledStageIds ++=

Review Comment:
   **Finding 8.** Worth considering landing this as two PRs, because these are 
two separate features rather than one change.
   
   Neither half depends on the other:
   
   - `AQEPropagateEmptyRelation` references nothing the other half adds -- no 
`cancelObsoleteStages`, no `withStageLifecycleLock`, no 
`markSharedStageResult`. Its only new import is `execution.{BaseLimitExec, 
ColumnarToRowExec, ProjectExec, SortExec, SparkPlan}`.
   - `cancelObsoleteStages` references neither the rule nor `EmptyRelation`. 
It's driven purely by `stagesToReplace` minus what survives in 
`newPhysicalPlan`, i.e. by *any* re-plan that drops a branch -- a join flipping 
to a broadcast, a skew-join rewrite, an `EmptyRelation` from the pre-existing 
direct-stage propagation. It doesn't need this rule in order to have work to do.
   
   That second point isn't only a reading of the code: when I reverted 
`AQEPropagateEmptyRelation.scala` to the merge base and re-ran the new tests 
(finding 2), the cancellation still fired -- `TaskKilled (Stage cancelled: 
[SPARK_JOB_CANCELLED] ... The query stage is no longer referenced by the 
current adaptive plan.)` across two jobs. The cancellation half is fully 
exercised by AQE behaviour that already exists on master.
   
   So this is two features sharing one JIRA, and the bundling has already cost 
something concrete. Each needs its own "what changed / why / how tested" -- 
finding 1 is largely the description of one feature being stretched over two. 
And the rule half's gaps (findings 2 and 3) are easy to miss beside the 
cancellation work: the three approvals so far all describe the wrapper 
recursion as verified for safety, which it is, without touching whether it runs.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala:
##########
@@ -986,6 +1042,23 @@ case class AdaptiveExecutionContext(session: 
SparkSession, qe: QueryExecution) {
   val stageCache: TrieMap[SparkPlan, ExchangeQueryStageExec] =
     new TrieMap[SparkPlan, ExchangeQueryStageExec]()
 
+  private val stageLifecycleLock = new Object
+
+  private[adaptive] def withStageLifecycleLock[T](body: => T): T = {
+    stageLifecycleLock.synchronized(body)
+  }
+
+  private val sharedStageResults =
+    new ConcurrentHashMap[AtomicReference[Option[Any]], Boolean]()
+
+  def markSharedStageResult(resultOption: AtomicReference[Option[Any]]): Unit 
= {
+    sharedStageResults.put(resultOption, true)
+  }
+
+  def isSharedStageResult(resultOption: AtomicReference[Option[Any]]): Boolean 
= {

Review Comment:
   **Finding 10.** `markSharedStageResult` and `isSharedStageResult` are fully 
public, while the sibling `withStageLifecycleLock` a few lines up is 
`private[adaptive]`. Every caller, `AdaptiveQueryExecSuite` included, is in 
`org.apache.spark.sql.execution.adaptive`, so `private[adaptive]` works for 
both and keeps them off `AdaptiveExecutionContext`'s public surface.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala:
##########
@@ -420,6 +426,45 @@ case class AdaptiveSparkPlanExec(
       .get.asInstanceOf[T]
   }
 
+  private def cancelObsoleteStages(
+      newPhysicalPlan: SparkPlan,
+      stagesToReplace: Seq[QueryStageExec]): Seq[Int] = {
+    val newStages = newPhysicalPlan.collect {
+      case stage: QueryStageExec => stage
+    }
+    val obsoleteStages = stagesToReplace.collect {
+      case stage: ExchangeQueryStageExec
+          if !newStages.exists(newStage =>
+            newStage.id == stage.id || 
newStage.resultOption.eq(stage.resultOption)) => stage
+    }
+    obsoleteStages.flatMap { stage =>
+      context.withStageLifecycleLock {
+        if (!stage.isMaterialized && 
!context.isSharedStageResult(stage.resultOption)) {
+          removeStageFromCache(stage)

Review Comment:
   **Finding 4.** `removeStageFromCache(stage)` runs before 
`stage.cancel(...)`, so a cancellation that throws leaves the stage in the 
worst of both worlds: evicted from `stageCache` but still materializing, and 
absent from `obsoleteCancelledStageIds`.
   
   Failure scenario: `ShuffleQueryStageExec.doCancel` -> `cancelShuffleJob` -> 
`futureAction.get().foreach(_.cancel(reason))` throws (or 
`BroadcastQueryStageExec` -> `cancelJobsWithTag` throws). We log and return 
`None`, and the stage keeps running. Its cache entry is gone, so the next 
identical exchange in the query builds a second stage for the same shuffle 
instead of reusing this one. Then, when the half-cancelled stage reports 
`StageFailure`, `obsoleteCancelledStageIds.contains(stage.id)` is false at 
`:361`, so `errors.append(ex)` -> `cleanUpAndThrowException` fails the whole 
query over a stage that is no longer in the plan -- exactly the failure this PR 
set out to remove.
   
   @viirya read this as safe on the grounds that a failed cancel isn't silently 
swallowed. That's accurate; my point is that for an obsolete stage, not 
swallowing it is the bug. Note this does not undo @shrirangmhalgi's fix: stages 
*skipped* because they are materialized or shared must still return `None` and 
must not be suppressed, and they do below.
   
   ```scala
   if (!stage.isMaterialized && 
!context.isSharedStageResult(stage.resultOption)) {
     try {
       stage.cancel("The query stage is no longer referenced by the current 
adaptive plan.")
     } catch {
       case NonFatal(t) =>
         logError(s"Exception in cancelling obsolete query stage: 
${stage.treeString}", t)
     }
     // The stage is obsolete either way: drop it from the cache so it can't be 
reused, and
     // suppress its materialization failure, which is no longer relevant to 
this query.
     removeStageFromCache(stage)
     Some(stage.id)
   } else {
     None
   }
   ```
   
   `obsolete stage cancellation preserves shared reused exchange stages` pins 
the current behaviour (`assert(failedCancelledIds.isEmpty)`), so it would need 
updating alongside.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEPropagateEmptyRelation.scala:
##########
@@ -52,19 +53,46 @@ object AQEPropagateEmptyRelation extends 
PropagateEmptyRelationBase {
   //   - positive value means an estimated row count which can be 
over-estimated
   //   - none means the plan has not materialized or the plan can not be 
estimated
   private def getEstimatedRowCount(plan: LogicalPlan): Option[BigInt] = plan 
match {
-    case LogicalQueryStage(_, stage: QueryStageExec) if stage.isMaterialized =>
+    case LogicalQueryStage(_, physicalPlan) =>
+      getEstimatedRowCount(physicalPlan)
+
+    case _: EmptyRelation => Some(0)
+
+    case _ => None
+  }
+
+  private def getEstimatedRowCount(plan: SparkPlan): Option[BigInt] = plan 
match {
+    case stage: QueryStageExec if stage.isMaterialized =>
       stage.getRuntimeStatistics.rowCount
 
-    case LogicalQueryStage(_, agg: BaseAggregateExec) if 
agg.groupingExpressions.nonEmpty &&
-      agg.child.isInstanceOf[QueryStageExec] =>
-      val stage = agg.child.asInstanceOf[QueryStageExec]
-      if (stage.isMaterialized) {
-        stage.getRuntimeStatistics.rowCount
+    case read: AQEShuffleReadExec =>

Review Comment:
   **Finding 3.** Three of the five wrapper branches never execute. I wrapped 
each new case in a marker printing the branch name and its return value, then 
ran the whole suite (`build/sbt 'sql/testOnly *AdaptiveQueryExecSuite'`, 134 
tests, all green):
   
   | branch | hits |
   | --- | --- |
   | `SortExec` | 24 (12x `Some(10)`, 4x `Some(80)`, 8x `None`) |
   | `BaseAggregateExec`, empty grouping | 34 (17x `Some(2)`, 12x `Some(1)`, 5x 
`None`) |
   | `BaseLimitExec` | 10 -- all ten from `limit wrappers propagate only 
provably empty query stages`, none from a real query |
   | `AQEShuffleReadExec` | 0 |
   | `ProjectExec` | 0 |
   | `ColumnarToRowExec` | 0 |
   
   So the shape the description leads with cannot be the one that fires -- 
`AQEShuffleReadExec` is never reached. That's consistent with how 
`physicalNode` is picked (finding 2): a physical wrapper only becomes 
`LogicalQueryStage.physicalPlan` if it carries the same logical link as the 
stage below it, and `AQEShuffleReadExec` (built by `OptimizeSkewedJoin` with 
the plain constructor, no link) doesn't, while `ColumnarToRowExec` is inserted 
by `postStageCreationRules` and so lands *inside* a stage's own plan or inside 
`ResultQueryStageExec.plan`, after which there is no more re-planning. And 
wherever a physical wrapper does sit above a stage, the corresponding *logical* 
operator sits above the `LogicalQueryStage`, where 
`PropagateEmptyRelationBase.commonApplyFunc` already propagates emptiness 
through `Project` / `Filter` / `Sort` / `GlobalLimit` / `LocalLimit` / `Offset` 
/ `RepartitionOperation`.
   
   Worth noting that even `SortExec` never returned `Some(0)` in that run -- it 
only ever fed `nonEmpty`, so the one branch that does execute has no observed 
effect on `isEmpty`.
   
   Either way out is fine by me: show a query that reaches each branch and 
assert the outcome in a test that fails on base, or drop the branches that 
can't be reached and keep the aggregate case, which does fire 34 times and is 
the part with a real semantic delta over base.
   
   This is where I part company with the approvals: @yadavay-amzn and @viirya 
both verified that the four wrappers *preserve* emptiness, which I agree with 
-- a pass-through can't be unsafe. Reachability is the separate question, and 
it's the one that decides whether the branches earn their place.
   



##########
sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala:
##########
@@ -363,6 +368,314 @@ class AdaptiveQueryExecSuite
     }
   }
 
+  test("empty materialized stage short-circuits AQE through sort wrappers") {
+    withSQLConf(
+      SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+      SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+      SQLConf.SHUFFLE_PARTITIONS.key -> "10") {
+      val jobEndEvents = new mutable.ArrayBuffer[SparkListenerJobEnd]
+      val listener = new SparkListener {
+        override def onJobEnd(jobEnd: SparkListenerJobEnd): Unit = 
jobEndEvents.synchronized {
+          jobEndEvents += jobEnd
+        }
+      }
+      spark.sparkContext.addSparkListener(listener)
+      try {
+        val left = spark.range(0, 1, 1, 1).where("id < 
0").select($"id".as("k"))
+        val right = spark.range(0, 200, 1, 20).as[Long].map { id =>

Review Comment:
   **Finding 7.** This test turns on a race. `Thread.sleep(200)` x 200 rows 
over 20 partitions has to keep the right stage unfinished until the left stage 
materializes, the re-plan runs and `cancelObsoleteStages` reaches it; if the 
right side wins, no job is cancelled and the `JobFailed` assertion at 
`:398-404` fails. It also costs seconds of wall clock in an otherwise 
sub-second suite (2.7s and 4.7s across my two runs), and 
`e.getMessage.toLowerCase(Locale.ROOT).contains("cancel")` couples it to the 
wording of `SPARK_JOB_CANCELLED`.
   
   Two cheaper options: have the right side block on a `CountDownLatch` the 
test releases after observing the cancellation, instead of a fixed sleep; or 
drop the `JobFailed` assertion from this test entirely and leave cancellation 
to `obsolete stage cancellation preserves shared reused exchange stages`, which 
already covers it deterministically.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AdaptiveSparkPlanExec.scala:
##########
@@ -420,6 +426,45 @@ case class AdaptiveSparkPlanExec(
       .get.asInstanceOf[T]
   }
 
+  private def cancelObsoleteStages(
+      newPhysicalPlan: SparkPlan,
+      stagesToReplace: Seq[QueryStageExec]): Seq[Int] = {
+    val newStages = newPhysicalPlan.collect {
+      case stage: QueryStageExec => stage
+    }
+    val obsoleteStages = stagesToReplace.collect {
+      case stage: ExchangeQueryStageExec
+          if !newStages.exists(newStage =>
+            newStage.id == stage.id || 
newStage.resultOption.eq(stage.resultOption)) => stage
+    }
+    obsoleteStages.flatMap { stage =>
+      context.withStageLifecycleLock {
+        if (!stage.isMaterialized && 
!context.isSharedStageResult(stage.resultOption)) {
+          removeStageFromCache(stage)
+          try {
+            stage.cancel("The query stage is no longer referenced by the 
current adaptive plan.")

Review Comment:
   **Finding 5.** A cancelled obsolete shuffle stage never registers its 
`shuffleId`, so whatever map output it had already written stays on disk for 
the rest of the application.
   
   `context.shuffleIds` is populated only in the success branch of the 
materialization callback (`:329-334`). `SQLExecution.extractShuffleIds` prefers 
that set over scanning the plan whenever an `AdaptiveSparkPlanExec` is present 
(`SQLExecution.scala:111-121`), and it is what drives `shuffleCleanupMode` at 
the end of the execution (`SQLExecution.scala:242-264`): `RemoveShuffleFiles` 
-> `shuffleDriverComponents.removeShuffle`, `SkipMigration` -> 
`addShuffleToSkip`. Before this PR the obsolete stage ran to completion, hit 
the success branch and got cleaned up with the rest; now it is cancelled, so 
the map tasks that finished before the cancellation took effect are only 
reclaimed when `ContextCleaner` GCs the RDD.
   
   The obvious fix is to mirror the success-path registration for the stages we 
cancel:
   
   ```scala
   stage.plan.collect { case s: ShuffleExchangeLike => 
context.shuffleIds.put(s.shuffleId, true) }
   ```
   
   One wrinkle to handle: `ShuffleExchangeExec.shuffleId` is 
`shuffleDependency.shuffleId` off a `lazy val`, so touching it on a stage whose 
job never started would *create* a shuffle dependency rather than describe one. 
Worth gating on the stage having actually been submitted.
   



##########
sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEPropagateEmptyRelation.scala:
##########
@@ -52,19 +53,46 @@ object AQEPropagateEmptyRelation extends 
PropagateEmptyRelationBase {
   //   - positive value means an estimated row count which can be 
over-estimated
   //   - none means the plan has not materialized or the plan can not be 
estimated
   private def getEstimatedRowCount(plan: LogicalPlan): Option[BigInt] = plan 
match {
-    case LogicalQueryStage(_, stage: QueryStageExec) if stage.isMaterialized =>
+    case LogicalQueryStage(_, physicalPlan) =>
+      getEstimatedRowCount(physicalPlan)
+
+    case _: EmptyRelation => Some(0)
+
+    case _ => None
+  }
+
+  private def getEstimatedRowCount(plan: SparkPlan): Option[BigInt] = plan 
match {

Review Comment:
   **Finding 9.** `LogicalQueryStage.computeStats()` already does what this 
method does, global-aggregate special case included 
(`LogicalQueryStage.scala:57-71`):
   
   ```scala
   val physicalStats = physicalPlan.collectFirst {
     case a: BaseAggregateExec if a.groupingExpressions.isEmpty =>
       a.collectFirst { case s: QueryStageExec => s.computeStats() 
}.flatten.map { stat =>
         if (stat.rowCount.contains(0)) stat.copy(rowCount = Some(1)) else stat
       }
     case s: QueryStageExec => s.computeStats()
   }.flatten
   ```
   
   So the tree now holds two implementations of "find the row count under this 
`LogicalQueryStage`'s physical plan, and count a global aggregate over an empty 
stage as one row", with different traversal rules: `computeStats` uses 
`collectFirst` and therefore looks through *any* node -- its own `TODO this is 
not accurate when there is other physical nodes above QueryStageExec` says as 
much, and a `FilterExec` or a join above the stage would be walked straight 
through -- while this method uses an allowlist. They can disagree, and a fix to 
one won't reach the other.
   
   Worth considering: lift the allowlist walk into a single helper next to 
`LogicalQueryStage` and have both `computeStats` and this rule call it. That 
also tightens `computeStats`, since the allowlist is the more conservative of 
the two. If you'd rather not touch `computeStats` here, a comment on each 
pointing at the other would at least make the coupling visible.
   



-- 
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