LuciferYang commented on code in PR #58054:
URL: https://github.com/apache/spark/pull/58054#discussion_r3812118865
##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2082,201 @@ class SparkContext(config: SparkConf) extends Logging {
}
}
+ /**
+ * Whether `holdExecutors()` is supported in the current deployment. It
requires a scheduler
+ * backend that can adjust the number of executors and can hold them,
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 match {
+ case cg: CoarseGrainedSchedulerBackend => cg.supportsExecutorHold
+ case _: ExecutorAllocationClient => true
+ case _ => false
+ }) &&
+ (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.
+ // Use the tracked requirement only when it was explicitly
requested: killing an
+ // executor of a static application seeds it with a bookkeeping
zero, which is
+ // not a requirement to restore.
+ val requested = b match {
+ case cg: CoarseGrainedSchedulerBackend if
cg.hasExplicitExecutorRequests =>
+ cg.requestedTotalExecutors()
+ case _ => immutable.Map.empty[ResourceProfile, Int]
+ }
+ heldNumExecutors = if (requested.nonEmpty) {
+
requested.getOrElse(resourceProfileManager.defaultResourceProfile, 0)
Review Comment:
`heldNumExecutors` reads what the driver has pushed rather than what the
application currently
wants, and it is wrong in three ways. With dynamic allocation off that map
starts empty (YARN and K8s
set the initial target without going through the driver) and
`requestExecutors` accumulates from
zero, so an application with `spark.executor.instances=100` that ever called
`sc.requestExecutors(3)`
is recorded as 3 and resume pushes 3 while the UI still shows Running.
`requestExecutors(0)` is legal
too, and then resume pushes zero and the application is parked at no
executors. In the other
direction the absolute zero that `killExecutors` writes does not set the
flag, so line 2150's
fallback applies: on standalone resume pushes `Int.MaxValue`, which with
`spark.cores.max` unset
takes every free core and undoes the downscale the operator just performed.
This looks like the same root cause as the executor-requirement point above:
have the backend
maintain the application's desired target, written by both public APIs and
by `adjustExecutors`, and
let resume push that. The seed differs per cluster manager, and standalone
starts unbounded
(`initialExecutorLimit` is None with dynamic allocation off), so its
`killExecutors` case has to
derive the target from the live executor count — decrementing an unbounded
value gets nowhere.
##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2082,201 @@ class SparkContext(config: SparkConf) extends Logging {
}
}
+ /**
+ * Whether `holdExecutors()` is supported in the current deployment. It
requires a scheduler
+ * backend that can adjust the number of executors and can hold them,
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 match {
+ case cg: CoarseGrainedSchedulerBackend => cg.supportsExecutorHold
+ case _: ExecutorAllocationClient => true
+ case _ => false
+ }) &&
+ (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.
+ // Use the tracked requirement only when it was explicitly
requested: killing an
+ // executor of a static application seeds it with a bookkeeping
zero, which is
+ // not a requirement to restore.
+ val requested = b match {
+ case cg: CoarseGrainedSchedulerBackend if
cg.hasExplicitExecutorRequests =>
+ cg.requestedTotalExecutors()
+ case _ => immutable.Map.empty[ResourceProfile, Int]
+ }
+ heldNumExecutors = if (requested.nonEmpty) {
+
requested.getOrElse(resourceProfileManager.defaultResourceProfile, 0)
+ } 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,
Review Comment:
With dynamic allocation off, a barrier job submitted during a hold fails
after about ten minutes
instead of waiting for the resume the way an ordinary job does.
`isExecutorActive` excludes
`executorsPendingDecommission`, so `maxNumConcurrentTasks` reads 0 as soon
as `drainHeldExecutors`
runs, without waiting for any executor to exit;
`checkBarrierStageWithNumSlots` then throws
`BarrierJobSlotsNumberCheckFailed`, and DAGScheduler re-posts every 15s and
calls `jobFailed` after 40
failures. MLlib distributed training and TorchDistributor are in scope.
Not consuming that failure count while held (or resetting it on resume)
would let these jobs wait like
any other. If touching the scheduler is not wanted, saying in
`docs/web-ui.md` that barrier jobs fail
during a hold would also do.
##########
core/src/main/scala/org/apache/spark/ui/jobs/JobsTab.scala:
##########
@@ -62,4 +66,44 @@ private[ui] class JobsTab(parent: SparkUI, store:
AppStatusStore)
}
}
}
+
+ // Serves the hold/resume requests off the Jetty serving thread: they talk
to the cluster
+ // manager and may block up to the RPC ask timeout. A single thread also
serializes
+ // concurrent requests. Created on first use and shut down by `stop()`, so
that the thread
+ // does not outlive the SparkContext.
+ private var holdRequestExecutor: Option[ExecutorService] = None
+
+ private def holdRequestExecutorPool: ExecutorService = synchronized {
+ holdRequestExecutor.getOrElse {
+ val pool =
ThreadUtils.newDaemonSingleThreadExecutor("spark-ui-hold-resume")
+ holdRequestExecutor = Some(pool)
+ pool
+ }
+ }
+
+ def stop(): Unit = synchronized {
+ holdRequestExecutor.foreach(_.shutdownNow())
+ }
+
+ def handleHoldRequest(request: HttpServletRequest): Unit = {
+ if (holdEnabled &&
parent.securityManager.checkModifyPermissions(request.getRemoteUser)) {
+ sc.filter(_.executorHoldSupported).foreach { ctx =>
+ holdRequestExecutorPool.execute(() =>
Utils.tryLogNonFatalError(ctx.holdExecutors()))
+ // Do a quick pause here to give Spark time to decommission the
executors so the
+ // application shows up as held after the refresh. Note that this will
block the
+ // serving thread so the time should be limited in duration.
+ Thread.sleep(100)
+ }
+ }
+ }
+
+ def handleResumeRequest(request: HttpServletRequest): Unit = {
+ if (holdEnabled &&
parent.securityManager.checkModifyPermissions(request.getRemoteUser)) {
+ sc.filter(_.executorHoldSupported).foreach { ctx =>
+ holdRequestExecutorPool.execute(() =>
Utils.tryLogNonFatalError(ctx.resumeExecutors()))
Review Comment:
`Utils.tryLogNonFatalError(ctx.holdExecutors())` and the resume equivalent
discard the `Boolean`, so
neither success nor failure of either operation shows up on the page and a
failure leaves only a
logWarning. There is a second layer: resume waits on the
`requestTotalExecutors` ask
(`spark.rpc.askTimeout`, 120s by default — the dynamic-allocation path
reaches the same `final
override` through `syncTargetsWithClient()`) and sets `_executorsHeld =
false` only afterwards, so the
reloaded page still reads "Held". With dynamic allocation on it can be twice
that: `EAM.resume()`
shares its monitor with the 100ms `schedule()`, which is itself blocked in
the same ask once a push
has been rejected.
`beforeRedirect` is `HttpServletRequest => Unit` and has no response to
write to, so the outcome has
to land in driver-side state the page reads — a `@volatile`
in-progress/failed state rendered next to
the Application line, for instance, which would also make the
`Thread.sleep(100)` unnecessary.
--
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]