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


##########
core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala:
##########
@@ -282,12 +293,75 @@ private[spark] class ExecutorAllocationManager(
   def reset(): Unit = synchronized {
     addTime = 0L
     numExecutorsTargetPerResourceProfileId.keys.foreach { rpId =>
-      numExecutorsTargetPerResourceProfileId(rpId) = initialNumExecutors
+      numExecutorsTargetPerResourceProfileId(rpId) = if (suspended) 0 else 
initialNumExecutors
     }
     numExecutorsToAddPerResourceProfileId.keys.foreach { rpId =>
       numExecutorsToAddPerResourceProfileId(rpId) = 1
     }
     executorMonitor.reset()
+    if (suspended) {
+      // A restarted cluster manager AM may have allocated executors on its 
own, so the zero
+      // targets have to be pushed again. Leave that to `schedule()`: this 
method may run
+      // inside the cluster manager's RPC handler, where a synchronous request 
would
+      // self-deadlock.
+      suspendedSyncPending = true
+    }
+  }
+
+  /**
+   * Suspend allocation and lower the executor targets of all resource 
profiles to zero, so that
+   * pending tasks do not bring up new executors while the executors are held. 
`schedule()` is a
+   * no-op until [[resume()]] is called.
+   */
+  def suspend(): Unit = synchronized {
+    if (!suspended) {
+      suspended = true
+      numExecutorsTargetPerResourceProfileId.keys.foreach { rpId =>
+        numExecutorsTargetPerResourceProfileId(rpId) = 0
+      }
+      numExecutorsToAddPerResourceProfileId.keys.foreach { rpId =>
+        numExecutorsToAddPerResourceProfileId(rpId) = 1
+      }
+      if (!testing) {

Review Comment:
   **Finding 2.** The acknowledgment is discarded here (and in `resume()`), so 
a request the cluster manager rejects is never re-sent.
   
   `client.requestTotalExecutors` returns `false` on a real, recoverable path: 
`YarnSchedulerEndpoint` replies `false` whenever `amEndpoint` is empty 
("Attempted to request executors before the AM has registered"). Everywhere 
else in this file the ack is checked -- `doUpdateRequest` rolls the target back 
on `false` so the next tick retries, and you added `suspendedSyncPending` to 
get exactly that retry for `reset()`. `suspend()` is the one push with neither: 
after a `false` the driver believes the target is 0, `schedule()` returns early 
on every tick without re-pushing, and the cluster manager keeps granting 
executors against its stale target -- each registers and is immediately 
decommissioned by the new `executorsHeld` branch, so the application churns 
executors for as long as it is held.
   
   The retry you already built covers it, it just needs arming from here:
   
   ```scala
         if (!(testing || client.requestTotalExecutors(
             numExecutorsTargetPerResourceProfileId.toMap,
             numLocalityAwareTasksPerResourceProfileId.toMap,
             rpIdToHostToLocalTaskCount))) {
           suspendedSyncPending = true
         }
   ```
   
   `resume()` has the mirror hole: if its push is rejected, `suspended` is 
cleared anyway, and `updateAndSyncNumExecutorsTarget` only calls the client 
when a target actually *changes*. With `spark.dynamicAllocation.minExecutors = 
1` and no pending tasks the target settles back on 1, `delta == 0`, no request 
is sent, and the application stays at zero executors on the cluster-manager 
side until some later backlog moves the target.
   



##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2075,126 @@ class SparkContext(config: SparkConf) extends Logging {
     }
   }
 
+  // Whether the executors are held via `holdExecutors()`, and, when dynamic 
allocation is
+  // disabled, the number of executors to restore on `resumeExecutors()`.
+  @volatile private var _executorsHeld: Boolean = false
+  private var heldNumExecutors: Int = 0
+
+  /**
+   * Whether `holdExecutors()` is supported in the current deployment. It 
requires a scheduler
+   * backend that can adjust the number of executors, an external shuffle 
service to keep the
+   * shuffle data of the decommissioned executors, and decommission support.
+   */
+  private[spark] def executorHoldSupported: Boolean = {
+    schedulerBackend.isInstanceOf[ExecutorAllocationClient] &&
+      conf.get(SHUFFLE_SERVICE_ENABLED) && conf.get(DECOMMISSION_ENABLED)
+  }
+
+  /** Whether the executors are currently held by `holdExecutors()`. */
+  private[spark] def executorsHeld: Boolean = _executorsHeld
+
+  /**
+   * :: DeveloperApi ::
+   * Hold the whole application by declining to allocate new executors and 
gracefully
+   * decommissioning all existing ones. Each executor finishes its running 
tasks and then exits,
+   * while the shuffle data already written remains available through the 
external shuffle
+   * service, so the application can later pick up where it left off via 
`resumeExecutors()`.
+   *
+   * This requires both an external shuffle service 
(`spark.shuffle.service.enabled`) and
+   * decommission support (`spark.decommission.enabled`).
+   *
+   * @return whether the request is received by the cluster manager.
+   */
+  @DeveloperApi
+  def holdExecutors(): Boolean = {
+    schedulerBackend match {
+      case b: ExecutorAllocationClient =>
+        require(executorHoldSupported,
+          s"holdExecutors() requires both ${SHUFFLE_SERVICE_ENABLED.key} and " 
+
+            s"${DECOMMISSION_ENABLED.key}")
+        synchronized {
+          if (!_executorsHeld) {
+            if (executorAllocationManager.isEmpty) {
+              // The requirement to restore on resume. Standalone ignores
+              // spark.executor.instances and has no explicit executor 
requirement by default,
+              // so restore an unbounded one, even when the conf carries a 
leftover value.
+              // Elsewhere follow the conf, or fall back to the cluster 
manager's default when
+              // no executor has registered yet.
+              heldNumExecutors = schedulerBackend match {
+                case _: StandaloneSchedulerBackend => Int.MaxValue
+                case _ =>
+                  
conf.get(EXECUTOR_INSTANCES).getOrElse(math.max(b.getExecutorIds().size,
+                    SchedulerBackendUtils.DEFAULT_NUMBER_EXECUTORS))
+              }
+            }
+            // Mark the hold before talking to the cluster manager, so that a 
partial failure
+            // below leaves the executors held, and thus resumable, instead of 
half-held.
+            _executorsHeld = true
+            b match {
+              case cg: CoarseGrainedSchedulerBackend => 
cg.setExecutorsHeld(true)
+              case _ =>
+            }
+            executorAllocationManager match {
+              case Some(manager) => manager.suspend()
+              case None =>
+                b.requestTotalExecutors(
+                  
immutable.Map(resourceProfileManager.defaultResourceProfile.id -> 0),

Review Comment:
   **Finding 4.** With dynamic allocation off nothing re-asserts this zero 
target after the cluster manager loses the driver's state, so the hold is 
silently lost on the requirement side.
   
   `YarnSchedulerBackend.reset()` (YARN client mode, AM re-registration) runs 
`super.reset()`, which clears `requestedTotalExecutorsPerResourceProfile`, and 
then `sc.executorAllocationManager.foreach(_.reset())` -- the `foreach` is a 
no-op here, so the `suspendedSyncPending` re-push you added never happens on 
this path. The restarted AM initialises its target from 
`SchedulerBackendUtils.getInitialTargetExecutorNumber(conf)`, i.e. 
`spark.executor.instances`, and starts allocating again; the new 
`executorsHeld` branch then decommissions each one on registration, so the 
application churns executors instead of staying held.
   
   `CoarseGrainedSchedulerBackend` already knows it is held, so `reset()` is 
the natural place -- and it can re-assert without the self-deadlock you 
documented, as long as it does not await:
   
   ```scala
     protected[scheduler] def reset(): Unit = {
       val executors: Set[String] = synchronized {
         requestedTotalExecutorsPerResourceProfile.clear()
         if (executorsHeld) {
           val defaultProf = 
scheduler.sc.resourceProfileManager.defaultResourceProfile
           requestedTotalExecutorsPerResourceProfile(defaultProf) = 0
           
doRequestTotalExecutors(requestedTotalExecutorsPerResourceProfile.toMap)
         }
         executorDataMap.keys.toSet
       }
   ```
   
   That would also make the dynamic-allocation path symmetric instead of 
special-cased.
   



##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2075,126 @@ class SparkContext(config: SparkConf) extends Logging {
     }
   }
 
+  // Whether the executors are held via `holdExecutors()`, and, when dynamic 
allocation is
+  // disabled, the number of executors to restore on `resumeExecutors()`.
+  @volatile private var _executorsHeld: Boolean = false
+  private var heldNumExecutors: Int = 0
+
+  /**
+   * Whether `holdExecutors()` is supported in the current deployment. It 
requires a scheduler
+   * backend that can adjust the number of executors, an external shuffle 
service to keep the
+   * shuffle data of the decommissioned executors, and decommission support.
+   */
+  private[spark] def executorHoldSupported: Boolean = {
+    schedulerBackend.isInstanceOf[ExecutorAllocationClient] &&
+      conf.get(SHUFFLE_SERVICE_ENABLED) && conf.get(DECOMMISSION_ENABLED)
+  }
+
+  /** Whether the executors are currently held by `holdExecutors()`. */
+  private[spark] def executorsHeld: Boolean = _executorsHeld
+
+  /**
+   * :: DeveloperApi ::
+   * Hold the whole application by declining to allocate new executors and 
gracefully
+   * decommissioning all existing ones. Each executor finishes its running 
tasks and then exits,
+   * while the shuffle data already written remains available through the 
external shuffle
+   * service, so the application can later pick up where it left off via 
`resumeExecutors()`.
+   *
+   * This requires both an external shuffle service 
(`spark.shuffle.service.enabled`) and
+   * decommission support (`spark.decommission.enabled`).
+   *
+   * @return whether the request is received by the cluster manager.
+   */
+  @DeveloperApi
+  def holdExecutors(): Boolean = {
+    schedulerBackend match {
+      case b: ExecutorAllocationClient =>
+        require(executorHoldSupported,
+          s"holdExecutors() requires both ${SHUFFLE_SERVICE_ENABLED.key} and " 
+
+            s"${DECOMMISSION_ENABLED.key}")
+        synchronized {
+          if (!_executorsHeld) {
+            if (executorAllocationManager.isEmpty) {
+              // The requirement to restore on resume. Standalone ignores
+              // spark.executor.instances and has no explicit executor 
requirement by default,
+              // so restore an unbounded one, even when the conf carries a 
leftover value.
+              // Elsewhere follow the conf, or fall back to the cluster 
manager's default when
+              // no executor has registered yet.
+              heldNumExecutors = schedulerBackend match {
+                case _: StandaloneSchedulerBackend => Int.MaxValue
+                case _ =>
+                  
conf.get(EXECUTOR_INSTANCES).getOrElse(math.max(b.getExecutorIds().size,
+                    SchedulerBackendUtils.DEFAULT_NUMBER_EXECUTORS))
+              }
+            }
+            // Mark the hold before talking to the cluster manager, so that a 
partial failure
+            // below leaves the executors held, and thus resumable, instead of 
half-held.
+            _executorsHeld = true
+            b match {
+              case cg: CoarseGrainedSchedulerBackend => 
cg.setExecutorsHeld(true)
+              case _ =>
+            }
+            executorAllocationManager match {

Review Comment:
   **Finding 1.** Both branches here can throw, and neither is caught, so 
`holdExecutors()` can leave the application marked held with every executor 
still running.
   
   `manager.suspend()` and the `case None` branch both end up in 
`CoarseGrainedSchedulerBackend.requestTotalExecutors`, which finishes with 
`defaultAskTimeout.awaitResult(response)` -- that throws on an ask timeout or 
an AM failure. `ExecutorAllocationManager.doUpdateRequest` wraps the very same 
call in `catch { case NonFatal(e) => ... }` with the comment "Errors here are 
more commonly caused by YARN AM restarts, which is a recoverable issue", so 
this is a path that fires in practice.
   
   When it throws, `_executorsHeld` and `setExecutorsHeld(true)` are already 
set, but `b.decommissionExecutors(...)` below never runs. Nothing marked those 
executors pending-decommission, so `isExecutorActive` stays true and they keep 
being offered new tasks, while the Jobs page reports `Held (draining N 
executors)` indefinitely. The comment two lines above says marking the hold 
first avoids leaving it "half-held" -- but this *is* the half-held state: held 
on the driver, untouched on the cluster.
   
   Make the drain unconditional, e.g.
   
   ```scala
               Utils.tryLogNonFatalError {
                 executorAllocationManager match {
                   case Some(manager) => manager.suspend()
                   case None =>
                     b.requestTotalExecutors(
                       
immutable.Map(resourceProfileManager.defaultResourceProfile.id -> 0),
                       immutable.Map.empty, immutable.Map.empty)
                 }
               }
               val executors = b.getExecutorIds()
   ```
   
   combined with the retry in finding 2, so a transient cluster-manager error 
costs a re-push rather than the whole hold.
   



##########
core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala:
##########
@@ -321,6 +326,15 @@ class CoarseGrainedSchedulerBackend(scheduler: 
TaskSchedulerImpl, val rpcEnv: Rp
               decommissionExecutors(Array((executorId, v._1)), v._2, v._3)
               unknownExecutorsPendingDecommission.invalidate(executorId)
             })
+          if (executorsHeld) {

Review Comment:
   **Finding 3.** This branch has no test. It is a new execution-time path 
inside an RPC handler, and its failure mode -- an executor the cluster manager 
granted before the hold quietly surviving it -- raises no exception, so nothing 
in CI would catch a regression.
   
   `CoarseGrainedSchedulerBackendSuite` already has the shape ~250 lines above, 
for the sibling `unknownExecutorsPendingDecommission` branch: 
`test("SPARK-41766: New registered executor should receive decommission request 
sent before registration")`. The held version is that test with one line 
changed:
   
   ```scala
     test("SPARK-58828: New registered executor should be decommissioned while 
held") {
       val conf = new SparkConf().setMaster("local-cluster[0, 3, 
1024]").setAppName("test")
       sc = new SparkContext(conf)
       val backend = 
sc.schedulerBackend.asInstanceOf[CoarseGrainedSchedulerBackend]
       val mockEndpointRef = new MockExecutorRpcEndpointRef(conf)
       val mockAddress = mock[RpcAddress]
       backend.setExecutorsHeld(true)
       backend.driverEndpoint.askSync[Boolean](
         RegisterExecutor("1", mockEndpointRef, mockAddress.host, 1, Map(), 
Map(),
           Map.empty, ResourceProfile.DEFAULT_RESOURCE_PROFILE_ID))
       sc.listenerBus.waitUntilEmpty(executorUpTimeout.toMillis)
       assert(mockEndpointRef.decommissionReceived)
     }
   ```
   
   (`decommissionExecutors` does not gate on `spark.decommission.enabled`, 
which is why the SPARK-41766 test works without it.)
   



##########
core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala:
##########
@@ -321,6 +326,15 @@ class CoarseGrainedSchedulerBackend(scheduler: 
TaskSchedulerImpl, val rpcEnv: Rp
               decommissionExecutors(Array((executorId, v._1)), v._2, v._3)
               unknownExecutorsPendingDecommission.invalidate(executorId)
             })
+          if (executorsHeld) {
+            // The executors are held; drain this late-registered executor 
immediately.
+            val decommissioned = decommissionExecutors(
+              Array((executorId, ExecutorDecommissionInfo("Executors are 
held"))),
+              adjustTargetNumExecutors = false,
+              triggeredByExecutor = false)
+            scheduler.sc.executorAllocationManager.foreach(
+              _.executorMonitor.executorsDecommissioned(decommissioned))

Review Comment:
   **Finding 9.** This call is a no-op on this path. `ExecutorMonitor` is 
registered with `listenerBus.addToManagementQueue(...)`, so it builds its 
tracker asynchronously from the `SparkListenerExecutorAdded` posted ~15 lines 
above; by the time `executorsDecommissioned` runs here, `executors.get(id)` is 
still `null` and the `decommissioning = true` assignment is dropped. So the 
executor never shows up in `numberDecommissioningExecutors` or 
`decommissioningPerResourceProfileId` -- the two things the comment above says 
the call is for.
   
   Harmless while held, since `schedule()` returns early, but it means the 
metric under-reports exactly the executors the hold had to drain. Either drop 
the call here (and let `ExecutorMonitor`'s own removal handling take over), or 
adjust the comment so the limitation is on the record.
   



##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2075,126 @@ class SparkContext(config: SparkConf) extends Logging {
     }
   }
 
+  // Whether the executors are held via `holdExecutors()`, and, when dynamic 
allocation is
+  // disabled, the number of executors to restore on `resumeExecutors()`.
+  @volatile private var _executorsHeld: Boolean = false
+  private var heldNumExecutors: Int = 0
+
+  /**
+   * Whether `holdExecutors()` is supported in the current deployment. It 
requires a scheduler
+   * backend that can adjust the number of executors, an external shuffle 
service to keep the
+   * shuffle data of the decommissioned executors, and decommission support.
+   */
+  private[spark] def executorHoldSupported: Boolean = {
+    schedulerBackend.isInstanceOf[ExecutorAllocationClient] &&
+      conf.get(SHUFFLE_SERVICE_ENABLED) && conf.get(DECOMMISSION_ENABLED)

Review Comment:
   **Finding 6.** The stated requirement is "an external shuffle service to 
keep the shuffle data of the decommissioned executors", but the check is 
`spark.shuffle.service.enabled` specifically, which also rejects a deployment 
that keeps shuffle data through a reliable-storage `ShuffleDataIO`. 
`ExecutorAllocationManager.validateSettings` already treats the two as 
alternatives (`!conf.get(SHUFFLE_SERVICE_ENABLED) && !reliableShuffleStorage`), 
and `shuffleDriverComponents` is reachable from here:
   
   ```scala
     private[spark] def executorHoldSupported: Boolean = {
       schedulerBackend.isInstanceOf[ExecutorAllocationClient] &&
         (conf.get(SHUFFLE_SERVICE_ENABLED) ||
           _shuffleDriverComponents.supportsReliableStorage()) &&
         conf.get(DECOMMISSION_ENABLED)
     }
   ```
   
   Being too strict fails silently here -- the control simply never renders and 
`holdExecutors()` throws -- so it is easy to miss. For what it's worth, shuffle 
tracking and `spark.storage.decommission.shuffleBlocks.enabled` are correctly 
*not* alternatives: holding the whole application leaves no peer to migrate to.
   



##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2075,126 @@ class SparkContext(config: SparkConf) extends Logging {
     }
   }
 
+  // Whether the executors are held via `holdExecutors()`, and, when dynamic 
allocation is
+  // disabled, the number of executors to restore on `resumeExecutors()`.
+  @volatile private var _executorsHeld: Boolean = false
+  private var heldNumExecutors: Int = 0
+
+  /**
+   * Whether `holdExecutors()` is supported in the current deployment. It 
requires a scheduler
+   * backend that can adjust the number of executors, an external shuffle 
service to keep the
+   * shuffle data of the decommissioned executors, and decommission support.
+   */
+  private[spark] def executorHoldSupported: Boolean = {
+    schedulerBackend.isInstanceOf[ExecutorAllocationClient] &&
+      conf.get(SHUFFLE_SERVICE_ENABLED) && conf.get(DECOMMISSION_ENABLED)
+  }
+
+  /** Whether the executors are currently held by `holdExecutors()`. */
+  private[spark] def executorsHeld: Boolean = _executorsHeld
+
+  /**
+   * :: DeveloperApi ::
+   * Hold the whole application by declining to allocate new executors and 
gracefully
+   * decommissioning all existing ones. Each executor finishes its running 
tasks and then exits,
+   * while the shuffle data already written remains available through the 
external shuffle
+   * service, so the application can later pick up where it left off via 
`resumeExecutors()`.

Review Comment:
   **Finding 7.** Worth saying explicitly that cached blocks do not survive a 
hold. Shuffle output does, via the shuffle service, but persisted RDD/DataFrame 
blocks are gone: `BlockManagerDecommissioner` needs a live peer to replicate 
to, and holding decommissions every executor at once, so 
`rddBlockMigrationRunnable` takes its "No available peers to receive RDD 
blocks, stop migration." branch (and with the default 
`spark.storage.decommission.enabled=false` it never even tries). After resume, 
everything cached is recomputed.
   
   "pick up where it left off" here, and "no loss of in-progress task work or 
shuffle output" in the PR description, are both accurate about tasks and 
shuffle -- so one sentence in this scaladoc and in the `spark.ui.holdEnabled` 
doc is all it needs, e.g. "Cached blocks are not preserved and are recomputed 
after resuming."
   



##########
core/src/main/scala/org/apache/spark/ExecutorAllocationManager.scala:
##########
@@ -175,6 +175,17 @@ private[spark] class ExecutorAllocationManager(
   //   (2) an executor idle timeout has elapsed.
   @volatile private var initializing: Boolean = true
 
+  // Whether allocation is suspended because the executors are held. While 
this is true,
+  // `schedule()` is a no-op so that pending tasks do not bring up new 
executors.
+  // See `SparkContext.holdExecutors()`.
+  private var suspended: Boolean = false

Review Comment:
   **Finding 8.** Worth considering whether the hold can ride on the existing 
target machinery instead of running beside it.
   
   As written the feature adds a second control path: `suspended` 
short-circuits `schedule()`, `suspend()`/`resume()` push to the cluster manager 
themselves, `suspendedSyncPending` re-implements the retry `doUpdateRequest` 
already has, `reset()` grows a special case and `onStageSubmitted` grows 
another. Findings 1, 2 and 4 are all consequences of that path not inheriting 
`doUpdateRequest`'s defensiveness.
   
   The alternative is to keep `schedule()` running and make the *bounds* zero 
while held:
   
   ```scala
     private def effectiveMinNumExecutors = if (suspended) 0 else 
minNumExecutors
     private def effectiveMaxNumExecutors = if (suspended) 0 else 
maxNumExecutors
     // and maxNumExecutorsNeededPerResourceProfile returns 0 while suspended
   ```
   
   `suspend()` then only sets the flag (plus `initializing = false`, so the 
tick is not skipped) and talks to nobody. On the next tick 
`updateAndSyncNumExecutorsTarget` sees `maxNeeded(0) < targetExecs`, 
`decrementExecutors` drives each target to `math.max(0, 0) = 0`, and 
`doUpdateRequest` does the push, checks the ack, rolls the target back on 
failure and retries on the following tick. That is exactly what findings 1, 2 
and 4 ask for, and it falls out for free:
   
   - `reset()` restores `initialNumExecutors`, so the next tick decrements to 0 
and pushes -- the `suspendedSyncPending` field and its `reset()`/`schedule()` 
blocks go away, and the YARN AM-restart case is covered without them;
   - a new resource profile in `onStageSubmitted` gets `initialNumExecutors` 
and is decremented to 0 on the next tick -- that special case goes away too;
   - both `if (!testing)` guards go away, so the production push is the one the 
suite exercises;
   - when the target is already 0 nothing is pushed at all, instead of an 
unconditional request.
   
   `resume()` keeps its floor restore and `addTime` nudge as-is.
   
   Two honest counter-arguments. `schedule()` would also keep running 
`timedOutExecutors()`/`removeExecutors()` while held, so idle executors get 
drained by the normal idle path alongside your explicit `decommissionExecutors` 
call, and "nothing happens while held" stops being visible as one `return`. And 
`initializing` needs a decision: `suspend()` clearing it changes what 
`resume()`'s `if (initializing) initialNumExecutors else minNumExecutors` floor 
resolves to, so the "resume before the first stage" case needs the pre-hold 
value remembered.
   
   If you prefer the explicit path, that's fair -- then findings 1, 2 and 4 
each need fixing on their own.
   



##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2075,126 @@ class SparkContext(config: SparkConf) extends Logging {
     }
   }
 
+  // Whether the executors are held via `holdExecutors()`, and, when dynamic 
allocation is
+  // disabled, the number of executors to restore on `resumeExecutors()`.
+  @volatile private var _executorsHeld: Boolean = false
+  private var heldNumExecutors: Int = 0
+
+  /**
+   * Whether `holdExecutors()` is supported in the current deployment. It 
requires a scheduler
+   * backend that can adjust the number of executors, an external shuffle 
service to keep the
+   * shuffle data of the decommissioned executors, and decommission support.
+   */
+  private[spark] def executorHoldSupported: Boolean = {
+    schedulerBackend.isInstanceOf[ExecutorAllocationClient] &&
+      conf.get(SHUFFLE_SERVICE_ENABLED) && conf.get(DECOMMISSION_ENABLED)
+  }
+
+  /** Whether the executors are currently held by `holdExecutors()`. */
+  private[spark] def executorsHeld: Boolean = _executorsHeld
+
+  /**
+   * :: DeveloperApi ::
+   * Hold the whole application by declining to allocate new executors and 
gracefully
+   * decommissioning all existing ones. Each executor finishes its running 
tasks and then exits,
+   * while the shuffle data already written remains available through the 
external shuffle
+   * service, so the application can later pick up where it left off via 
`resumeExecutors()`.
+   *
+   * This requires both an external shuffle service 
(`spark.shuffle.service.enabled`) and
+   * decommission support (`spark.decommission.enabled`).
+   *
+   * @return whether the request is received by the cluster manager.

Review Comment:
   **Finding 5.** The supported branch returns a literal `true` regardless of 
what the cluster manager said, so this `@return` does not hold -- same for 
`resumeExecutors()`. The sibling `requestTotalExecutors` returns 
`b.requestTotalExecutors(...)` directly, and the caller has no other way to 
learn the request was rejected.
   
   Once finding 2 makes the ack available, propagating it is cheap -- have 
`suspend()`/`resume()` return the ack and use it:
   
   ```scala
               val acknowledged = executorAllocationManager match {
                 case Some(manager) => manager.suspend()
                 case None => b.requestTotalExecutors(...)
               }
               ...
               acknowledged
   ```
   
   Otherwise the doc should say the boolean only reports whether the current 
scheduler supports holding.
   



##########
core/src/main/scala/org/apache/spark/ui/jobs/AllJobsPage.scala:
##########
@@ -354,6 +354,37 @@ private[ui] class AllJobsPage(parent: JobsTab, store: 
AppStatusStore) extends We
             <strong>Scheduling Mode: </strong>
             {schedulingMode}
           </li>
+          {
+            if (parent.holdEnabled && 
parent.sc.exists(_.executorHoldSupported)) {
+              val basePathUri = UIUtils.prependBaseUri(request, 
parent.basePath)
+              if (parent.sc.get.executorsHeld) {
+                val numDraining = parent.sc.get.getExecutorIds().size
+                val status = if (numDraining > 0) {
+                  s"Held (draining $numDraining executors)"

Review Comment:
   **Finding 10.** `Held (draining 1 executors)` -- visible in the state 
diagram in the PR description too.
   
   ```suggestion
                     s"Held (draining $numDraining executor${if (numDraining > 
1) "s" else ""})"
   ```
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


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

Reply via email to