This is an automated email from the ASF dual-hosted git repository.
sunchao 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 688a73cd32a4 [SPARK-56842][SQL] Short-circuit AQE when materialized
stages are empty
688a73cd32a4 is described below
commit 688a73cd32a4d3fdf8446053ded2049fa47b5487
Author: Chao Sun <[email protected]>
AuthorDate: Fri Aug 21 10:06:08 2026 -0700
[SPARK-56842][SQL] Short-circuit AQE when materialized stages are empty
### Why are the changes needed?
Adaptive Query Execution can miss a provable empty or nonempty result when
a materialized query stage is represented through a supported physical sort,
limit, or aggregate operator. In particular, a global aggregate over empty
input still produces one output row: treating that aggregate as empty changes
the result of conditionless semi/anti joins.
Adaptive replanning can also remove exchange stages that are still
materializing. Leaving a private obsolete stage running wastes work and can let
an irrelevant materialization failure abort the selected query plan. Reusing
the same exchange inside one adaptive plan must not permanently protect that
result after every local alias disappears, while reuse by another adaptive plan
or subquery must remain protected. Some obsolete stages cannot safely be
cancelled at all, and an opaque del [...]
### What changes were proposed in this PR?
- Infer runtime row counts only through the supported, explicitly safe
`SortExec`, `BaseLimitExec`, and `BaseAggregateExec` cases. A global aggregate
over an empty stage reports one row; grouped aggregates preserve the child
estimate; limits propagate only provable emptiness, including `LIMIT 0`.
- Remove the unsupported `AQEShuffleReadExec`, `ProjectExec`, and
`ColumnarToRowExec` recursion paths instead of claiming coverage for physical
shapes that the adaptive rule cannot reach.
- Derive obsolete-stage candidates from both the previous physical plan and
the current round of replacement stages, deduplicating stage IDs before
applying the unfinished/private-exchange checks so an older stage remains
discoverable when a later replan drops its branch.
- Record the owning adaptive-plan instance when an exchange result enters
the shared stage cache. Reuse within that same plan remains cancellable once
every local alias disappears; reuse by a different plan or subquery, or reuse
of an entry without a recorded owner, remains permanently protected. Compare
owners by reference identity rather than the plan's structural `equals`
implementation.
- Track every historical local stage ID associated with a reused result,
group cancellation by result identity, and cancel its underlying exchange at
most once. Return or mark all alias IDs so every materialization callback
suppresses an irrelevant ordinary failure while retained/shared and fatal
failures continue to propagate.
- Permanently remember a failed cancellation by result identity and never
retry it for a later same-plan reuse alias: a native shuffle marks itself
cancelled before attempting to cancel its `FutureAction`, so a retry could
otherwise report false success while its job is still running. Keep the
original cache entry available and track later aliases for the same guarded
obsolete-failure handling.
- Proactively cancel only eligible, private obsolete shuffle stages. Never
cancel broadcast stages, stages already shared or retained by the adopted plan,
or opaque delegated shuffle exchanges that cannot expose their own cancellable
submission. Mark only private obsolete broadcasts and identifiable submitted
shuffles that cannot safely be cancelled for guarded eventual-failure handling;
opaque delegated shuffles are neither cancelled nor marked for failure
suppression because their h [...]
- Register an already-submitted shuffle ID before any cancellation attempt,
without creating a lazy shuffle dependency for an unsubmitted stage. Resolve
the underlying exchange directly from `ShuffleQueryStageExec.shuffle`,
including aliases wrapped in the leaf `ReusedExchangeExec`. Under
`RemoveShuffleFiles`, do not cancel a submitted shuffle: scheduler cancellation
is asynchronous, so deleting its files could race map writers. Submitted
shuffles remain eligible under cleanup modes t [...]
- Preserve the exchange-cache entry when cancellation fails or an obsolete
stage cannot safely be cancelled. Remove it immediately only after successful
cancellation. For a failed cancellation, an obsolete broadcast, or a known
submitted shuffle protected by `RemoveShuffleFiles`, ignore an eventual
nonfatal failure only after confirming that the stage remains private and
absent from the current physical plan, registering any visible submitted
shuffle ID, and evicting the failed cache [...]
- Serialize exchange-cache lookup, owner registration, reuse, and
cross-plan shared-result marking under short adaptive stage-lifecycle lock
sections. Reserve cancellation using a stage-result-scoped `CompletableFuture`,
then acquire the shuffle monitor and cancel outside the query-global lock. Both
the initial cache lookup and the post-child-planning cache lookup await
cancellation of that same result outside the global lock and retry; unrelated
exchanges continue. Keep stage-local s [...]
- Precompute retained stage IDs and shared-result identities and remove
exchange-cache entries through canonical-key lookup, avoiding repeated
full-cache scans during adaptive replanning.
- Document nested-stage materialization, same-plan versus cross-subquery
exchange ownership, lifecycle lock ordering, alias failure callbacks, and the
permanent protection of externally shared stage results. Keep lifecycle helpers
scoped to `private[adaptive]`.
### Does this PR introduce _any_ user-facing change?
Yes. Eligible obsolete shuffle jobs may be cancelled under cleanup modes
that do not remove submitted shuffle files, and listeners may observe
corresponding failed/cancelled job events. Broadcast jobs, opaque delegated
shuffles, and submitted shuffles subject to immediate file removal are
intentionally left running. An eventual ordinary failure from a private
obsolete broadcast or identifiable cleanup-protected shuffle may be ignored
after cleanup checks; an opaque delegated shuffle's [...]
### How was this PR tested?
The latest owner-aware exchange reuse, alias-wide failure handling, direct
shuffle-registration, and sticky failed-cancellation changes passed both
validation runs on top of published PR head
`4baed923d832a9452d48d6a08a97be04c06e73e6`. The owner-aware follow-up remains
local, uncommitted, and unpublished.
- Focused exchange-reuse regressions: **13 tests passed**, with **0
failures, 0 errors, and 0 skipped tests**; JUnit XML timestamp
`2026-08-19T14:03:59`, duration `4.893s`.
- Complete `AdaptiveQueryExecSuite`: **144 tests passed**, with **0
failures, 0 errors, and 0 skipped tests**; JUnit XML timestamp
`2026-08-19T14:07:18`, duration `127.294s`.
The regression coverage includes:
- A mutation-sensitive conditionless left-semi join over a global aggregate
on empty input, with assertions for the original join and its adaptive rewrite.
- A real planned `ORDER BY ... LIMIT ... DISTINCT` query that establishes a
logically linked physical sort/shuffle shape, plus direct rule assertions for
sort and limit wrappers derived from that shape.
- Zero and positive limits, offsets, unknown statistics, grouped
aggregates, and the single output row of global aggregates over empty input.
- Exchange reuse, private/shared-stage protection, cache retention after
failed cancellation, fatal-stage failure propagation, and broadcast
cancellation safety.
- Same-plan reuse with multiple dropped aliases, one underlying
cancellation, and an ordinary failure callback for each alias; retained aliases
and fatal alias failures remain protected.
- Reuse by a separately owned adaptive subquery remains protected even when
its plan compares structurally equal to the original owner; cache entries with
unknown owners also remain protected.
- A failed cancellation followed by later same-plan reuse keeps the
original exchange cached, never retries the already-marked native cancellation,
and guards eventual failures from the additional aliases.
- Historical same-plan alias IDs remain tracked when an earlier
adaptive-plan adoption has already dropped those aliases; subsequent
cancellation returns every alias ID and suppresses each obsolete ordinary
failure.
- Submitted-shuffle ID registration, including a reused-exchange alias
backed by its actual owner-created shuffle, unsubmitted lazy-dependency safety,
and `RemoveShuffleFiles` protection against active map writers. A
submitted-shuffle regression checks that a wrapped fatal failure still
propagates, a later ordinary obsolete-stage failure is ignored, its shuffle ID
is registered and its failed cache entry is removed, and its underlying shuffle
action is never cancelled.
- An opaque delegated exchange whose own `futureAction` is empty remains
uncancelled and cached; its later ordinary failure is not suppressed because
the hidden delegate's shuffle ID cannot safely be registered.
- A stage present in a constructed previous physical plan but absent from
the latest replacement batch, including deduplication, cancellation, later
nonfatal cancelled-stage failure suppression, and cache retention for the stage
kept by the new plan.
- Unrelated-exchange progress while either a generic cancellation callback
or a real shuffle monitor is blocked; same-result reuse waits outside the
global lock and reuses the original cached stage if cancellation fails.
The focused regressions and complete suite were run against the updated
integration worktree:
```bash
/private/tmp/spark-pr55839-sbt.sh \
'sql/testOnly
org.apache.spark.sql.execution.adaptive.AdaptiveQueryExecSuite -- -z "reuse"'
/private/tmp/spark-pr55839-sbt.sh \
'sql/testOnly
org.apache.spark.sql.execution.adaptive.AdaptiveQueryExecSuite'
```
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: OpenAI Codex
Closes #55839 from sunchao/dev/chao/codex/oss-aqe-empty-stage.
Lead-authored-by: Chao Sun <[email protected]>
Co-authored-by: fredliu-data <[email protected]>
Signed-off-by: Chao Sun <[email protected]>
---
.../adaptive/AQEPropagateEmptyRelation.scala | 37 +-
.../execution/adaptive/AdaptiveSparkPlanExec.scala | 355 ++++++-
.../adaptive/AdaptiveQueryExecSuite.scala | 1099 +++++++++++++++++++-
3 files changed, 1469 insertions(+), 22 deletions(-)
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEPropagateEmptyRelation.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEPropagateEmptyRelation.scala
index e2a013b9e814..d9107c6a128e 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEPropagateEmptyRelation.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/adaptive/AQEPropagateEmptyRelation.scala
@@ -22,6 +22,7 @@ import
org.apache.spark.sql.catalyst.planning.ExtractSingleColumnNullAwareAntiJo
import org.apache.spark.sql.catalyst.plans.logical.EmptyRelation
import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
import org.apache.spark.sql.catalyst.trees.TreePattern.{LOCAL_RELATION,
LOGICAL_QUERY_STAGE, TRUE_OR_FALSE_LITERAL}
+import org.apache.spark.sql.execution.{BaseLimitExec, SortExec, SparkPlan}
import org.apache.spark.sql.execution.aggregate.BaseAggregateExec
import org.apache.spark.sql.execution.exchange.{REPARTITION_BY_COL,
REPARTITION_BY_NUM, ShuffleExchangeLike}
import org.apache.spark.sql.execution.joins.HashedRelationWithAllNullKeys
@@ -52,19 +53,39 @@ 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 sort: SortExec =>
+ getEstimatedRowCount(sort.child)
+
+ // A global limit can also drop rows through an offset, so only propagate
+ // proven emptiness; a zero limit is unconditionally empty.
+ case limit: BaseLimitExec =>
+ if (limit.limit == 0) {
+ Some(BigInt(0))
} else {
- None
+ getEstimatedRowCount(limit.child).filter(_ == 0)
}
- case _: EmptyRelation => Some(0)
+ // Match the global-aggregate invariant in LogicalQueryStage.computeStats
without adopting
+ // its logical-statistics fallback or its unrestricted physical-plan
traversal.
+ case aggregate: BaseAggregateExec if aggregate.groupingExpressions.isEmpty
=>
+ getEstimatedRowCount(aggregate.child).map { rowCount =>
+ if (rowCount == 0) BigInt(1) else rowCount
+ }
+
+ case aggregate: BaseAggregateExec =>
+ getEstimatedRowCount(aggregate.child)
case _ => None
}
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 bfe6a9a3f633..a12b202b5b00 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,8 @@
package org.apache.spark.sql.execution.adaptive
import java.util
-import java.util.concurrent.{ConcurrentHashMap, LinkedBlockingQueue}
+import java.util.concurrent.{CompletableFuture, ConcurrentHashMap,
LinkedBlockingQueue}
+import java.util.concurrent.atomic.AtomicReference
import scala.collection.concurrent.TrieMap
import scala.collection.mutable
@@ -77,6 +78,17 @@ case class AdaptiveSparkPlanExec(
@transient private val lock = new Object()
+ // Access is serialized by the execution context's stage lifecycle lock.
+ @transient private val uncancelledObsoleteStageIds =
mutable.HashSet.empty[Int]
+
+ // Remember every local alias so cancellation and failure handling cover
every stage ID.
+ @transient private val stageIdsByResult =
+ mutable.HashMap.empty[AtomicReference[Option[Any]],
mutable.LinkedHashSet[Int]]
+
+ // A failed native cancellation can leave its exchange marked cancelled
while its job still runs.
+ @transient private val failedStageCancellationResults =
+ mutable.HashSet.empty[AtomicReference[Option[Any]]]
+
@transient private val logOnLevel: ( => MessageWithContext) => Unit =
logBasedOnLevel(conf.adaptiveExecutionLogLevel)
@@ -301,6 +313,7 @@ case class AdaptiveSparkPlanExec(
var result = createQueryStages(fun, currentPhysicalPlan, firstRun =
true)
val events = new LinkedBlockingQueue[StageMaterializationEvent]()
val errors = new mutable.ArrayBuffer[Throwable]()
+ val obsoleteCancelledStageIds = new mutable.HashSet[Int]
var stagesToReplace = Seq.empty[QueryStageExec]
while (!result.allChildStagesMaterialized) {
currentPhysicalPlan = result.newPlan
@@ -356,7 +369,9 @@ case class AdaptiveSparkPlanExec(
stage.resultOption.set(Some(res))
case StageFailure(stage, ex) =>
stage.error.set(Some(ex))
- errors.append(ex)
+ if (!shouldIgnoreObsoleteStageFailure(stage, ex,
obsoleteCancelledStageIds)) {
+ errors.append(ex)
+ }
}
// In case of errors, we cancel all running stages and throw
exception.
@@ -397,6 +412,9 @@ case class AdaptiveSparkPlanExec(
currentPhysicalPlan.treeString,
newPhysicalPlan.treeString).mkString("\n")
logOnLevel(log"Plan changed:\n${MDC(QUERY_PLAN, plans)}")
cleanUpTempTags(newPhysicalPlan)
+ obsoleteCancelledStageIds ++=
+ cancelObsoleteStages(
+ newPhysicalPlan,
obsoleteStageCandidates(currentPhysicalPlan, stagesToReplace))
currentPhysicalPlan = newPhysicalPlan
currentLogicalPlan = newLogicalPlan
stagesToReplace = Seq.empty[QueryStageExec]
@@ -420,6 +438,204 @@ case class AdaptiveSparkPlanExec(
.get.asInstanceOf[T]
}
+ /** Include stages retained by earlier adopted plans as well as newly
created query stages. */
+ private def obsoleteStageCandidates(
+ oldPhysicalPlan: SparkPlan,
+ stagesToReplace: Seq[QueryStageExec]): Seq[QueryStageExec] = {
+ val visitedStageIds = mutable.HashSet.empty[Int]
+ (oldPhysicalPlan.collect { case stage: QueryStageExec => stage } ++
stagesToReplace)
+ .filter(stage => visitedStageIds.add(stage.id))
+ }
+
+ /**
+ * Cancel unfinished exchange stages that are no longer referenced by the
adopted plan.
+ *
+ * An unfinished stage cannot be hidden inside another stage's plan because
parent stages are
+ * created only after all child stages have materialized. Results reused by
another adaptive
+ * plan, such as a subquery, are separately protected by the context's
shared-result tracking.
+ * A failed or unsafe cancellation leaves the stage cached until its
eventual materialization
+ * event proves whether the stage is still required.
+ */
+ private def cancelObsoleteStages(
+ newPhysicalPlan: SparkPlan,
+ candidateStages: Seq[QueryStageExec]): Seq[Int] = {
+ val retainedStageIds = mutable.HashSet.empty[Int]
+ val retainedStageResults =
mutable.HashSet.empty[AtomicReference[Option[Any]]]
+ newPhysicalPlan.foreach {
+ case stage: QueryStageExec =>
+ retainedStageIds += stage.id
+ retainedStageResults += stage.resultOption
+ case _ =>
+ }
+ val obsoleteStages = mutable.LinkedHashMap.empty[
+ AtomicReference[Option[Any]],
mutable.ArrayBuffer[ExchangeQueryStageExec]]
+ candidateStages.foreach {
+ case stage: ExchangeQueryStageExec
+ if !retainedStageIds.contains(stage.id) &&
+ !retainedStageResults.contains(stage.resultOption) =>
+ obsoleteStages.getOrElseUpdate(stage.resultOption,
mutable.ArrayBuffer.empty) += stage
+ case _ =>
+ }
+ obsoleteStages.values.toSeq.flatMap { stages =>
+ val stage = stages.head
+ val reservation = context.withStageLifecycleLock {
+ if (!stage.isMaterialized &&
!context.isSharedStageResult(stage.resultOption)) {
+ val recordedStageIds =
stageIdsByResult.get(stage.resultOption).toSeq.flatten
+ val obsoleteStageIds = (recordedStageIds ++
stages.map(_.id)).distinct
+ if (failedStageCancellationResults.contains(stage.resultOption)) {
+ uncancelledObsoleteStageIds ++= obsoleteStageIds
+ None
+ } else {
+ context.reserveStageCancellation(stage.resultOption).map {
cancellation =>
+ (cancellation, obsoleteStageIds)
+ }
+ }
+ } else {
+ None
+ }
+ }
+ reservation.toSeq.flatMap { case (cancellation, obsoleteStageIds) =>
+ try {
+ withShuffleCancellationLock(stage) {
+ val shouldCancel = context.withStageLifecycleLock {
+ if (stage.isMaterialized ||
context.isSharedStageResult(stage.resultOption)) {
+ false
+ } else if (canCancelStage(stage)) {
+ true
+ } else {
+ if (canTrackObsoleteStageFailure(stage)) {
+ uncancelledObsoleteStageIds ++= obsoleteStageIds
+ }
+ false
+ }
+ }
+ if (shouldCancel) {
+ try {
+ recordSubmittedShuffleIds(stage)
+ stage.cancel(
+ "The query stage is no longer referenced by the current
adaptive plan.")
+ context.withStageLifecycleLock {
+ removeStageFromCache(stage)
+ }
+ obsoleteStageIds
+ } catch {
+ case NonFatal(t) =>
+ context.withStageLifecycleLock {
+ failedStageCancellationResults += stage.resultOption
+ uncancelledObsoleteStageIds ++= obsoleteStageIds
+ }
+ logError(s"Exception in cancelling obsolete query stage:
${stage.treeString}", t)
+ Seq.empty
+ }
+ } else {
+ Seq.empty
+ }
+ }
+ } finally {
+ context.withStageLifecycleLock {
+ context.finishStageCancellation(stage.resultOption, cancellation)
+ }
+ }
+ }
+ }
+ }
+
+ /** Serialize shuffle eligibility and cancellation with the exchange's job
submission. */
+ private def withShuffleCancellationLock[T](
+ stage: ExchangeQueryStageExec)(body: => T): T = stage match {
+ case shuffleStage: ShuffleQueryStageExec =>
shuffleStage.shuffle.synchronized(body)
+ case _ => body
+ }
+
+ /**
+ * Broadcast cancellation cannot prevent its supplier from submitting
another job afterward.
+ * Delegating shuffles may conceal an already submitted job behind an empty
futureAction, and
+ * submitted shuffle cancellation is asynchronous, so removing its files can
race map writers.
+ */
+ private def canCancelStage(stage: ExchangeQueryStageExec): Boolean = stage
match {
+ case _: BroadcastQueryStageExec => false
+ case shuffleStage: ShuffleQueryStageExec =>
+ val shuffle = shuffleStage.shuffle
+ val submitted = shuffle.futureAction.get().isDefined
+ (shuffle.isInstanceOf[ShuffleExchangeExec] || submitted) &&
+ (!submitted || context.qe.shuffleCleanupMode != RemoveShuffleFiles)
+ case _ => true
+ }
+
+ /** Opaque delegated shuffles can conceal submitted jobs and their required
cleanup IDs. */
+ private def canTrackObsoleteStageFailure(stage: ExchangeQueryStageExec):
Boolean = stage match {
+ case shuffleStage: ShuffleQueryStageExec =>
+ shuffleStage.shuffle.isInstanceOf[ShuffleExchangeExec] ||
+ shuffleStage.shuffle.futureAction.get().isDefined
+ case _ => true
+ }
+
+ /** Never hide fatal stage failures, even when their stage was cancelled as
obsolete. */
+ private def shouldIgnoreObsoleteStageFailure(
+ stage: QueryStageExec,
+ error: Throwable,
+ obsoleteCancelledStageIds: scala.collection.Set[Int]): Boolean = {
+ !error.isInstanceOf[SparkFatalException] && NonFatal(error) &&
+ (obsoleteCancelledStageIds.contains(stage.id) ||
ignoreFailedObsoleteStageFailure(stage))
+ }
+
+ /**
+ * Suppress an uncancelled obsolete stage's later failure only while its
result remains private
+ * and absent from the current physical plan. This covers failed
cancellation and stages whose
+ * cancellation would be unsafe. Reused stages must still fail their
consumers; otherwise, the
+ * failed cache entry can now be safely discarded.
+ */
+ private def ignoreFailedObsoleteStageFailure(stage: QueryStageExec): Boolean
= stage match {
+ case exchangeStage: ExchangeQueryStageExec =>
+ context.withStageLifecycleLock {
+ if (!uncancelledObsoleteStageIds.remove(stage.id)) {
+ false
+ } else {
+ val stillReferenced = currentPhysicalPlan.exists {
+ case retainedStage: QueryStageExec =>
+ retainedStage.id == stage.id ||
retainedStage.resultOption.eq(stage.resultOption)
+ case _ => false
+ }
+ if (!stillReferenced &&
!context.isSharedStageResult(stage.resultOption)) {
+ recordSubmittedShuffleIds(exchangeStage)
+ removeStageFromCache(exchangeStage)
+ true
+ } else {
+ false
+ }
+ }
+ }
+ case _ => false
+ }
+
+ /** Record only submitted shuffles, avoiding initialization of an
unsubmitted lazy dependency. */
+ private def recordSubmittedShuffleIds(stage: ExchangeQueryStageExec): Unit =
{
+ def record(shuffle: ShuffleExchangeLike): Unit = {
+ if (shuffle.futureAction.get().isDefined) {
+ context.shuffleIds.put(shuffle.shuffleId, true)
+ }
+ }
+ stage match {
+ // A reuse instance wraps its exchange in ReusedExchangeExec, which is a
leaf node.
+ case shuffleStage: ShuffleQueryStageExec => record(shuffleStage.shuffle)
+ case _ =>
+ stage.plan.foreach {
+ case shuffle: ShuffleExchangeLike => record(shuffle)
+ case _ =>
+ }
+ }
+ }
+
+ /** Remove the exact canonical exchange-cache entry only when it still owns
this result. */
+ private def removeStageFromCache(stage: ExchangeQueryStageExec): Unit = {
+ val cacheKey = stage.plan.canonicalized
+ context.stageCache.get(cacheKey).foreach { cachedStage =>
+ if (cachedStage.resultOption.eq(stage.resultOption)) {
+ context.stageCache.remove(cacheKey)
+ }
+ }
+ }
+
// Use a lazy val to avoid this being called more than once.
@transient private lazy val finalPlanUpdate: Unit = {
// Do final plan update after result stage has materialized.
@@ -623,16 +839,25 @@ case class AdaptiveSparkPlanExec(
private def createNonResultQueryStages(plan: SparkPlan): CreateStageResult =
plan match {
case e: Exchange =>
// First have a quick check in the `stageCache` without having to
traverse down the node.
- context.stageCache.get(e.canonicalized) match {
- case Some(existingStage) if conf.exchangeReuseEnabled =>
- val stage = reuseQueryStage(existingStage, e)
+ val reusedStage = if (conf.exchangeReuseEnabled) {
+ withStageCacheEntry(e.canonicalized) {
+ _.map { existingStage =>
+ reuseQueryStage(existingStage, e)
+ }
+ }
+ } else {
+ None
+ }
+
+ reusedStage match {
+ case Some(stage) =>
val isMaterialized = stage.isMaterialized
CreateStageResult(
newPlan = stage,
allChildStagesMaterialized = isMaterialized,
newStages = if (isMaterialized) Seq.empty else Seq(stage))
- case _ =>
+ case None =>
val result = createNonResultQueryStages(e.child)
val newPlan =
e.withNewChildren(Seq(result.newPlan)).asInstanceOf[Exchange]
// Create a query stage only when all the child query stages are
ready.
@@ -642,10 +867,14 @@ case class AdaptiveSparkPlanExec(
// Check the `stageCache` again for reuse. If a match is found,
ditch the new stage
// and reuse the existing stage found in the `stageCache`,
otherwise update the
// `stageCache` with the new stage.
- val queryStage = context.stageCache.getOrElseUpdate(
- newStage.plan.canonicalized, newStage)
- if (queryStage.ne(newStage)) {
- newStage = reuseQueryStage(queryStage, e)
+ val cacheKey = newStage.plan.canonicalized
+ withStageCacheEntry(cacheKey) {
+ case Some(queryStage) =>
+ newStage = reuseQueryStage(queryStage, e)
+ case None =>
+ context.registerStageOwner(newStage.resultOption, this)
+ recordStageId(newStage)
+ context.stageCache.put(cacheKey, newStage)
}
}
val isMaterialized = newStage.isMaterialized
@@ -685,6 +914,26 @@ case class AdaptiveSparkPlanExec(
}
}
+ /** Wait for cancellation of this result without blocking unrelated
exchange-cache access. */
+ @scala.annotation.tailrec
+ private def withStageCacheEntry[T](
+ cacheKey: SparkPlan)(
+ useStage: Option[ExchangeQueryStageExec] => T): T = {
+ val result: Either[CompletableFuture[Unit], T] =
context.withStageLifecycleLock {
+ val cachedStage = context.stageCache.get(cacheKey)
+ cachedStage.flatMap(stage =>
context.pendingStageCancellation(stage.resultOption)) match {
+ case Some(cancellation) => Left(cancellation)
+ case None => Right(useStage(cachedStage))
+ }
+ }
+ result match {
+ case Left(cancellation) =>
+ cancellation.join()
+ withStageCacheEntry(cacheKey)(useStage)
+ case Right(value) => value
+ }
+ }
+
private def newResultQueryStage(
resultHandler: SparkPlan => Any,
plan: SparkPlan): ResultQueryStageExec = {
@@ -740,12 +989,19 @@ case class AdaptiveSparkPlanExec(
private def reuseQueryStage(
existing: ExchangeQueryStageExec,
exchange: Exchange): ExchangeQueryStageExec = {
+ context.markSharedStageResult(existing.resultOption, this)
val queryStage = existing.newReuseInstance(currentStageId, exchange.output)
currentStageId += 1
setLogicalLinkForNewQueryStage(queryStage, exchange)
+ recordStageId(queryStage)
queryStage
}
+ /** Track local reuse aliases whose materialization may report the same
eventual failure. */
+ private def recordStageId(stage: ExchangeQueryStageExec): Unit = {
+ stageIdsByResult.getOrElseUpdate(stage.resultOption,
mutable.LinkedHashSet.empty) += stage.id
+ }
+
/**
* Set the logical node link of the `stage` as the corresponding logical
node of the `plan` it
* encloses. If an `plan` has been transformed from a `Repartition`, it
should have `logicalLink`
@@ -973,7 +1229,6 @@ object AdaptiveSparkPlanExec {
* The execution context shared between the main query and all sub-queries.
*/
case class AdaptiveExecutionContext(session: SparkSession, qe: QueryExecution)
{
-
/**
* The subquery-reuse map shared across the entire query.
*/
@@ -986,6 +1241,84 @@ case class AdaptiveExecutionContext(session:
SparkSession, qe: QueryExecution) {
val stageCache: TrieMap[SparkPlan, ExchangeQueryStageExec] =
new TrieMap[SparkPlan, ExchangeQueryStageExec]()
+ private val stageLifecycleLock = new Object
+
+ /**
+ * Serialize exchange-cache lookup, reuse, and cancellation reservations
across subqueries.
+ * Blocking cancellation and waits for a particular result happen outside
this query-wide lock.
+ */
+ private[adaptive] def withStageLifecycleLock[T](body: => T): T = {
+ stageLifecycleLock.synchronized(body)
+ }
+
+ /** Stage-scoped reservations keep a cancelling result cached without
blocking other stages. */
+ private val stageCancellationReservations =
+ new ConcurrentHashMap[AtomicReference[Option[Any]],
CompletableFuture[Unit]]()
+
+ /** Claim a result before waiting for its shuffle monitor or cancelling its
exchange. */
+ private[adaptive] def reserveStageCancellation(
+ resultOption: AtomicReference[Option[Any]]):
Option[CompletableFuture[Unit]] = {
+ val cancellation = new CompletableFuture[Unit]()
+ if (stageCancellationReservations.putIfAbsent(resultOption, cancellation)
== null) {
+ Some(cancellation)
+ } else {
+ None
+ }
+ }
+
+ /** Return the cancellation that a potential consumer of this particular
result must await. */
+ private[adaptive] def pendingStageCancellation(
+ resultOption: AtomicReference[Option[Any]]):
Option[CompletableFuture[Unit]] = {
+ Option(stageCancellationReservations.get(resultOption))
+ }
+
+ /** Publish the final cache state before allowing consumers of this result
to retry lookup. */
+ private[adaptive] def finishStageCancellation(
+ resultOption: AtomicReference[Option[Any]],
+ cancellation: CompletableFuture[Unit]): Unit = {
+ stageCancellationReservations.remove(resultOption, cancellation)
+ cancellation.complete(())
+ }
+
+ /**
+ * The adaptive plan that first cached each result. Identity, rather than
case-class equality,
+ * distinguishes independently planned subqueries with equivalent physical
input plans.
+ */
+ private val stageResultOwners =
+ new ConcurrentHashMap[AtomicReference[Option[Any]],
AdaptiveSparkPlanExec]()
+
+ /** Record the original owner atomically with exchange-cache insertion. */
+ private[adaptive] def registerStageOwner(
+ resultOption: AtomicReference[Option[Any]], owner:
AdaptiveSparkPlanExec): Unit = {
+ stageResultOwners.putIfAbsent(resultOption, owner)
+ }
+
+ /**
+ * Results reused by another adaptive plan in this execution context.
Cross-plan protection
+ * remains for the context's lifetime; aliases within one plan are not
considered shared.
+ */
+ private val sharedStageResults =
+ new ConcurrentHashMap[AtomicReference[Option[Any]], Boolean]()
+
+ /** Conservatively protect a result whose owner is unknown or whose
protection is test-forced. */
+ private[adaptive] def markSharedStageResult(resultOption:
AtomicReference[Option[Any]]): Unit = {
+ sharedStageResults.put(resultOption, true)
+ }
+
+ /** Conservatively protect a result with unknown ownership or reuse by
another adaptive plan. */
+ private[adaptive] def markSharedStageResult(
+ resultOption: AtomicReference[Option[Any]], owner:
AdaptiveSparkPlanExec): Unit = {
+ val originalOwner = stageResultOwners.get(resultOption)
+ if (originalOwner == null || (originalOwner ne owner)) {
+ markSharedStageResult(resultOption)
+ }
+ }
+
+ /** Return whether this result is conservatively protected from
obsolete-stage cancellation. */
+ private[adaptive] def isSharedStageResult(resultOption:
AtomicReference[Option[Any]]): Boolean = {
+ sharedStageResults.containsKey(resultOption)
+ }
+
val shuffleIds: ConcurrentHashMap[Int, Boolean] = new ConcurrentHashMap[Int,
Boolean]()
}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
index 9523ce897ecf..8e98d7785f6c 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/adaptive/AdaptiveQueryExecSuite.scala
@@ -19,7 +19,10 @@ package org.apache.spark.sql.execution.adaptive
import java.io.File
import java.net.URI
+import java.util.concurrent.{CountDownLatch, TimeUnit}
+import java.util.concurrent.atomic.{AtomicInteger, AtomicReference}
+import scala.concurrent.Future
import scala.concurrent.duration._
import org.apache.logging.log4j.Level
@@ -33,8 +36,8 @@ import org.apache.spark.sql.{DataFrame, Dataset, Row,
SparkSession}
import org.apache.spark.sql.catalyst.InternalRow
import org.apache.spark.sql.catalyst.expressions.{Ascending, Attribute,
AttributeReference, EqualTo, IsNull, Literal, Or, SortOrder}
import org.apache.spark.sql.catalyst.optimizer.{BuildLeft, BuildRight,
EliminateLimits}
-import org.apache.spark.sql.catalyst.plans.{Inner, LeftAnti}
-import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, GlobalLimit,
Join, JoinHint, LeafNode, LocalRelation, LogicalPlan, Statistics}
+import org.apache.spark.sql.catalyst.plans.{Inner, LeftAnti, LeftSemi}
+import org.apache.spark.sql.catalyst.plans.logical.{Aggregate, EmptyRelation,
GlobalLimit, Join, JoinHint, LeafNode, LocalRelation, LogicalPlan, Statistics}
import
org.apache.spark.sql.catalyst.plans.physical.{CoalescedNullAwareHashPartitioning,
SinglePartition}
import org.apache.spark.sql.classic.Strategy
import org.apache.spark.sql.execution._
@@ -60,8 +63,8 @@ import org.apache.spark.sql.test.SQLTestData.TestData
import org.apache.spark.sql.types.{IntegerType, StructType}
import org.apache.spark.sql.util.QueryExecutionListener
import org.apache.spark.tags.SlowSQLTest
+import org.apache.spark.util.{SparkFatalException, ThreadUtils, Utils}
import org.apache.spark.util.ArrayImplicits._
-import org.apache.spark.util.Utils
/**
* A leaf whose cost estimate under-counts its structural row bound. Used by
SPARK-57956 to verify
@@ -373,6 +376,1061 @@ class AdaptiveQueryExecSuite
}
}
+ test("non-empty global aggregate stage eliminates conditionless semi joins")
{
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "2") {
+ val globalAggregate = spark.range(1).where("id < 0").repartition(2)
+ .agg(count("*").as("c"))
+ val df = testData.join(globalAggregate, Seq.empty[String], "left_semi")
+
+ val logicalJoin = df.queryExecution.optimizedPlan.collectFirst {
+ case join: Join => join
+ }.getOrElse(fail("expected a conditionless semi join before adaptive
execution"))
+ assert(logicalJoin.joinType == LeftSemi)
+ assert(logicalJoin.condition.isEmpty)
+
+ val initialPlan = df.queryExecution.executedPlan
+ .asInstanceOf[AdaptiveSparkPlanExec].initialPlan
+ assert(findTopLevelBaseJoin(initialPlan).size == 1)
+
+ val aggregate = collect(initialPlan) {
+ case aggregate: BaseAggregateExec if
aggregate.groupingExpressions.isEmpty => aggregate
+ }.headOption.getOrElse(fail("expected a global aggregate in the initial
adaptive plan"))
+ val emptyStage = TestExchangeQueryStageExec(
+ 0,
+ aggregate.child,
+ aggregate.child.canonicalized,
+ runtimeRowCount = Some(BigInt(0)))
+ emptyStage.resultOption.set(Some(()))
+ val aggregateStage = LogicalQueryStage(
+ logicalJoin.right, aggregate.withNewChildren(Seq(emptyStage)))
+ val rewrittenJoin = AQEPropagateEmptyRelation(logicalJoin.copy(right =
aggregateStage))
+ assert(rewrittenJoin.fastEquals(logicalJoin.left), rewrittenJoin)
+
+ checkAnswer(df, testData.collect().toSeq)
+
+ val finalPlan = stripAQEPlan(df.queryExecution.executedPlan)
+ assert(findTopLevelBaseJoin(finalPlan).isEmpty, finalPlan)
+ }
+ }
+
+ test("global aggregate over empty filtered input preserves its single output
row") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "2") {
+ withTempView("empty_rows") {
+ spark.range(1).where("id < 0").createOrReplaceTempView("empty_rows")
+
+ val globalAggregate = sql(
+ "SELECT count(*) AS c FROM " +
+ "(SELECT /*+ REPARTITION(2) */ id FROM empty_rows ORDER BY id)
sorted_empty_rows")
+ checkAnswer(globalAggregate, Row(0L))
+ val finalAggregatePlan =
stripAQEPlan(globalAggregate.queryExecution.executedPlan)
+ assert(!finalAggregatePlan.isInstanceOf[EmptyRelationExec])
+
+ val df = sql(
+ "SELECT l.* FROM testData l LEFT ANTI JOIN " +
+ "(SELECT count(*) AS c FROM empty_rows HAVING count(*) = 0) r " +
+ "ON l.key = r.c + 1")
+
+ checkAnswer(df, testData.filter($"key" =!= 1).collect().toSeq)
+ }
+ }
+ }
+
+ test("sort and limit wrappers propagate only provably empty query stages") {
+ val getEstimatedRowCount =
+ PrivateMethod[Option[BigInt]](Symbol("getEstimatedRowCount"))
+ val scan = LocalTableScanExec(Nil, Nil, None)
+
+ val unknownStage = TestExchangeQueryStageExec(0, scan, scan)
+ val emptyStage = TestExchangeQueryStageExec(
+ 1, scan, scan, runtimeRowCount = Some(BigInt(0)))
+ emptyStage.resultOption.set(Some(()))
+ val nonEmptyStage = TestExchangeQueryStageExec(
+ 2, scan, scan, runtimeRowCount = Some(BigInt(10)))
+ nonEmptyStage.resultOption.set(Some(()))
+
+ def estimatedRowCount(plan: SparkPlan): Option[BigInt] =
+ AQEPropagateEmptyRelation.invokePrivate(getEstimatedRowCount(plan))
+
+ assert(estimatedRowCount(LocalLimitExec(0,
unknownStage)).contains(BigInt(0)))
+ assert(estimatedRowCount(GlobalLimitExec(0,
unknownStage)).contains(BigInt(0)))
+
+ assert(estimatedRowCount(LocalLimitExec(5,
emptyStage)).contains(BigInt(0)))
+ assert(estimatedRowCount(GlobalLimitExec(5,
emptyStage)).contains(BigInt(0)))
+ assert(estimatedRowCount(GlobalLimitExec(5, emptyStage, offset =
2)).contains(BigInt(0)))
+ assert(estimatedRowCount(GlobalLimitExec(-1, emptyStage, offset =
2)).contains(BigInt(0)))
+
+ assert(estimatedRowCount(LocalLimitExec(5, unknownStage)).isEmpty)
+ assert(estimatedRowCount(GlobalLimitExec(5, unknownStage)).isEmpty)
+ assert(estimatedRowCount(LocalLimitExec(1, nonEmptyStage)).isEmpty)
+ assert(estimatedRowCount(GlobalLimitExec(1, nonEmptyStage, offset =
99)).isEmpty)
+
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.TOP_K_SORT_FALLBACK_THRESHOLD.key -> "1",
+ SQLConf.ORDERING_AWARE_LIMIT_OFFSET.key -> "true",
+ SQLConf.COALESCE_PARTITIONS_ENABLED.key -> "false") {
+ val sorted = spark.range(0, 10, 1, numPartitions = 2)
+ .where("id < 0")
+ .orderBy($"id" % 8)
+ .limit(2)
+ .distinct()
+ val initialPlan = sorted.queryExecution.executedPlan
+ .asInstanceOf[AdaptiveSparkPlanExec].initialPlan
+ val (plannedSort, plannedShuffle) = initialPlan.collectFirst {
+ case GlobalLimitExec(_, sort @ SortExec(_, false, shuffle:
ShuffleExchangeExec, _), _)
+ if sort.logicalLink.exists(logical =>
+ shuffle.logicalLink.exists(_ eq logical)) => (sort, shuffle)
+ }.getOrElse(fail("expected a logically linked sort above the
middle-limit shuffle"))
+
+ val emptySortStage = TestExchangeQueryStageExec(
+ 3,
+ plannedShuffle,
+ plannedShuffle.canonicalized,
+ runtimeRowCount = Some(BigInt(0)))
+ emptySortStage.resultOption.set(Some(()))
+ val sortedEmptyStage = plannedSort.withNewChildren(Seq(emptySortStage))
+
+ Seq(sortedEmptyStage, LocalLimitExec(5, sortedEmptyStage)).foreach {
physicalPlan =>
+ val logicalStage = LogicalQueryStage(plannedSort.logicalLink.get,
physicalPlan)
+
assert(AQEPropagateEmptyRelation(logicalStage).isInstanceOf[EmptyRelation])
+ }
+
+ checkAnswer(sorted.toDF(), Seq.empty)
+ }
+ }
+
+ test("obsolete stage cancellation preserves shared reused exchange stages") {
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true") {
+ val df = spark.sql("SELECT * FROM testData join testData2 ON key = a")
+ val adaptivePlan =
df.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val replacementPlan = LocalTableScanExec(Nil, Nil, None)
+
+ val sharedStage = TestExchangeQueryStageExec(
+ 0, LocalTableScanExec(Nil, Nil, None), LocalTableScanExec(Nil, Nil,
None))
+ adaptivePlan.context.markSharedStageResult(sharedStage.resultOption)
+
+ val sharedCancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(sharedStage)))
+ assert(sharedCancelledIds.isEmpty)
+ assert(!sharedStage.cancelled)
+
+ val materializedStage = TestExchangeQueryStageExec(
+ 1, LocalTableScanExec(Nil, Nil, None), LocalTableScanExec(Nil, Nil,
None))
+ materializedStage.resultOption.set(Some(()))
+ val materializedCancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(materializedStage)))
+ assert(materializedCancelledIds.isEmpty)
+ assert(!materializedStage.cancelled)
+
+ val retainedStage = TestExchangeQueryStageExec(
+ 2, LocalTableScanExec(Nil, Nil, None), LocalTableScanExec(Nil, Nil,
None))
+ val retainedCancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(retainedStage, Seq(retainedStage)))
+ assert(retainedCancelledIds.isEmpty)
+ assert(!retainedStage.cancelled)
+
+ val reusedStage = TestExchangeQueryStageExec(
+ 3, LocalTableScanExec(Nil, Nil, None), LocalTableScanExec(Nil, Nil,
None))
+ val reusedReplacementStage = reusedStage.newReuseInstance(4,
reusedStage.output)
+ val reusedCancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(reusedReplacementStage, Seq(reusedStage)))
+ assert(reusedCancelledIds.isEmpty)
+ assert(!reusedStage.cancelled)
+
+ val unsharedStage = TestExchangeQueryStageExec(
+ 5, LocalTableScanExec(Nil, Nil, None), LocalTableScanExec(Nil, Nil,
None))
+ adaptivePlan.context.stageCache.put(unsharedStage.plan.canonicalized,
unsharedStage)
+ val unsharedCancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(unsharedStage)))
+ assert(unsharedCancelledIds == Seq(unsharedStage.id))
+ assert(unsharedStage.cancelled)
+
assert(!adaptivePlan.context.stageCache.contains(unsharedStage.plan.canonicalized))
+
+ val failedStage = TestExchangeQueryStageExec(
+ 6,
+ LocalTableScanExec(Nil, Nil, None),
+ LocalTableScanExec(Nil, Nil, None),
+ cancelFailure = Some(new IllegalStateException("test stage
cancellation failed")))
+ adaptivePlan.context.stageCache.put(failedStage.plan.canonicalized,
failedStage)
+ val failedCancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(failedStage)))
+ assert(failedCancelledIds.isEmpty)
+ assert(!failedStage.cancelled)
+
assert(adaptivePlan.context.stageCache.get(failedStage.plan.canonicalized)
+ .exists(_ eq failedStage))
+ }
+ }
+
+ test("obsolete same-plan reused exchange aliases are cancelled only when all
are removed") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "true") {
+ val adaptivePlan = spark.sql("SELECT * FROM testData JOIN testData2 ON
key = a")
+ .queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val exchange = adaptivePlan.initialPlan.collectFirst {
+ case shuffle: ShuffleExchangeExec => shuffle
+ }.getOrElse(fail("expected a shuffle exchange in the initial adaptive
plan"))
+
+ val createNonResultQueryStages =
+ PrivateMethod[Any](Symbol("createNonResultQueryStages"))
+ def acquireStage(): ExchangeQueryStageExec = {
+ adaptivePlan.invokePrivate(createNonResultQueryStages(exchange))
+
.asInstanceOf[Product].productElement(0).asInstanceOf[ExchangeQueryStageExec]
+ }
+
+ val originalStage = acquireStage()
+ val reusedStage = acquireStage()
+ assert(originalStage.id != reusedStage.id)
+ assert(originalStage.resultOption.eq(reusedStage.resultOption))
+
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val candidateStages = Seq(originalStage, reusedStage)
+
+ // Retaining either alias keeps the shared physical exchange alive.
+ assert(adaptivePlan.invokePrivate(
+ cancelObsoleteStages(reusedStage, candidateStages)).isEmpty)
+
assert(adaptivePlan.context.stageCache.get(originalStage.plan.canonicalized)
+ .exists(_.resultOption.eq(originalStage.resultOption)))
+
+ val emptyReplacement = LocalTableScanExec(Nil, Nil, None)
+ val cancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(emptyReplacement, candidateStages))
+ assert(cancelledIds.toSet == Set(originalStage.id, reusedStage.id))
+ assert(cancelledIds.distinct.size == candidateStages.size)
+
assert(!adaptivePlan.context.stageCache.contains(originalStage.plan.canonicalized))
+
+ val shouldIgnoreObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("shouldIgnoreObsoleteStageFailure"))
+ val cancellationFailure = new IllegalStateException("obsolete reused
exchange was cancelled")
+ candidateStages.foreach { stage =>
+ assert(adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ stage, cancellationFailure, cancelledIds.toSet)))
+ assert(!adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ stage, new SparkFatalException(new OutOfMemoryError("fatal obsolete
alias")),
+ cancelledIds.toSet)))
+ }
+
+ val unrelatedStage = TestExchangeQueryStageExec(
+ 412, exchange, exchange.canonicalized)
+ assert(!adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ unrelatedStage, cancellationFailure, cancelledIds.toSet)))
+ }
+ }
+
+ test("obsolete exchange cancellation preserves reuse by another adaptive
subquery plan") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "true") {
+ val adaptivePlan = spark.sql("SELECT * FROM testData JOIN testData2 ON
key = a")
+ .queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val exchange = adaptivePlan.initialPlan.collectFirst {
+ case shuffle: ShuffleExchangeExec => shuffle
+ }.getOrElse(fail("expected a shuffle exchange in the initial adaptive
plan"))
+
+ val createNonResultQueryStages =
+ PrivateMethod[Any](Symbol("createNonResultQueryStages"))
+ def acquireStage(plan: AdaptiveSparkPlanExec): ExchangeQueryStageExec = {
+ plan.invokePrivate(createNonResultQueryStages(exchange))
+
.asInstanceOf[Product].productElement(0).asInstanceOf[ExchangeQueryStageExec]
+ }
+
+ val originalStage = acquireStage(adaptivePlan)
+ val samePlanAlias = acquireStage(adaptivePlan)
+ val subqueryPlan = adaptivePlan.copy(isSubquery = true)
+ assert(subqueryPlan ne adaptivePlan)
+ assert(subqueryPlan == adaptivePlan)
+ assert(subqueryPlan.context eq adaptivePlan.context)
+
+ val subqueryAlias = acquireStage(subqueryPlan)
+ assert(originalStage.resultOption.eq(samePlanAlias.resultOption))
+ assert(originalStage.resultOption.eq(subqueryAlias.resultOption))
+
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val emptyReplacement = LocalTableScanExec(Nil, Nil, None)
+ assert(adaptivePlan.invokePrivate(cancelObsoleteStages(
+ emptyReplacement, Seq(originalStage, samePlanAlias))).isEmpty)
+
assert(adaptivePlan.context.stageCache.get(originalStage.plan.canonicalized)
+ .exists(_.resultOption.eq(subqueryAlias.resultOption)))
+
+ val shouldIgnoreObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("shouldIgnoreObsoleteStageFailure"))
+ val requiredStageFailure = new IllegalStateException("required subquery
exchange failed")
+ assert(!adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ originalStage, requiredStageFailure, Set.empty[Int])))
+ assert(!adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ samePlanAlias, requiredStageFailure, Set.empty[Int])))
+ }
+ }
+
+ test("failed obsolete exchange cancellation is not retried after same-plan
reuse") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "true") {
+ val adaptivePlan = spark.sql("SELECT * FROM testData JOIN testData2 ON
key = a")
+ .queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val exchange = adaptivePlan.initialPlan.collectFirst {
+ case shuffle: ShuffleExchangeExec => shuffle
+ }.getOrElse(fail("expected a shuffle exchange in the initial adaptive
plan"))
+
+ val cancellationAttempts = new AtomicInteger()
+ val originalStage = TestExchangeQueryStageExec(
+ 413,
+ exchange,
+ exchange.canonicalized,
+ cancelCallback = Some(() => {
+ if (cancellationAttempts.incrementAndGet() == 1) {
+ throw new IllegalStateException("first stage cancellation failed")
+ }
+ }))
+ adaptivePlan.context.registerStageOwner(originalStage.resultOption,
adaptivePlan)
+ adaptivePlan.context.stageCache.put(exchange.canonicalized,
originalStage)
+
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val emptyReplacement = LocalTableScanExec(Nil, Nil, None)
+ assert(adaptivePlan.invokePrivate(
+ cancelObsoleteStages(emptyReplacement, Seq(originalStage))).isEmpty)
+ assert(cancellationAttempts.get() == 1)
+ assert(!originalStage.cancelled)
+
assert(adaptivePlan.context.stageCache.get(exchange.canonicalized).contains(originalStage))
+
+ val createNonResultQueryStages =
+ PrivateMethod[Any](Symbol("createNonResultQueryStages"))
+ val reusedStage =
adaptivePlan.invokePrivate(createNonResultQueryStages(exchange))
+
.asInstanceOf[Product].productElement(0).asInstanceOf[ExchangeQueryStageExec]
+ assert(originalStage.id != reusedStage.id)
+ assert(originalStage.resultOption.eq(reusedStage.resultOption))
+
assert(!adaptivePlan.context.isSharedStageResult(originalStage.resultOption))
+
+ assert(adaptivePlan.invokePrivate(
+ cancelObsoleteStages(emptyReplacement, Seq(reusedStage))).isEmpty)
+ assert(cancellationAttempts.get() == 1,
+ "a failed exchange cancellation must never be retried for another
local alias")
+ assert(!originalStage.cancelled)
+
assert(adaptivePlan.context.stageCache.get(exchange.canonicalized).contains(originalStage))
+
+ val shouldIgnoreObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("shouldIgnoreObsoleteStageFailure"))
+ val fatalFailure = new SparkFatalException(new OutOfMemoryError("fatal
failed cancellation"))
+ Seq(originalStage, reusedStage).foreach { stage =>
+ assert(!adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ stage, fatalFailure, Set.empty[Int])))
+ }
+
+ val obsoleteFailure = new IllegalStateException("uncancelled obsolete
exchange failed")
+ Seq(originalStage, reusedStage).foreach { stage =>
+ assert(adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ stage, obsoleteFailure, Set.empty[Int])))
+ }
+ assert(cancellationAttempts.get() == 1)
+ }
+ }
+
+ test("obsolete reused exchange cancellation includes aliases dropped by an
earlier plan") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "true") {
+ val adaptivePlan = spark.sql("SELECT * FROM testData JOIN testData2 ON
key = a")
+ .queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val exchange = adaptivePlan.initialPlan.collectFirst {
+ case shuffle: ShuffleExchangeExec => shuffle
+ }.getOrElse(fail("expected a shuffle exchange in the initial adaptive
plan"))
+
+ val createNonResultQueryStages =
+ PrivateMethod[Any](Symbol("createNonResultQueryStages"))
+ def acquireStage(): ExchangeQueryStageExec = {
+ adaptivePlan.invokePrivate(createNonResultQueryStages(exchange))
+
.asInstanceOf[Product].productElement(0).asInstanceOf[ExchangeQueryStageExec]
+ }
+
+ val originalStage = acquireStage()
+ val previouslyDroppedAlias = acquireStage()
+ val lastRemainingAlias = acquireStage()
+ val allAliases = Seq(originalStage, previouslyDroppedAlias,
lastRemainingAlias)
+ assert(allAliases.map(_.id).distinct.size == allAliases.size)
+ assert(allAliases.forall(_.resultOption.eq(originalStage.resultOption)))
+
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ assert(adaptivePlan.invokePrivate(
+ cancelObsoleteStages(lastRemainingAlias, allAliases)).isEmpty)
+
+ val emptyReplacement = LocalTableScanExec(Nil, Nil, None)
+ val cancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(emptyReplacement, Seq(lastRemainingAlias)))
+ assert(cancelledIds.toSet == allAliases.map(_.id).toSet)
+ assert(cancelledIds.distinct.size == allAliases.size)
+
assert(!adaptivePlan.context.stageCache.contains(originalStage.plan.canonicalized))
+
+ val shouldIgnoreObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("shouldIgnoreObsoleteStageFailure"))
+ val cancellationFailure = new IllegalStateException("previously dropped
exchange failed")
+ allAliases.foreach { alias =>
+ assert(adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ alias, cancellationFailure, cancelledIds.toSet)))
+ }
+ }
+ }
+
+ test("cancelling a submitted reused shuffle alias preserves its underlying
shuffle cleanup ID") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "true",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "2") {
+ val owner = org.apache.spark.sql.classic.Dataset.ofRows(
+ spark, spark.range(16).repartition(2).logicalPlan, DoNotCleanup)
+ val adaptivePlan =
owner.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ assert(adaptivePlan.context.qe.shuffleCleanupMode == DoNotCleanup)
+ val exchange = adaptivePlan.initialPlan.collectFirst {
+ case shuffle: ShuffleExchangeExec => shuffle
+ }.getOrElse(fail("expected a shuffle exchange in the initial adaptive
plan"))
+
+ val createNonResultQueryStages =
+ PrivateMethod[Any](Symbol("createNonResultQueryStages"))
+ def acquireStage(): ShuffleQueryStageExec = {
+ adaptivePlan.invokePrivate(createNonResultQueryStages(exchange))
+
.asInstanceOf[Product].productElement(0).asInstanceOf[ShuffleQueryStageExec]
+ }
+
+ val originalStage = acquireStage()
+ val reusedStage = acquireStage()
+ assert(reusedStage.shuffle eq originalStage.shuffle)
+ assert(reusedStage.plan.isInstanceOf[ReusedExchangeExec])
+ ThreadUtils.awaitResult(originalStage.materialize(), 30.seconds)
+ assert(originalStage.shuffle.futureAction.get().isDefined)
+ assert(!originalStage.isMaterialized)
+
+ val shuffleId = originalStage.shuffle.shuffleId
+ adaptivePlan.context.shuffleIds.remove(shuffleId)
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val emptyReplacement = LocalTableScanExec(Nil, Nil, None)
+ val cancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(emptyReplacement, Seq(reusedStage)))
+
+ assert(cancelledIds.toSet == Set(originalStage.id, reusedStage.id))
+ assert(adaptivePlan.context.shuffleIds.containsKey(shuffleId),
+ "a reused exchange leaf must not hide its submitted underlying shuffle
from cleanup")
+
assert(!adaptivePlan.context.stageCache.contains(originalStage.plan.canonicalized))
+ }
+ }
+
+ test("obsolete stage cancellation includes stages from previous adaptive
plan adoptions") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "true") {
+ val adaptivePlan = spark.sql("SELECT * FROM testData JOIN testData2 ON
key = a")
+ .queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val exchanges = adaptivePlan.initialPlan.collect {
+ case exchange: ShuffleExchangeExec => exchange
+ }
+ assert(exchanges.size >= 2, "expected two shuffle exchanges in the
initial adaptive plan")
+ val previousExchange = exchanges.head
+ val latestExchange = exchanges.find(exchange =>
+ !exchange.canonicalized.fastEquals(previousExchange.canonicalized))
+ .getOrElse(fail("expected independently cached shuffle exchanges"))
+
+ val previouslyRetainedStage = TestExchangeQueryStageExec(
+ 410, previousExchange, previousExchange.canonicalized)
+ val latestStage = TestExchangeQueryStageExec(
+ 411, latestExchange, latestExchange.canonicalized)
+ val oldPhysicalPlan = UnionExec(Seq(previouslyRetainedStage,
latestStage))
+ val latestStages = Seq(latestStage)
+ adaptivePlan.context.stageCache.put(previousExchange.canonicalized,
previouslyRetainedStage)
+ adaptivePlan.context.stageCache.put(latestExchange.canonicalized,
latestStage)
+
+ val obsoleteStageCandidates =
+ PrivateMethod[Seq[QueryStageExec]](Symbol("obsoleteStageCandidates"))
+ val candidates = adaptivePlan.invokePrivate(
+ obsoleteStageCandidates(oldPhysicalPlan, latestStages))
+
+ assert(!latestStages.exists(_.id == previouslyRetainedStage.id))
+ assert(candidates.map(_.id).toSet == Set(previouslyRetainedStage.id,
latestStage.id))
+ assert(candidates.count(_.id == latestStage.id) == 1)
+
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val cancelledIds =
adaptivePlan.invokePrivate(cancelObsoleteStages(latestStage, candidates))
+ assert(cancelledIds == Seq(previouslyRetainedStage.id))
+ assert(previouslyRetainedStage.cancelled)
+ assert(!latestStage.cancelled)
+
assert(!adaptivePlan.context.stageCache.contains(previousExchange.canonicalized))
+ assert(adaptivePlan.context.stageCache.get(latestExchange.canonicalized)
+ .exists(_ eq latestStage))
+
+ val shouldIgnoreObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("shouldIgnoreObsoleteStageFailure"))
+ val stageFailure = new IllegalStateException("obsolete stage failed
after cancellation")
+ assert(adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ previouslyRetainedStage, stageFailure, cancelledIds.toSet)))
+ assert(!adaptivePlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ latestStage, stageFailure, cancelledIds.toSet)))
+ }
+ }
+
+ test("obsolete broadcast stages are not cancelled or initialized") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1") {
+ val df = spark.sql(
+ "SELECT /*+ BROADCAST(r) */ * FROM testData l JOIN testData2 r ON
l.key = r.a")
+ val adaptivePlan =
df.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val exchange = adaptivePlan.initialPlan.collectFirst {
+ case broadcast: BroadcastExchangeExec => broadcast
+ }.getOrElse(fail("expected an uninitialized broadcast exchange"))
+
+ val stage = BroadcastQueryStageExec(400, exchange,
exchange.canonicalized)
+ adaptivePlan.context.stageCache.put(exchange.canonicalized, stage)
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val replacementPlan = LocalTableScanExec(Nil, Nil, None)
+
+ assert(adaptivePlan.invokePrivate(cancelObsoleteStages(replacementPlan,
Seq(stage))).isEmpty)
+ assert(stage.resultOption.get().isEmpty)
+
assert(adaptivePlan.context.stageCache.get(exchange.canonicalized).exists(_ eq
stage))
+ }
+ }
+
+ test("fatal obsolete stage failures are never ignored") {
+ withSQLConf(SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true") {
+ val df = spark.sql("SELECT * FROM testData JOIN testData2 ON key = a")
+ val adaptivePlan =
df.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val shouldIgnoreObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("shouldIgnoreObsoleteStageFailure"))
+ val scan = LocalTableScanExec(Nil, Nil, None)
+ val stage = TestExchangeQueryStageExec(401, scan, scan)
+ val cancelledStageIds = Set(stage.id)
+ val wrappedFatal = new SparkFatalException(new OutOfMemoryError("fatal
stage failure"))
+ val rawFatal = new OutOfMemoryError("fatal stage failure")
+ val cancellationFailure = new
java.util.concurrent.CancellationException("stage cancelled")
+
+ assert(!adaptivePlan.invokePrivate(
+ shouldIgnoreObsoleteStageFailure(stage, wrappedFatal,
cancelledStageIds)))
+ assert(!adaptivePlan.invokePrivate(
+ shouldIgnoreObsoleteStageFailure(stage, rawFatal, cancelledStageIds)))
+ assert(adaptivePlan.invokePrivate(
+ shouldIgnoreObsoleteStageFailure(stage, cancellationFailure,
cancelledStageIds)))
+ assert(!adaptivePlan.invokePrivate(
+ shouldIgnoreObsoleteStageFailure(stage, cancellationFailure,
Set.empty[Int])))
+
+ val failedStage = TestExchangeQueryStageExec(
+ 402,
+ scan,
+ scan,
+ cancelFailure = Some(new IllegalStateException("stage cancellation
failed")))
+ adaptivePlan.context.stageCache.put(scan.canonicalized, failedStage)
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ assert(adaptivePlan.invokePrivate(cancelObsoleteStages(scan,
Seq(failedStage))).isEmpty)
+
+ failedStage.error.set(Some(wrappedFatal))
+ assert(!adaptivePlan.invokePrivate(
+ shouldIgnoreObsoleteStageFailure(failedStage, wrappedFatal,
Set.empty[Int])))
+ assert(!adaptivePlan.invokePrivate(
+ shouldIgnoreObsoleteStageFailure(failedStage, rawFatal,
Set.empty[Int])))
+ assert(adaptivePlan.context.stageCache.get(scan.canonicalized).exists(_
eq failedStage))
+
+ failedStage.error.set(Some(cancellationFailure))
+ assert(adaptivePlan.invokePrivate(
+ shouldIgnoreObsoleteStageFailure(failedStage, cancellationFailure,
Set.empty[Int])))
+ assert(!adaptivePlan.context.stageCache.contains(scan.canonicalized))
+ }
+ }
+
+ test("obsolete shuffle stage cancellation records only submitted shuffles
for cleanup") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "2") {
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val replacementPlan = LocalTableScanExec(Nil, Nil, None)
+
+ val unsubmitted = spark.range(8).repartition(2)
+ val unsubmittedPlan =
+
unsubmitted.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val unsubmittedExchange = unsubmittedPlan.initialPlan.collectFirst {
+ case exchange: ShuffleExchangeExec => exchange
+ }.getOrElse(fail("expected an unsubmitted shuffle exchange"))
+ val unsubmittedStage = TestExchangeQueryStageExec(
+ 200, unsubmittedExchange, unsubmittedExchange.canonicalized)
+
unsubmittedPlan.context.stageCache.put(unsubmittedExchange.canonicalized,
unsubmittedStage)
+
+ assert(unsubmittedExchange.futureAction.get().isEmpty)
+ assert(unsubmittedPlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(unsubmittedStage))) ==
Seq(unsubmittedStage.id))
+ assert(unsubmittedPlan.context.shuffleIds.isEmpty)
+ assert(unsubmittedExchange.futureAction.get().isEmpty)
+
+ val submitted = spark.range(8).repartition(2)
+ submitted.collect()
+ val submittedPlan =
submitted.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val submittedExchange = collect(submittedPlan) {
+ case stage: ShuffleQueryStageExec if
stage.shuffle.futureAction.get().isDefined =>
+ stage.shuffle
+ }.headOption.getOrElse(fail("expected a submitted shuffle exchange"))
+ val submittedShuffleId = submittedExchange.shuffleId
+ submittedPlan.context.shuffleIds.remove(submittedShuffleId)
+ val submittedStage = TestExchangeQueryStageExec(
+ 201, submittedExchange, submittedExchange.canonicalized)
+ submittedPlan.context.stageCache.put(submittedExchange.canonicalized,
submittedStage)
+
+ assert(submittedPlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(submittedStage))) ==
Seq(submittedStage.id))
+ assert(submittedPlan.context.shuffleIds.containsKey(submittedShuffleId))
+
+ submittedPlan.context.shuffleIds.remove(submittedShuffleId)
+ val failedSubmittedStage = TestExchangeQueryStageExec(
+ 202,
+ submittedExchange,
+ submittedExchange.canonicalized,
+ cancelFailure = Some(new IllegalStateException("submitted stage
cancellation failed")))
+ submittedPlan.context.stageCache.put(submittedExchange.canonicalized,
failedSubmittedStage)
+
+ assert(submittedPlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan,
Seq(failedSubmittedStage))).isEmpty)
+ assert(submittedPlan.context.shuffleIds.containsKey(submittedShuffleId))
+
assert(submittedPlan.context.stageCache.get(submittedExchange.canonicalized)
+ .exists(_ eq failedSubmittedStage))
+
+ val delegatedExchange = org.apache.spark.sql.MyShuffleExchangeExec(
+ submittedExchange.asInstanceOf[ShuffleExchangeExec])
+ val delegatedStage = ShuffleQueryStageExec(
+ 203, delegatedExchange, delegatedExchange.canonicalized)
+ submittedPlan.context.stageCache.put(delegatedExchange.canonicalized,
delegatedStage)
+
+ assert(submittedExchange.futureAction.get().isDefined)
+ assert(delegatedExchange.futureAction.get().isEmpty)
+ assert(submittedPlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(delegatedStage))).isEmpty)
+
assert(submittedPlan.context.stageCache.get(delegatedExchange.canonicalized)
+ .exists(_ eq delegatedStage))
+
+ submittedPlan.context.shuffleIds.remove(submittedShuffleId)
+ val shouldIgnoreObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("shouldIgnoreObsoleteStageFailure"))
+ val delegatedFailure = new IllegalStateException("opaque delegated
shuffle failed")
+ delegatedStage.error.set(Some(delegatedFailure))
+ assert(!submittedPlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ delegatedStage, delegatedFailure, Set.empty[Int])))
+ assert(!submittedPlan.context.shuffleIds.containsKey(submittedShuffleId))
+ assert(submittedExchange.futureAction.get().exists(!_.isCancelled))
+
assert(submittedPlan.context.stageCache.get(delegatedExchange.canonicalized)
+ .exists(_ eq delegatedStage))
+ }
+ }
+
+ test("obsolete submitted shuffle stages are not cancelled before
shuffle-file cleanup") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.SHUFFLE_PARTITIONS.key -> "2") {
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val replacementPlan = LocalTableScanExec(Nil, Nil, None)
+
+ val submitted = spark.range(8).repartition(2)
+ submitted.collect()
+ val submittedPlan =
submitted.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val submittedExchange = collect(submittedPlan) {
+ case stage: ShuffleQueryStageExec if
stage.shuffle.futureAction.get().isDefined =>
+ stage.shuffle
+ }.headOption.getOrElse(fail("expected a submitted shuffle exchange"))
+ val submittedAction = submittedExchange.futureAction.get().get
+
+ val cleanup = org.apache.spark.sql.classic.Dataset.ofRows(
+ spark, spark.range(16).repartition(2).logicalPlan, RemoveShuffleFiles)
+ val cleanupPlan =
cleanup.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ assert(cleanupPlan.context.qe.shuffleCleanupMode == RemoveShuffleFiles)
+
+ val submittedStage = ShuffleQueryStageExec(
+ 300, submittedExchange, submittedExchange.canonicalized)
+ cleanupPlan.context.stageCache.put(submittedExchange.canonicalized,
submittedStage)
+
+ assert(!submittedAction.isCancelled)
+ assert(cleanupPlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(submittedStage))).isEmpty)
+ assert(!submittedAction.isCancelled)
+
assert(cleanupPlan.context.stageCache.get(submittedExchange.canonicalized)
+ .exists(_ eq submittedStage))
+
+ val shouldIgnoreObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("shouldIgnoreObsoleteStageFailure"))
+ val fatalFailure = new SparkFatalException(new OutOfMemoryError("fatal
shuffle failure"))
+ assert(!cleanupPlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ submittedStage, fatalFailure, Set.empty[Int])))
+
+ val stageFailure = new IllegalStateException("obsolete submitted shuffle
failed")
+ submittedStage.error.set(Some(stageFailure))
+ assert(cleanupPlan.invokePrivate(shouldIgnoreObsoleteStageFailure(
+ submittedStage, stageFailure, Set.empty[Int])))
+
assert(cleanupPlan.context.shuffleIds.containsKey(submittedExchange.shuffleId))
+
assert(!cleanupPlan.context.stageCache.contains(submittedExchange.canonicalized))
+ assert(!submittedAction.isCancelled)
+
+ val unsubmittedExchange = cleanupPlan.initialPlan.collectFirst {
+ case exchange: ShuffleExchangeExec => exchange
+ }.getOrElse(fail("expected an unsubmitted shuffle exchange"))
+ val unsubmittedStage = ShuffleQueryStageExec(
+ 301, unsubmittedExchange, unsubmittedExchange.canonicalized)
+ cleanupPlan.context.stageCache.put(unsubmittedExchange.canonicalized,
unsubmittedStage)
+
+ assert(unsubmittedExchange.futureAction.get().isEmpty)
+ assert(cleanupPlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(unsubmittedStage))) ==
Seq(unsubmittedStage.id))
+ assert(unsubmittedExchange.futureAction.get().isEmpty)
+
assert(!cleanupPlan.context.stageCache.contains(unsubmittedExchange.canonicalized))
+ }
+ }
+
+ test("concurrent exchange reuse and obsolete cancellation coordinate by
stage") {
+ withSQLConf(
+ SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
+ SQLConf.AUTO_BROADCASTJOIN_THRESHOLD.key -> "-1",
+ SQLConf.EXCHANGE_REUSE_ENABLED.key -> "true") {
+ val createNonResultQueryStages =
+ PrivateMethod[Any](Symbol("createNonResultQueryStages"))
+ val cancelObsoleteStages =
+ PrivateMethod[Seq[Int]](Symbol("cancelObsoleteStages"))
+ val ignoreFailedObsoleteStageFailure =
+ PrivateMethod[Boolean](Symbol("ignoreFailedObsoleteStageFailure"))
+ val replacementPlan = LocalTableScanExec(Nil, Nil, None)
+
+ def newPlanAndExchange(): (AdaptiveSparkPlanExec, ShuffleExchangeExec) =
{
+ val df = spark.sql("SELECT * FROM testData JOIN testData2 ON key = a")
+ val adaptivePlan =
df.queryExecution.executedPlan.asInstanceOf[AdaptiveSparkPlanExec]
+ val exchange = adaptivePlan.initialPlan.collectFirst {
+ case shuffle: ShuffleExchangeExec => shuffle
+ }.getOrElse(fail("expected a shuffle exchange in the initial adaptive
plan"))
+ (adaptivePlan, exchange)
+ }
+
+ def acquireStage(
+ adaptivePlan: AdaptiveSparkPlanExec,
+ exchange: ShuffleExchangeExec): ExchangeQueryStageExec = {
+ adaptivePlan.invokePrivate(createNonResultQueryStages(exchange))
+
.asInstanceOf[Product].productElement(0).asInstanceOf[ExchangeQueryStageExec]
+ }
+
+ type LifecycleWorker = (Thread, AtomicReference[Throwable])
+
+ def startWorker(name: String)(body: => Unit): LifecycleWorker = {
+ val started = new CountDownLatch(1)
+ val failure = new AtomicReference[Throwable]()
+ val worker = new Thread(name) {
+ override def run(): Unit = {
+ started.countDown()
+ try {
+ spark.withActive {
+ body
+ }
+ } catch {
+ case error: Throwable => failure.set(error)
+ }
+ }
+ }
+ worker.setDaemon(true)
+ worker.start()
+ assert(started.await(30, TimeUnit.SECONDS), s"$name did not start")
+ (worker, failure)
+ }
+
+ def waitForWorker(worker: LifecycleWorker): Unit = {
+ worker._1.join(TimeUnit.SECONDS.toMillis(30))
+ }
+
+ def checkWorker(worker: LifecycleWorker): Unit = {
+ assert(!worker._1.isAlive, s"${worker._1.getName} did not finish")
+ Option(worker._2.get()).foreach(throw _)
+ }
+
+ {
+ val (adaptivePlan, exchange) = newPlanAndExchange()
+ val reuseEntered = new CountDownLatch(1)
+ val releaseReuse = new CountDownLatch(1)
+ val existingStage = TestExchangeQueryStageExec(
+ 100,
+ exchange,
+ exchange.canonicalized,
+ reuseCallback = Some(() => {
+ reuseEntered.countDown()
+ assert(releaseReuse.await(30, TimeUnit.SECONDS), "stage reuse was
not released")
+ }))
+ adaptivePlan.context.stageCache.put(exchange.canonicalized,
existingStage)
+
+ val acquiredStage = new AtomicReference[ExchangeQueryStageExec]()
+ val cancelledIds = new AtomicReference[Seq[Int]]()
+ val reuseWorker = startWorker("aqe-stage-reuse-first") {
+ acquiredStage.set(acquireStage(adaptivePlan, exchange))
+ }
+ var cancellationWorker: Option[LifecycleWorker] = None
+
+ try {
+ assert(reuseEntered.await(30, TimeUnit.SECONDS), "exchange reuse did
not start")
+ cancellationWorker =
Some(startWorker("aqe-stage-cancellation-after-reuse") {
+ cancelledIds.set(adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(existingStage))))
+ })
+ eventually(timeout(10.seconds), interval(10.milliseconds)) {
+ assert(cancellationWorker.get._1.getState == Thread.State.BLOCKED)
+ }
+ } finally {
+ releaseReuse.countDown()
+ waitForWorker(reuseWorker)
+ cancellationWorker.foreach(waitForWorker)
+ }
+
+ checkWorker(reuseWorker)
+ cancellationWorker.foreach(checkWorker)
+ assert(cancelledIds.get().isEmpty)
+ assert(!existingStage.cancelled)
+ assert(acquiredStage.get().resultOption.eq(existingStage.resultOption))
+
assert(adaptivePlan.context.isSharedStageResult(existingStage.resultOption))
+ assert(adaptivePlan.context.stageCache.get(exchange.canonicalized)
+ .exists(_ eq existingStage))
+ }
+
+ {
+ val (adaptivePlan, exchange) = newPlanAndExchange()
+ val unrelatedExchange = adaptivePlan.initialPlan.collectFirst {
+ case candidate: ShuffleExchangeExec
+ if !candidate.canonicalized.fastEquals(exchange.canonicalized)
=> candidate
+ }.getOrElse(fail("expected an unrelated shuffle exchange in the
initial adaptive plan"))
+ val cancellationEntered = new CountDownLatch(1)
+ val releaseCancellation = new CountDownLatch(1)
+ val obsoleteStage = TestExchangeQueryStageExec(
+ 101,
+ exchange,
+ exchange.canonicalized,
+ cancelCallback = Some(() => {
+ cancellationEntered.countDown()
+ assert(releaseCancellation.await(30, TimeUnit.SECONDS),
+ "stage cancellation was not released")
+ }))
+ adaptivePlan.context.stageCache.put(exchange.canonicalized,
obsoleteStage)
+
+ val acquiredStage = new AtomicReference[ExchangeQueryStageExec]()
+ val unrelatedStage = new AtomicReference[ExchangeQueryStageExec]()
+ val unrelatedAcquired = new CountDownLatch(1)
+ val cancelledIds = new AtomicReference[Seq[Int]]()
+ val cancellationWorker = startWorker("aqe-stage-cancellation-first") {
+ cancelledIds.set(adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(obsoleteStage))))
+ }
+ var reuseWorker: Option[LifecycleWorker] = None
+ var unrelatedWorker: Option[LifecycleWorker] = None
+
+ try {
+ assert(cancellationEntered.await(30, TimeUnit.SECONDS),
+ "obsolete stage cancellation did not start")
+ reuseWorker = Some(startWorker("aqe-stage-reuse-after-cancellation")
{
+ acquiredStage.set(acquireStage(adaptivePlan, exchange))
+ })
+ eventually(timeout(10.seconds), interval(10.milliseconds)) {
+ assert(Set(Thread.State.BLOCKED, Thread.State.WAITING,
Thread.State.TIMED_WAITING)
+ .contains(reuseWorker.get._1.getState))
+ assert(acquiredStage.get() == null)
+ }
+
+ unrelatedWorker =
Some(startWorker("aqe-unrelated-stage-during-cancellation") {
+ unrelatedStage.set(acquireStage(adaptivePlan, unrelatedExchange))
+ unrelatedAcquired.countDown()
+ })
+ assert(unrelatedAcquired.await(10, TimeUnit.SECONDS),
+ "an unrelated exchange was blocked while stage cancellation was
running")
+
assert(unrelatedStage.get().resultOption.ne(obsoleteStage.resultOption))
+ assert(acquiredStage.get() == null)
+ } finally {
+ releaseCancellation.countDown()
+ waitForWorker(cancellationWorker)
+ reuseWorker.foreach(waitForWorker)
+ unrelatedWorker.foreach(waitForWorker)
+ }
+
+ checkWorker(cancellationWorker)
+ reuseWorker.foreach(checkWorker)
+ unrelatedWorker.foreach(checkWorker)
+ assert(cancelledIds.get() == Seq(obsoleteStage.id))
+ assert(obsoleteStage.cancelled)
+ assert(acquiredStage.get().resultOption.ne(obsoleteStage.resultOption))
+
assert(adaptivePlan.context.stageCache.get(acquiredStage.get().plan.canonicalized)
+ .exists(_ eq acquiredStage.get()))
+
assert(adaptivePlan.context.stageCache.get(unrelatedStage.get().plan.canonicalized)
+ .exists(_ eq unrelatedStage.get()))
+ }
+
+ {
+ val (adaptivePlan, obsoleteExchange) = newPlanAndExchange()
+ val unrelatedExchange = adaptivePlan.initialPlan.collectFirst {
+ case exchange: ShuffleExchangeExec
+ if
!exchange.canonicalized.fastEquals(obsoleteExchange.canonicalized) => exchange
+ }.getOrElse(fail("expected an unrelated shuffle exchange in the
initial adaptive plan"))
+ val obsoleteStage = ShuffleQueryStageExec(
+ 104, obsoleteExchange, obsoleteExchange.canonicalized)
+ adaptivePlan.context.stageCache.put(obsoleteExchange.canonicalized,
obsoleteStage)
+
+ val monitorEntered = new CountDownLatch(1)
+ val releaseMonitor = new CountDownLatch(1)
+ val monitorWorker = startWorker("aqe-obsolete-shuffle-monitor-held") {
+ obsoleteExchange.synchronized {
+ monitorEntered.countDown()
+ assert(releaseMonitor.await(30, TimeUnit.SECONDS),
+ "obsolete shuffle monitor was not released")
+ }
+ }
+
+ val cancelledIds = new AtomicReference[Seq[Int]]()
+ val acquiredStage = new AtomicReference[ExchangeQueryStageExec]()
+ val unrelatedAcquired = new CountDownLatch(1)
+ var cancellationWorker: Option[LifecycleWorker] = None
+ var unrelatedWorker: Option[LifecycleWorker] = None
+
+ try {
+ assert(monitorEntered.await(30, TimeUnit.SECONDS),
+ "obsolete shuffle monitor was not acquired")
+ cancellationWorker =
Some(startWorker("aqe-shuffle-cancellation-waits-for-monitor") {
+ cancelledIds.set(adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(obsoleteStage))))
+ })
+ eventually(timeout(10.seconds), interval(10.milliseconds)) {
+ assert(cancellationWorker.get._1.getState == Thread.State.BLOCKED)
+ }
+
+ unrelatedWorker =
Some(startWorker("aqe-unrelated-stage-acquisition") {
+ acquiredStage.set(acquireStage(adaptivePlan, unrelatedExchange))
+ unrelatedAcquired.countDown()
+ })
+ assert(unrelatedAcquired.await(10, TimeUnit.SECONDS),
+ "an unrelated exchange was blocked by obsolete shuffle
cancellation")
+
assert(acquiredStage.get().resultOption.ne(obsoleteStage.resultOption))
+
assert(adaptivePlan.context.stageCache.get(acquiredStage.get().plan.canonicalized)
+ .exists(_ eq acquiredStage.get()))
+ } finally {
+ releaseMonitor.countDown()
+ waitForWorker(monitorWorker)
+ cancellationWorker.foreach(waitForWorker)
+ unrelatedWorker.foreach(waitForWorker)
+ }
+
+ checkWorker(monitorWorker)
+ cancellationWorker.foreach(checkWorker)
+ unrelatedWorker.foreach(checkWorker)
+ assert(cancelledIds.get() == Seq(obsoleteStage.id))
+
assert(!adaptivePlan.context.stageCache.contains(obsoleteExchange.canonicalized))
+ }
+
+ {
+ val (adaptivePlan, exchange) = newPlanAndExchange()
+ val cancellationEntered = new CountDownLatch(1)
+ val releaseCancellation = new CountDownLatch(1)
+ val obsoleteStage = TestExchangeQueryStageExec(
+ 105,
+ exchange,
+ exchange.canonicalized,
+ cancelCallback = Some(() => {
+ cancellationEntered.countDown()
+ assert(releaseCancellation.await(30, TimeUnit.SECONDS),
+ "failed stage cancellation was not released")
+ throw new IllegalStateException("test concurrent stage
cancellation failed")
+ }))
+ adaptivePlan.context.stageCache.put(exchange.canonicalized,
obsoleteStage)
+
+ val cancelledIds = new AtomicReference[Seq[Int]]()
+ val acquiredStage = new AtomicReference[ExchangeQueryStageExec]()
+ val cancellationWorker = startWorker("aqe-failed-stage-cancellation") {
+ cancelledIds.set(adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(obsoleteStage))))
+ }
+ var reuseWorker: Option[LifecycleWorker] = None
+
+ try {
+ assert(cancellationEntered.await(30, TimeUnit.SECONDS),
+ "failing stage cancellation did not start")
+ reuseWorker =
Some(startWorker("aqe-stage-reuse-after-failed-cancellation") {
+ acquiredStage.set(acquireStage(adaptivePlan, exchange))
+ })
+ eventually(timeout(10.seconds), interval(10.milliseconds)) {
+ assert(Set(Thread.State.BLOCKED, Thread.State.WAITING,
Thread.State.TIMED_WAITING)
+ .contains(reuseWorker.get._1.getState))
+ assert(acquiredStage.get() == null)
+ }
+ } finally {
+ releaseCancellation.countDown()
+ waitForWorker(cancellationWorker)
+ reuseWorker.foreach(waitForWorker)
+ }
+
+ checkWorker(cancellationWorker)
+ reuseWorker.foreach(checkWorker)
+ assert(cancelledIds.get().isEmpty)
+ assert(!obsoleteStage.cancelled)
+ assert(acquiredStage.get().resultOption.eq(obsoleteStage.resultOption))
+
assert(adaptivePlan.context.isSharedStageResult(obsoleteStage.resultOption))
+ assert(adaptivePlan.context.stageCache.get(exchange.canonicalized)
+ .exists(_ eq obsoleteStage))
+ }
+
+ {
+ val (adaptivePlan, exchange) = newPlanAndExchange()
+ val obsoleteStage = TestExchangeQueryStageExec(
+ 102,
+ exchange,
+ exchange.canonicalized,
+ cancelFailure = Some(new IllegalStateException("test stage
cancellation failed")))
+ adaptivePlan.context.stageCache.put(exchange.canonicalized,
obsoleteStage)
+
+ val cancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(obsoleteStage)))
+ assert(cancelledIds.isEmpty)
+ assert(!obsoleteStage.cancelled)
+ assert(adaptivePlan.context.stageCache.get(exchange.canonicalized)
+ .exists(_ eq obsoleteStage))
+
+ val reusedStage = acquireStage(adaptivePlan, exchange)
+ assert(reusedStage.resultOption.eq(obsoleteStage.resultOption))
+
assert(adaptivePlan.context.isSharedStageResult(obsoleteStage.resultOption))
+
+ obsoleteStage.error.set(Some(new IllegalStateException("shared stage
failed")))
+
assert(!adaptivePlan.invokePrivate(ignoreFailedObsoleteStageFailure(obsoleteStage)))
+ assert(adaptivePlan.context.stageCache.get(exchange.canonicalized)
+ .exists(_ eq obsoleteStage))
+ }
+
+ {
+ val (adaptivePlan, exchange) = newPlanAndExchange()
+ val obsoleteStage = TestExchangeQueryStageExec(
+ 103,
+ exchange,
+ exchange.canonicalized,
+ cancelFailure = Some(new IllegalStateException("test stage
cancellation failed")))
+ adaptivePlan.context.stageCache.put(exchange.canonicalized,
obsoleteStage)
+
+ val cancelledIds = adaptivePlan.invokePrivate(
+ cancelObsoleteStages(replacementPlan, Seq(obsoleteStage)))
+ assert(cancelledIds.isEmpty)
+ assert(adaptivePlan.context.stageCache.get(exchange.canonicalized)
+ .exists(_ eq obsoleteStage))
+
+ obsoleteStage.error.set(Some(new IllegalStateException("obsolete stage
failed")))
+
assert(adaptivePlan.invokePrivate(ignoreFailedObsoleteStageFailure(obsoleteStage)))
+
assert(!adaptivePlan.context.stageCache.contains(exchange.canonicalized))
+ }
+ }
+ }
+
test("Scalar subquery") {
withSQLConf(
SQLConf.ADAPTIVE_EXECUTION_ENABLED.key -> "true",
@@ -4304,6 +5362,41 @@ class AdaptiveQueryExecSuite
}
}
+private case class TestExchangeQueryStageExec(
+ override val id: Int,
+ override val plan: SparkPlan,
+ override val _canonicalized: SparkPlan,
+ runtimeRowCount: Option[BigInt] = None,
+ cancelFailure: Option[Throwable] = None,
+ reuseCallback: Option[() => Unit] = None,
+ cancelCallback: Option[() => Unit] = None) extends ExchangeQueryStageExec {
+ @volatile var cancelled: Boolean = false
+
+ override protected def doMaterialize(): Future[Any] = Future.successful(())
+
+ override def getRuntimeStatistics:
org.apache.spark.sql.catalyst.plans.logical.Statistics =
+ org.apache.spark.sql.catalyst.plans.logical.Statistics(
+ sizeInBytes = BigInt(0), rowCount = runtimeRowCount)
+
+ override protected def doCancel(reason: String): Unit = {
+ cancelFailure.foreach { failure =>
+ throw failure
+ }
+ cancelCallback.foreach(_())
+ cancelled = true
+ }
+
+ override def newReuseInstance(
+ newStageId: Int,
+ newOutput: Seq[Attribute]): ExchangeQueryStageExec = {
+ reuseCallback.foreach(_())
+ val reuse = copy(id = newStageId)
+ reuse._resultOption = this._resultOption
+ reuse._error = this._error
+ reuse
+ }
+}
+
/**
* A minimal leaf plan with a single output attribute, used to build tiny
plans for cost tests.
*/
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]