sunchao commented on code in PR #58054:
URL: https://github.com/apache/spark/pull/58054#discussion_r3808236156


##########
core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala:
##########
@@ -321,6 +326,27 @@ 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. Note
+            // that ExecutorMonitor cannot be told here: it builds its state 
asynchronously
+            // from the SparkListenerExecutorAdded event posted above, so this 
executor is not
+            // tracked there yet and its decommissioning is under-reported in 
the metrics.
+            // The monitor's own executor-removal handling covers the eventual 
exit.
+            decommissionExecutors(
+              Array((executorId, ExecutorDecommissionInfo("Executors are 
held"))),
+              adjustTargetNumExecutors = false,
+              triggeredByExecutor = false)
+            // The cluster manager granted this executor against a stale 
requirement (e.g. a
+            // restarted AM using its own initial target, or a lost response 
to an earlier
+            // request), so re-assert the zero requirement, or it would keep 
granting
+            // replacements that churn through this drain. Do not await the 
response: this
+            // runs inside the RPC handler.
+            CoarseGrainedSchedulerBackend.this.synchronized {
+              val defaultProf = 
scheduler.sc.resourceProfileManager.defaultResourceProfile
+              requestedTotalExecutorsPerResourceProfile(defaultProf) = 0
+              
doRequestTotalExecutors(requestedTotalExecutorsPerResourceProfile.toMap)

Review Comment:
   [P2] Coordinate the late-registration zero target with resume
   
   A late `RegisterExecutor` can observe `executorsHeld = true`, finish 
decommissioning, and pause before this block. Meanwhile, `resumeExecutors()` 
can clear the guard, restore a nonzero target, receive an acknowledgment, and 
clear `_executorsHeld`. This block then overwrites the restored requirement 
with zero without checking whether that hold is still current.
   
   With static Standalone allocation, the last request wins and no allocation 
loop repairs it; another `resumeExecutors()` is a no-op because the context 
already considers itself resumed. An idle dynamic-allocation application can 
also retain its positive EAM target without sending another request. A 
deterministic probe using the current method bodies reproduced target requests 
`2 -> 0 -> 2 -> 0`.
   
   Could the zero-target reassertion and resume transition share one 
ordering/lock or generation check? A regression test that pauses registration 
after the held check, completes an acknowledged resume, then releases 
registration would cover the failure.



##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2075,203 @@ 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, decommission support, 
and shuffle data
+   * kept outside the executors: either an external shuffle service or a 
`ShuffleDataIO` with
+   * reliable storage.
+   */
+  private[spark] def executorHoldSupported: Boolean = {
+    schedulerBackend.isInstanceOf[ExecutorAllocationClient] &&
+      (conf.get(SHUFFLE_SERVICE_ENABLED) || 
shuffleDriverComponents.supportsReliableStorage()) &&
+      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 outside the 
executors, so the
+   * application can later pick up where it left off via `resumeExecutors()`.
+   * Cached blocks are not preserved and are recomputed after resuming.
+   *
+   * This requires decommission support (`spark.decommission.enabled`) and 
shuffle data kept
+   * outside the executors: either an external shuffle service
+   * (`spark.shuffle.service.enabled`) or a `ShuffleDataIO` with reliable 
storage.
+   *
+   * @return whether the lowered executor requirement was acknowledged by the 
cluster manager.
+   *         With dynamic allocation a rejected request is retried in the 
background; the
+   *         executors are drained in either case.
+   */
+  @DeveloperApi
+  def holdExecutors(): Boolean = {
+    schedulerBackend match {
+      case b: ExecutorAllocationClient =>
+        require(executorHoldSupported,
+          s"holdExecutors() requires ${DECOMMISSION_ENABLED.key} and either " +
+            s"${SHUFFLE_SERVICE_ENABLED.key} or a ShuffleDataIO with reliable 
storage")
+        synchronized {
+          if (_executorsHeld) {
+            true
+          } else {
+            if (executorAllocationManager.isEmpty) {
+              // The requirement to restore on resume: prefer what the 
application actually
+              // requested (e.g. through requestExecutors), which the backend 
tracks. When it
+              // never pushed a requirement, Standalone has no explicit one by 
default (and
+              // ignores spark.executor.instances, even a leftover value), so 
restore an
+              // unbounded one; elsewhere follow the conf, or fall back to the 
cluster
+              // manager's default when no executor has registered yet.
+              val requested = b match {
+                case cg: CoarseGrainedSchedulerBackend => 
cg.requestedTotalExecutors()
+                case _ => immutable.Map.empty[ResourceProfile, Int]
+              }
+              heldNumExecutors = if (requested.nonEmpty) {
+                // Floor with the live executor count: killing an executor of 
a static
+                // application seeds the tracked requirement with a zero, 
which is a
+                // bookkeeping artifact rather than a requirement to restore.
+                math.max(
+                  
requested.getOrElse(resourceProfileManager.defaultResourceProfile, 0),
+                  b.getExecutorIds().size)

Review Comment:
   [P2] Preserve an explicitly reduced static executor target
   
   The existing [`requestTotalExecutors` 
contract](https://github.com/apache/spark/blob/7dcc4702335ef25b918f054645304317a7279c2a/core/src/main/scala/org/apache/spark/SparkContext.scala#L1962-L1968)
 permits lowering the requested total without killing running executors. For 
example, a static application can have 10 live executors but explicitly request 
a future total of 2. This `math.max` saves 10, so a hold followed by resume 
requests 10 instead of the application's explicit target of 2. An explicit zero 
target is lost in the same way.
   
   The bookkeeping-zero workaround cannot distinguish that legitimate request 
from an inferred target. Could the backend preserve whether the target was 
explicitly requested and use the live-count fallback only when it was not? A 
focused probe of the current methods reproduced the 2-to-10 restoration.



##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2075,203 @@ 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, decommission support, 
and shuffle data
+   * kept outside the executors: either an external shuffle service or a 
`ShuffleDataIO` with
+   * reliable storage.
+   */
+  private[spark] def executorHoldSupported: Boolean = {
+    schedulerBackend.isInstanceOf[ExecutorAllocationClient] &&
+      (conf.get(SHUFFLE_SERVICE_ENABLED) || 
shuffleDriverComponents.supportsReliableStorage()) &&
+      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 outside the 
executors, so the
+   * application can later pick up where it left off via `resumeExecutors()`.
+   * Cached blocks are not preserved and are recomputed after resuming.
+   *
+   * This requires decommission support (`spark.decommission.enabled`) and 
shuffle data kept
+   * outside the executors: either an external shuffle service
+   * (`spark.shuffle.service.enabled`) or a `ShuffleDataIO` with reliable 
storage.
+   *
+   * @return whether the lowered executor requirement was acknowledged by the 
cluster manager.
+   *         With dynamic allocation a rejected request is retried in the 
background; the
+   *         executors are drained in either case.
+   */
+  @DeveloperApi
+  def holdExecutors(): Boolean = {
+    schedulerBackend match {
+      case b: ExecutorAllocationClient =>
+        require(executorHoldSupported,
+          s"holdExecutors() requires ${DECOMMISSION_ENABLED.key} and either " +
+            s"${SHUFFLE_SERVICE_ENABLED.key} or a ShuffleDataIO with reliable 
storage")
+        synchronized {
+          if (_executorsHeld) {
+            true
+          } else {
+            if (executorAllocationManager.isEmpty) {
+              // The requirement to restore on resume: prefer what the 
application actually
+              // requested (e.g. through requestExecutors), which the backend 
tracks. When it
+              // never pushed a requirement, Standalone has no explicit one by 
default (and
+              // ignores spark.executor.instances, even a leftover value), so 
restore an
+              // unbounded one; elsewhere follow the conf, or fall back to the 
cluster
+              // manager's default when no executor has registered yet.
+              val requested = b match {
+                case cg: CoarseGrainedSchedulerBackend => 
cg.requestedTotalExecutors()
+                case _ => immutable.Map.empty[ResourceProfile, Int]
+              }
+              heldNumExecutors = if (requested.nonEmpty) {
+                // Floor with the live executor count: killing an executor of 
a static
+                // application seeds the tracked requirement with a zero, 
which is a
+                // bookkeeping artifact rather than a requirement to restore.
+                math.max(
+                  
requested.getOrElse(resourceProfileManager.defaultResourceProfile, 0),
+                  b.getExecutorIds().size)
+              } else {
+                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) =>
+                val acknowledged = manager.suspend()
+                drainHeldExecutors(b)
+                acknowledged
+              case None => zeroExecutorRequirementAndDrain(b)
+            }
+          }
+        }
+      case _ =>
+        logWarning("Holding executors is not supported by current scheduler.")
+        false
+    }
+  }
+
+  // Gracefully decommission all the current executors of a held application 
and let the
+  // executor monitor know, so that it does not try to remove the draining 
executors again and
+  // reports them in the decommissioning metrics.
+  private def drainHeldExecutors(b: ExecutorAllocationClient): Unit = {
+    val executors = b.getExecutorIds()
+    if (executors.nonEmpty) {
+      val decommissioned = b.decommissionExecutors(
+        executors.map(id => (id, ExecutorDecommissionInfo("Executors are 
held"))).toArray,
+        adjustTargetNumExecutors = false,
+        triggeredByExecutor = false)
+      executorAllocationManager.foreach(
+        _.executorMonitor.executorsDecommissioned(decommissioned))
+    }
+  }
+
+  // Restore the hold invariant without dynamic allocation: push the zero 
executor requirement
+  // and drain the current executors. The requirement push must not abort the 
drain, which has
+  // to run even when the cluster manager is temporarily unreachable; the 
registration guard
+  // in CoarseGrainedSchedulerBackend remains the backstop when the push fails.
+  private[spark] def zeroExecutorRequirementAndDrain(b: 
ExecutorAllocationClient): Boolean = {
+    val acknowledged = try {
+      b.requestTotalExecutors(
+        immutable.Map(resourceProfileManager.defaultResourceProfile.id -> 0),
+        immutable.Map.empty, immutable.Map.empty)

Review Comment:
   [P2] Gate graceful hold for Kubernetes controller allocators
   
   This zero-target request runs before `drainHeldExecutors` (the dynamic path 
has the same ordering through `manager.suspend()`). With 
`spark.kubernetes.allocation.pods.allocator=deployment` or `statefulset`, the 
Kubernetes backend forwards the target to an allocator that immediately scales 
its controller to zero: 
[Deployment](https://github.com/apache/spark/blob/7dcc4702335ef25b918f054645304317a7279c2a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/DeploymentPodsAllocator.scala#L113-L123),
 
[StatefulSet](https://github.com/apache/spark/blob/7dcc4702335ef25b918f054645304317a7279c2a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/scheduler/cluster/k8s/StatefulSetPodsAllocator.scala#L105-L112).
 Those applications pass `executorHoldSupported` when decommissioning and a 
reliable `ShuffleDataIO` are enabled.
   
   Scaling to zero starts deleting running executor pods. The decommission 
`preStop` hook still runs within the pod's termination grace, which Spark 
[defaults to 30 
seconds](https://github.com/apache/spark/blob/7dcc4702335ef25b918f054645304317a7279c2a/resource-managers/kubernetes/core/src/main/scala/org/apache/spark/deploy/k8s/Config.scala#L832-L838);
 Kubernetes [forcibly terminates remaining processes after the grace 
period](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination-flow).
 A sufficiently long-running task is therefore interrupted rather than allowed 
to finish, contrary to the new hold API's guarantee.
   
   Could these allocators be excluded by a capability check until there is 
controller-aware draining? The source path and termination behavior are 
verified; I have not run a Kubernetes integration reproduction.



##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2075,203 @@ 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

Review Comment:
   [P2] Initialize hold state before exposing the startup UI
   
   These field initializers execute after the main constructor initialization 
block. That block already [attaches the live UI 
handlers](https://github.com/apache/spark/blob/7dcc4702335ef25b918f054645304317a7279c2a/core/src/main/scala/org/apache/spark/SparkContext.scala#L707-L732)
 and then calls `postStartHook()`, which can wait for cluster resources. A 
supported application can therefore receive a successful hold during that wait, 
only for these later `false`/`0` assignments to erase the context's held flag 
and saved target.
   
   The backend (and EAM, when enabled) remains held, but `resumeExecutors()` 
sees `_executorsHeld == false` and returns without restoring resources. I 
reproduced the reset with Scala 2.13.18 and the PR's exact hold/resume methods. 
Please initialize these fields with the other [early context-state 
fields](https://github.com/apache/spark/blob/7dcc4702335ef25b918f054645304317a7279c2a/core/src/main/scala/org/apache/spark/SparkContext.scala#L209-L246),
 before publishing the context to the UI.



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