sunchao commented on code in PR #55839:
URL: https://github.com/apache/spark/pull/55839#discussion_r3816456749
##########
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:
Agreed. The published PR description still needs to be updated. I prepared a
replacement that distinguishes the two behaviors: conservative
runtime-row-count propagation through the supported sort, limit, and aggregate
paths, and lifecycle handling of private obsolete exchange stages. It documents
proactive shuffle cancellation and user-visible listener events, the
broadcast/delegated-shuffle/cleanup-mode exclusions, guarded handling of
eventual failures from identifiable obsolete stages, the stricter treatment of
opaque delegated shuffles, cross-plan stage tracking, exchange-reuse
synchronization, fatal-error propagation, and regression coverage.
##########
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:
Good catch. I replaced the ineffective end-to-end assertion with the
conditionless left-semi join over a global aggregate that you suggested. The
regression verifies that the original plan contains the join, that the
aggregate remains a one-row result over empty input, and that AQE eliminates
the join using the new `Some(1)` information. Its direct rule assertion depends
on the global-aggregate cardinality handling rather than only checking an
unchanged final result. The final combined patch passed the complete 139-test
suite.
##########
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:
Agreed. I removed the `AQEShuffleReadExec`, `ProjectExec`, and
`ColumnarToRowExec` branches instead of treating emptiness preservation as
evidence of reachability. The remaining allowlist covers `SortExec`,
`BaseLimitExec`, and `BaseAggregateExec`. The wrapper regression obtains a
logically linked sort/shuffle shape from a real planned `ORDER BY ... LIMIT ...
DISTINCT` query and directly exercises sort and limit propagation using that
shape; the limit-specific assertion is a direct rule test, not a claim that a
naturally executed query necessarily chooses the limit branch. The
global-aggregate regression observes the new nonempty inference directly.
##########
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:
Fixed while retaining the distinction between retained/shared stages,
successfully cancelled stages, identifiable obsolete stages whose cancellation
fails or is unsafe, and opaque delegated shuffles. Cancellation is attempted
before the exchange-cache entry is removed, so a thrown cancellation leaves the
existing stage available for reuse. Failed attempts, obsolete broadcasts, and
known submitted cleanup-protected shuffles are tracked separately from actual
successful cancellations. Their later nonfatal materialization failure is
ignored only if the stage is still obsolete and private; at that point any
visible submitted shuffle ID is registered and the failed cache entry is
evicted. Fatal failures, failures from retained/shared stages, and failures
from opaque delegated shuffles continue to propagate; the opaque delegated
stage also remains cached. The regressions check cancellation IDs,
failed-cancellation cache retention, eventual eligible-stage eviction, and
conservative deleg
ated-stage handling.
##########
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:
Fixed. A submitted shuffle ID is registered before cancellation is
attempted, including when cancellation throws. Submission is checked before
reading `shuffleId`, so an unsubmitted exchange does not initialize its lazy
shuffle dependency. Submitted shuffles are not proactively cancelled under
`RemoveShuffleFiles`, because Spark acknowledges cancellation before executor
map writers have necessarily stopped; otherwise end-of-query cleanup could
delete files while those writers are still active. Such an identifiable
obsolete shuffle is still tracked: if it later fails with an ordinary error
while still private and unreferenced, its submitted shuffle ID is registered,
its failed cache entry is removed, and the irrelevant failure is ignored
without cancelling the underlying action. Wrapped fatal errors and failures
after reuse still propagate. An opaque delegated wrapper with an empty
`futureAction` is handled more conservatively: it is not cancelled, its failure
is not suppressed, an
d its cache entry is retained because the delegate's hidden shuffle ID cannot
safely be registered.
##########
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:
Added documentation for the stage-lifecycle lock, shared-result bookkeeping,
cancellation eligibility, guarded eventual failure handling for identifiable
uncancellable obsolete stages, cache removal, and cleanup registration. Opaque
delegated shuffles remain excluded from failure suppression because their
hidden delegate shuffle IDs cannot be safely registered. The comments explain
why a stage nested under a later stage must already be materialized, why
cross-subquery reuse needs shared-result identity tracking beyond the current
plan, why cache lookup/reuse/marking must remain atomic, and why once-shared
results stay protected for the lifetime of the adaptive context. A
stage-result-scoped `CompletableFuture` reservation also lets both
exchange-cache lookup paths wait for that result outside the query-global lock
while unrelated exchanges continue.
##########
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:
Fixed. I removed the sleep-based job-cancellation/listener assertion from
the wrapper test. Rule propagation and exchange-stage lifecycle behavior are
now checked separately using deterministic plan and stage assertions, so the
regression does not depend on relative task completion times or
cancellation-message wording.
##########
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:
I agree these are independent changes: row-count propagation can stand
alone, and obsolete-stage cancellation also applies to pre-existing adaptive
replans. The implementation and regression coverage now distinguish the two
behaviors, and I have prepared an updated PR description that still needs to be
applied. I am happy to split proactive stage cancellation into a separate
PR/JIRA if you would prefer to review and land the propagation change
independently.
--
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]