peter-toth commented on code in PR #58054:
URL: https://github.com/apache/spark/pull/58054#discussion_r3815331354
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -2093,8 +2093,15 @@ private[spark] class DAGScheduler(
} catch {
case e: BarrierJobSlotsNumberCheckFailed =>
// If jobId doesn't exist in the map, Scala coverts its value null to
0: Int automatically.
- val numCheckFailures =
barrierJobIdToNumTasksCheckFailures.compute(jobId,
- (_: Int, value: Int) => value + 1)
+ // Do not consume the retry budget while the executors are held: the
slot check sees
+ // zero slots for the whole hold, and the job should wait for the
resume like any
+ // other job instead of failing when the retries run out.
+ val numCheckFailures = if (sc.executorsHeld) {
Review Comment:
**Finding 29.** This fix identified the right class — a consumer that reads
zero slots must wait for the resume, not fail — but there is a second consumer
of the same count, and it is terminal. Anchoring here because that's where the
pattern lives; the code in question is `rejectUnadmittablePipelinedGroup` at
`core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:1844`.
`rejectUnadmittablePipelinedGroup` computes `totalSlots =
maxConcurrentTasksForProfile(rp.id)` → `sc.maxNumConcurrentTasks` →
`CoarseGrainedSchedulerBackend.maxNumConcurrentTasks`, which filters on
`isExecutorActive`. `drainHeldExecutors` puts every executor into
`executorsPendingDecommission`, so that count is 0 from the moment the hold
starts. Your own scaladoc there is explicit that the consequence is terminal:
"pipelined admission is TERMINAL (one check, then fail), delegating
transient-shortfall retry to the caller."
`spark.scheduler.pipelinedGroup.slotCheck.enabled` defaults to `true`.
I measured both halves against `eb38c362`.
Capacity, on a real `local-cluster[1,1,1024]` with the shuffle service and
decommissioning enabled, calling `holdExecutors()` for real:
```
slots-before=1 slots-after-hold=0 executors-still-registered=1
```
So it is 0 as soon as `holdExecutors()` returns — this does not wait for the
drain to finish.
The check's response to 0, via `DAGSchedulerSuite`'s existing
`maxConcurrentTasksForTest` seam:
```
failed-immediately=true stages-submitted=0
msg=[CONCURRENT_SCHEDULER_INSUFFICIENT_SLOT] Cannot run the pipelined stage
group: it needs 4
concurrent task slots to run all its stages together, but only 0 are
currently free. ...
```
Failed outright, no stage created, no retry. Since the comparison is `demand
> freeSlots` against `freeSlots = 0`, this holds for any pipelined group size —
the 4 above is just the group I used. The one step I did not measure end to end
is the seam substitution itself, i.e. that the value the production
`maxConcurrentTasksForProfile` reads is the same 0 the first measurement shows;
that is a one-line body.
The config combination is reachable: `rejectUnsupportedPipelinedJob`
(`:1156-1174`) rejects pipelined shuffle under speculation or dynamic
allocation, so pipelined implies DA off — precisely the
`executorAllocationManager.isEmpty` path this PR spent three rounds on. Add
`spark.decommission.enabled` and an external shuffle service and you have a
supported hold. A real-time-mode streaming query held between micro-batches, so
`hasPipelinedTaskSets` is false at that instant, then dies on the next batch
instead of pausing.
The fix is the mechanism you already have here — re-post rather than fail
while held:
```scala
if (demand > freeSlots) {
if (sc.executorsHeld) {
// The executors are held, so there are no slots by construction.
Wait for the resume
// like a barrier job rather than failing: the shortfall is not
transient scarcity.
logInfo(log"Deferring job ${MDC(JOB_ID, jobId)}: the executors are
held")
messageScheduler.schedule(new Runnable {
override def run(): Unit = eventProcessLoop.post(JobSubmitted(...))
}, timeIntervalNumTasksCheck, TimeUnit.SECONDS)
return true
}
...
```
`DAGSchedulerSuite` already has four tests asserting
`CONCURRENT_SCHEDULER_INSUFFICIENT_SLOT` on this path, so one more asserting
the job is *not* failed while held is a small addition — it needs the same seam
as finding 26.
If you'd rather keep this out of scope, the honest alternative is to say so
in `docs/web-ui.md`: right now it says a hold is rejected while a pipelined job
is running, which reads as though the interaction is handled in both directions.
##########
core/src/test/scala/org/apache/spark/SparkContextSuite.scala:
##########
@@ -1578,6 +1584,97 @@ class SparkContextSuite extends SparkFunSuite with
LocalSparkContext with Eventu
assert(err.getMessage.contains("Int.MaxValue"))
assert(err.getMessage.contains("overflowed"))
}
+
+ test("SPARK-58828: holdExecutors and resumeExecutors are unsupported by the
local scheduler") {
+ sc = new SparkContext(new
SparkConf().setAppName("test").setMaster("local"))
+ assert(!sc.executorHoldSupported)
+ assert(!sc.holdExecutors())
+ assert(!sc.resumeExecutors())
+ }
+
+ test("SPARK-58828: holdExecutors requires external shuffle service and
decommission support") {
+ sc = new SparkContext(
+ new SparkConf().setAppName("test").setMaster("local-cluster[1,1,1024]"))
+ assert(!sc.executorHoldSupported)
+ val err = intercept[IllegalArgumentException] {
+ sc.holdExecutors()
+ }
+ assert(err.getMessage.contains(SHUFFLE_SERVICE_ENABLED.key))
+ assert(err.getMessage.contains(DECOMMISSION_ENABLED.key))
+ }
+
+ private def verifyHoldAndResumeExecutors(conf: SparkConf): Unit = {
+ // The executors register with the external shuffle service on startup, so
run one
+ val transportConf = SparkTransportConf.fromSparkConf(conf, "shuffle",
numUsableCores = 2)
+ val rpcHandler = new ExternalBlockHandler(transportConf, null)
+ val transportContext = new TransportContext(transportConf, rpcHandler)
+ val server = transportContext.createServer()
+ try {
+ conf.set(SHUFFLE_SERVICE_PORT, server.getPort)
+ sc = new SparkContext(conf)
+ TestUtils.waitUntilExecutorsUp(sc, 1, 60000)
+ assert(sc.executorHoldSupported)
+ assert(!sc.executorsHeld)
+
+ assert(sc.holdExecutors())
Review Comment:
**Finding 19.** No test in this PR has a task running when the hold happens,
and none produces shuffle output across one — so neither of the two guarantees
`holdExecutors()`'s scaladoc makes is exercised.
`verifyHoldAndResumeExecutors` never submits a job: it waits for the
executor, holds, waits for the drain (now with the added settle check),
resumes, waits for a new executor. `ExecutorAllocationManagerSuite` drives
`suspend()`/`resume()` against a mocked client; the
`CoarseGrainedSchedulerBackendSuite` cases register a mock endpoint or poke the
backend directly. No tasks anywhere. So "each executor finishes its running
tasks and then exits, while the shuffle data already written remains available
outside the executors" is asserted nowhere — and that sentence is why
`executorHoldSupported` demands ESS-or-reliable-storage. If that gate, or
`ExecutorDecommissionInfo`'s `workerHost = None`, ever drifted,
`handleExecutorLost` would flip `fileLost` to true
(`core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:4105`),
every map output would be unregistered on the drain, and the only symptom would
be a stage silently recomputed after resume.
I wrote the assertions below and ran them against `eb38c362` inside the
existing helper — they pass, in 5.3 s:
```
map-outputs-before=4 map-outputs-after=4
stages-submitted-by-second-action=1 ids=[3]
```
So the guarantee does hold today; the ask is to pin it, not to fix it. The
single stage id is the part that matters — the map stage really is skipped
rather than recomputed, so the oracle discriminates.
```scala
val shuffled = sc.parallelize(1 to 100, 4).map(i => (i % 8,
i)).reduceByKey(_ + _)
assert(shuffled.count() === 8)
val shuffleId =
shuffled.dependencies.head.asInstanceOf[ShuffleDependency[_, _, _]].shuffleId
val tracker =
sc.env.mapOutputTracker.asInstanceOf[MapOutputTrackerMaster]
assert(tracker.getNumAvailableOutputs(shuffleId) === 4)
assert(sc.holdExecutors())
...
assert(sc.resumeExecutors())
...
// The map output written before the hold survived the drain: only the
reduce stage re-runs
val submitted = new ConcurrentLinkedQueue[Int]()
sc.addSparkListener(new SparkListener {
override def onStageSubmitted(e: SparkListenerStageSubmitted): Unit =
submitted.add(e.stageInfo.stageId)
})
assert(shuffled.collect().length === 8)
sc.listenerBus.waitUntilEmpty()
assert(submitted.size() === 1, s"expected only the reduce stage, got
$submitted")
assert(tracker.getNumAvailableOutputs(shuffleId) === 4)
```
(`SparkListenerStageSubmitted` needs adding to the existing
`org.apache.spark.scheduler` import.) Assert the map output *after* the second
job rather than right after the drain: `handleExecutorLost` runs asynchronously
on the DAGScheduler event loop, so an assertion straight after
`getExecutorIds().isEmpty` can pass before the removal has been processed.
For the running-tasks half, `WorkerDecommissionSuite`'s `test("verify a
running task with all workers decommissioned succeeds")` is the shape — a slow
job started before the hold, asserted to complete. That one also happens to be
the only thing that would catch finding 32.
##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2082,213 @@ 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 _ => 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.
+ *
+ * Executor requirements requested while held, through `requestExecutors` or
+ * `requestTotalExecutors`, are recorded but nothing is allocated until
`resumeExecutors()`
+ * restores them.
+ *
+ * The hold is rejected, on a best-effort check, while a pipelined job is
running: its
+ * transient shuffle data lives only on the executors and would not survive
the drain.
+ *
+ * @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 cg: CoarseGrainedSchedulerBackend if cg.supportsExecutorHold =>
+ require(executorHoldSupported,
+ s"holdExecutors() requires ${DECOMMISSION_ENABLED.key} and either " +
+ s"${SHUFFLE_SERVICE_ENABLED.key} or a ShuffleDataIO with reliable
storage")
+ val pipelinedRunning = taskScheduler match {
Review Comment:
**Finding 30.** Two things about this gate: it can be defeated, and the
scaladoc's "best-effort" undersells what losing the race costs.
**The window is wider than it looks.** `hasPipelinedTaskSets` reads
`taskSetsByStageIdAndAttempt`, so it only sees groups whose task sets have
already reached the `TaskSchedulerImpl`. A pipelined job that has passed
`submitJob` but whose stages are still being created on the DAGScheduler event
loop is invisible here, and the check is evaluated *outside* `synchronized`, so
`submitTasks` can also land between the check and `drainHeldExecutors`.
**What losing it costs.** `TaskSetManager.effectiveMaxTaskFailures` is `if
(taskSet.isPipelined) 1 else maxTaskFailures`
(`core/src/main/scala/org/apache/spark/scheduler/TaskSetManager.scala:80`), so
a single task lost to the decommission aborts the whole group — not a slow
drain, a failed user job. Worth saying that in the scaladoc even if the race
stays.
Two fixes, and they close different halves:
- For the narrow window, move the check next to the drain under one
acquisition of the scheduler monitor — `decommissionExecutors` already takes it
via `withLock`, so a `TaskSchedulerImpl` helper along the lines of `def
decommissionAllUnlessPipelined(...): Option[Seq[String]] = synchronized { if
(hasPipelinedTaskSets) None else Some(...) }` makes it atomic. Do **not** widen
that lock to cover `cg.republishRequestedTotals()`, which blocks in
`awaitResult` for up to `spark.rpc.askTimeout`.
- For the wide one, ask the DAGScheduler instead of the TaskScheduler:
`jobIdToActiveJob.values.exists(_.hasPipelinedDependency)` is the authoritative
"a pipelined job is live" set and is populated at job submission
(`DAGScheduler.scala:2148`), so it covers the pre-taskset window that
`hasPipelinedTaskSets` cannot.
If you fix finding 29 as suggested, the submit-side gate closes both halves
on its own and this check becomes belt-and-braces.
##########
core/src/main/scala/org/apache/spark/scheduler/DAGScheduler.scala:
##########
@@ -2093,8 +2093,15 @@ private[spark] class DAGScheduler(
} catch {
case e: BarrierJobSlotsNumberCheckFailed =>
// If jobId doesn't exist in the map, Scala coverts its value null to
0: Int automatically.
- val numCheckFailures =
barrierJobIdToNumTasksCheckFailures.compute(jobId,
- (_: Int, value: Int) => value + 1)
+ // Do not consume the retry budget while the executors are held: the
slot check sees
+ // zero slots for the whole hold, and the job should wait for the
resume like any
+ // other job instead of failing when the retries run out.
+ val numCheckFailures = if (sc.executorsHeld) {
Review Comment:
**Finding 26.** Three branches were added across this PR to satisfy review
findings, and none of them has a test. This one is also untestable as written,
which is the part worth fixing first.
`sc.executorsHeld` can only become true through `holdExecutors()`, which
needs a `CoarseGrainedSchedulerBackend`. `DAGSchedulerSuite` and
`BarrierStageOnSubmittedSuite` both run on `local[…]`, whose
`LocalSchedulerBackend` isn't one, so `sc.executorsHeld` is unreachably `false`
there and no assertion can distinguish this branch from the old code.
This file already solved exactly this problem, one screen up, and documents
why: `protected def maxConcurrentTasksForProfile` and
`outstandingTasksForOtherWork` are "Extracted as a seam so tests can control it
without changing the cluster's core count". The same idiom applies:
```scala
/** Extracted as a seam so tests can control the hold state. */
protected def executorsHeld: Boolean = sc.executorsHeld
```
read here and in `rejectUnadmittablePipelinedGroup`, after which
`BarrierStageOnSubmittedSuite`'s "requires more slots than the total number of
slots" tests are the template for asserting the retry budget is not consumed —
and finding 29 becomes testable by the same seam. (I used the two existing
seams to measure finding 29, which is how I know the pattern carries.)
The other two are cheap against existing precedents:
- `KubernetesClusterSchedulerBackend.supportsExecutorHold` — the one place
the guard can be wrong per allocator value — has no test.
`KubernetesClusterSchedulerBackendSuite` already builds a backend from a
`SparkConf`; three lines per allocator value.
- The UI gate has none either, and `UISeleniumSuite` has both halves ready:
`"spark.ui.killEnabled should properly control kill button display"` for the
link and the `/jobs/job/kill/?id=0` GET-response test for the endpoint. Its
`newSparkContext` takes a `killEnabled` flag, so a `holdEnabled` twin is
mechanical — with `local-cluster`, since the control doesn't render under
`local`.
##########
core/src/main/scala/org/apache/spark/ui/jobs/JobsTab.scala:
##########
@@ -62,4 +66,85 @@ 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 var stopped = false
+
+ // None once stopped, so that a request served during teardown neither hits
a rejected
+ // execution on the shut-down pool nor recreates it and leaks the thread.
+ private def holdRequestExecutorPool: Option[ExecutorService] = synchronized {
+ if (stopped) {
+ None
+ } else {
+ Some(holdRequestExecutor.getOrElse {
+ val pool =
ThreadUtils.newDaemonSingleThreadExecutor("spark-ui-hold-resume")
+ holdRequestExecutor = Some(pool)
+ pool
+ })
+ }
+ }
+
+ def stop(): Unit = synchronized {
+ stopped = true
+ holdRequestExecutor.foreach(_.shutdownNow())
+ }
+
+ // Outcome of the last hold/resume request served by this tab, rendered by
AllJobsPage next
+ // to the Application line: Some(message) while a request is running or
after it was not
+ // acknowledged, None when idle or after a success.
+ @volatile private var holdRequestStatus: Option[String] = None
+
+ private[jobs] def lastHoldRequestStatus: Option[String] = holdRequestStatus
+
+ def handleHoldRequest(request: HttpServletRequest): Unit = {
+ if (holdEnabled &&
parent.securityManager.checkModifyPermissions(request.getRemoteUser)) {
+ sc.filter(_.executorHoldSupported).foreach { ctx =>
+ holdRequestExecutorPool.foreach { pool =>
+ holdRequestStatus = Some("hold requested")
Review Comment:
**Finding 28.** Two things, both visible on the page.
One field carries both operations, and a failure message is only cleared by
a later *success* — so "the last hold request was not acknowledged, see the
driver logs" renders next to the **(resume)** link after the hold has in fact
taken effect, and survives every page load until someone resumes successfully.
Tagging it fixes both:
```scala
// (isHold, message); AllJobsPage renders it only next to the matching
control
@volatile private var holdRequestStatus: Option[(Boolean, String)] = None
```
Second, "not acknowledged" is now wrong for two of the three ways
`holdExecutors()` returns `false`. The pipelined rejection
(`SparkContext.scala:2141`) and the unsupported-backend branch never ask the
cluster manager at all, and with dynamic allocation the scaladoc says a
rejected push "is retried in the background; the executors are drained in
either case" — so the one case where the message is accurate is the static
rejected publish. Distinguishing them needs more than a boolean out of
`holdExecutors()`; the cheap version is to soften the wording to "the last hold
request did not take effect, see the driver logs", and the better one is to
have `holdExecutors()` report *why* it declined so the page can say "a
pipelined job is running".
##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2082,213 @@ 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 _ => 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.
+ *
+ * Executor requirements requested while held, through `requestExecutors` or
Review Comment:
**Finding 23.** This scaladoc is now the accurate description of the feature
and the PR body is three commits behind it. Four specifics, all of which a
reader would act on:
1. "Holding stops requesting new executors (via
`ExecutorAllocationManager.suspend()` when dynamic allocation is on, or
**`requestTotalExecutors(0)`** otherwise)" — it no longer does that, and the
difference is the point of `8bfca77`: `publishTotals()` publishes zeros
*without recording them*, which is what lets resume restore the pre-hold
requirement.
2. The paragraph added here — requirements requested during a hold are
retained and applied on resume — is new user-visible behavior for
`sc.requestExecutors` / `sc.requestTotalExecutors` and isn't mentioned.
3. The pipelined rejection isn't mentioned (it is in `web-ui.md` and in the
scaladoc two paragraphs down).
4. Barrier jobs no longer consume their retry budget while held — a
`DAGScheduler` behavior change that isn't in the body either, and the
description's file list gives no hint that `DAGScheduler` and
`TaskSchedulerImpl` are touched at all.
This is the third round where a fix changed the contract and the body didn't
follow (11 and 17 were the same shape), so a pass over the whole description
before merge is probably cheaper than another round of these.
##########
docs/web-ui.md:
##########
@@ -70,6 +70,16 @@ The information displayed at the top of the page includes:
The current user, application start time, and total uptime are shown in the
footer at the
bottom of every page.
+When the application can be held, the summary shows an **Application** line
with a **(hold)**
+link; clicking it stops requesting new executors and gracefully decommissions
the running ones,
+so each finishes its tasks and then exits. The line then reads `Held` with a
**(resume)** link
Review Comment:
**Finding 32.** This sentence has an unnamed precondition.
`drainHeldExecutors` → `decommissionExecutors` reaches the force-kill block at
`core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala:634`,
so when `spark.executor.decommission.forceKillTimeout` is set, executors still
running tasks are killed `cleanupInterval` seconds into the hold —
`killExecutors(stragglers, false, false, force = true)`. That directly
contradicts three pieces of prose:
- this line: "so each finishes its tasks and then exits"
- the `holdExecutors()` scaladoc: "Each executor finishes its running tasks
and then exits"
- the PR description: "Executors are drained gracefully with no loss of
in-progress task work"
The config has no default, so most clusters are fine — but it exists
precisely for operators who don't want unbounded drains, which is the same
audience that wants a hold. On those clusters a hold silently loses in-progress
task work, and for a pipelined group it aborts the whole group
(`effectiveMaxTaskFailures = 1`, see finding 30).
Either name it here and in the scaladoc — "unless
`spark.executor.decommission.forceKillTimeout` is set, in which case executors
still running tasks are killed after that timeout" — or give
`decommissionExecutors` a way to skip the force-kill schedule for a hold-driven
drain, since a hold has no deadline of its own to protect.
##########
core/src/main/scala/org/apache/spark/internal/config/UI.scala:
##########
@@ -92,6 +92,18 @@ private[spark] object UI {
.booleanConf
.createWithDefault(true)
+ val UI_HOLD_ENABLED = ConfigBuilder("spark.ui.holdEnabled")
+ .doc("Allows the whole application to be held and resumed from the web UI.
Holding " +
+ "gracefully decommissions all executors and stops requesting new ones.
Cached blocks " +
+ "are not preserved and are recomputed after resuming. This takes effect
only when " +
Review Comment:
**Finding 20.** `executorHoldSupported` is a conjunction of three things;
every user-facing description of it names two.
The missing one is the backend: a `CoarseGrainedSchedulerBackend` whose
`supportsExecutorHold` is true. So a user on `local[*]`, or on Kubernetes with
`spark.kubernetes.allocation.pods.allocator` set to `statefulset`, `deployment`
or a custom class, sets both documented configs, sees no `(hold)` line at all,
and has nothing to tell them why. Four sites say it the same incomplete way —
here, `docs/configuration.md:1614`, `docs/web-ui.md:79` and the PR description.
The `holdExecutors()` and `executorHoldSupported` scaladocs *do* get it right,
which is what makes the omission a consistent one rather than a slip. Appending
something like "…and a cluster manager that can hold executors (not local mode;
on Kubernetes only with `spark.kubernetes.allocation.pods.allocator=direct`)"
to each covers it.
Second, smaller point at the same anchor: the two unmet-precondition paths
behave differently and neither is documented. `holdExecutors()` now matches
`case cg: CoarseGrainedSchedulerBackend if cg.supportsExecutorHold`, so an
unsupported *backend* falls to `case _` and returns `false` with a warning,
while an unmet `spark.decommission.enabled` or shuffle-storage condition hits
`require(...)` and throws `IllegalArgumentException`. `SparkContextSuite:1595`
pins the throw, so it's deliberate — and `killExecutors` uses `require` too —
but the scaladoc promises only `@return whether the lowered executor
requirement was acknowledged`, and the description's "It is rejected otherwise"
reads as a `false` return. An `@throws` clause would settle it.
Worth mentioning while you're in this doc: it's the natural place to record
that fallback storage (`spark.storage.decommission.fallbackStorage.path`)
deliberately does *not* qualify. It does migrate shuffle blocks, so a user with
it set will expect to be covered, but `handleExecutorLost` computes `fileLost =
true` for that configuration, so anything not yet migrated when the executor
exits is dropped — a weaker guarantee than the scaladoc's, and a good reason to
exclude it. Saying so beats leaving it implicit.
##########
core/src/main/scala/org/apache/spark/scheduler/cluster/CoarseGrainedSchedulerBackend.scala:
##########
@@ -739,6 +778,95 @@ class CoarseGrainedSchedulerBackend(scheduler:
TaskSchedulerImpl, val rpcEnv: Rp
executorDataMap.keySet.toSeq
}
+ /** See `SparkContext.holdExecutors()`. */
+ private[spark] def setExecutorsHeld(held: Boolean): Unit = {
+ executorsHeld = held
+ }
+
+ /**
+ * Whether this backend can hold the executors gracefully. Overridden by
backends whose
+ * cluster manager would terminate running executors on a zero requirement.
+ */
+ private[spark] def supportsExecutorHold: Boolean = true
Review Comment:
**Finding 31.** The default makes an unknown backend opt *in*, and the
scaladoc scopes the exception too narrowly — "backends whose cluster manager
would terminate running executors on a zero requirement". There is a second way
to be unable to hold: not being able to honour a total at all.
`doRequestTotalExecutors` in this class returns `Future.successful(false)`
(`:1050`). A backend supplied through the `ExternalClusterManager` SPI that
extends `CoarseGrainedSchedulerBackend` without overriding it inherits
`supportsExecutorHold = true`. With `spark.decommission.enabled` and an
external shuffle service on — and `spark.ui.holdEnabled` defaulting to `true` —
the `(hold)` link renders. The click then *works*, because `drainHeldExecutors`
goes through `decommissionExecutors`, which never touches
`doRequestTotalExecutors`. `resumeExecutors()` afterwards gets `false` from
every publish, so it takes the failure path, re-arms the guard and re-drains —
forever. The application is held with no way out short of a restart.
Nothing in the tree hits this: all three subclasses override
`doRequestTotalExecutors`. It's the polarity of a new extension point that I'd
change, so the default is safe rather than convenient:
```scala
private[spark] def supportsExecutorHold: Boolean = false
```
with `override def supportsExecutorHold: Boolean = true` in
`StandaloneSchedulerBackend` and `YarnSchedulerBackend`, and the existing
allocator check kept in `KubernetesClusterSchedulerBackend`. Honest
counter-argument: three overrides instead of one, and the current denylist is
correct for everything in-tree. But the K8s comment in the same commit already
reaches for opt-in reasoning for pod allocators ("and unknown custom ones"), so
the two guards would end up consistent — and the failure mode here is worse
than the one the K8s guard prevents, because it isn't recoverable in-process.
##########
docs/web-ui.md:
##########
@@ -70,6 +70,16 @@ The information displayed at the top of the page includes:
The current user, application start time, and total uptime are shown in the
footer at the
bottom of every page.
+When the application can be held, the summary shows an **Application** line
with a **(hold)**
+link; clicking it stops requesting new executors and gracefully decommissions
the running ones,
+so each finishes its tasks and then exits. The line then reads `Held` with a
**(resume)** link
+that restores the executor requirement. Shuffle output written before the hold
stays available,
+but cached blocks are recomputed after resuming. A hold requested while a
pipelined job is
Review Comment:
**Finding 25.** One more consequence of a hold that outlives it, and it's
the kind users will misdiagnose.
`CoarseGrainedSchedulerBackend.defaultParallelism()` is
`conf.getInt(spark.default.parallelism, math.max(totalCoreCount.get(), 2))`,
and `totalCoreCount` is decremented as each executor is removed (`:515`). Once
the drain finishes it is 0, so with `spark.default.parallelism` unset — the
default — `sc.defaultParallelism` is **2** for the duration of the hold.
That is not transient. Any RDD created while held — `sc.parallelize(seq)`,
`sc.textFile(path)`, a shuffle sized from `defaultParallelism` — is fixed at 2
partitions and keeps it after resume. A driver that keeps building work during
a hold comes back with permanently under-partitioned RDDs, no error, and
nothing pointing at the hold.
Dynamic-allocation users scaled to zero already have this, so the mechanism
is pre-existing; what's new is that any operator can trigger it deliberately,
from a button. A sentence next to the cached-blocks one is enough: "While held
the application has no executors, so `spark.default.parallelism` falls back to
2; RDDs created during a hold keep that partition count after resuming."
##########
core/src/main/scala/org/apache/spark/SparkContext.scala:
##########
@@ -2075,6 +2082,213 @@ 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 _ => 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.
+ *
+ * Executor requirements requested while held, through `requestExecutors` or
+ * `requestTotalExecutors`, are recorded but nothing is allocated until
`resumeExecutors()`
+ * restores them.
+ *
+ * The hold is rejected, on a best-effort check, while a pipelined job is
running: its
+ * transient shuffle data lives only on the executors and would not survive
the drain.
+ *
+ * @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 cg: CoarseGrainedSchedulerBackend if cg.supportsExecutorHold =>
+ require(executorHoldSupported,
+ s"holdExecutors() requires ${DECOMMISSION_ENABLED.key} and either " +
+ s"${SHUFFLE_SERVICE_ENABLED.key} or a ShuffleDataIO with reliable
storage")
+ val pipelinedRunning = taskScheduler match {
+ case ts: TaskSchedulerImpl => ts.hasPipelinedTaskSets
+ case _ => false
+ }
+ if (pipelinedRunning) {
+ // A pipelined group reads and writes transient shuffle data that
lives only on
+ // its executors: a partially launched group would deadlock the
drain, and a
+ // force-killed member aborts the whole group.
+ logWarning(log"Cannot hold the executors while a pipelined job is
running.")
+ false
+ } else synchronized {
+ if (_executorsHeld) {
+ // A repeated hold re-asserts the zero requirement: the earlier
publish may not
+ // have been acknowledged, and with dynamic allocation off nothing
retries it.
+ if (executorAllocationManager.isDefined) true else
zeroExecutorRequirementAndDrain(cg)
+ } else {
+ if (executorAllocationManager.isEmpty) {
+ // The requirement to restore on resume when none was explicitly
requested
+ // (explicitly requested totals, made before or during the hold,
are
+ // republished from the backend directly). Only killExecutors'
bookkeeping
+ // zero is kill-seeded (read atomically against a concurrent
reset): restore
+ // the count of executors not already being removed, so that
resume neither
+ // parks the application at zero nor undoes the downscale.
Otherwise
+ // Standalone has no explicit requirement 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.
+ heldNumExecutors = if (cg.hasKillSeededTotalsOnly) {
+ cg.activeExecutorCount
+ } else {
+ schedulerBackend match {
+ case _: StandaloneSchedulerBackend => Int.MaxValue
Review Comment:
**Finding 27.** The sentinel leaks into the request-time bookkeeping and
never leaves.
On a standalone resume, `requestTotalExecutors(Map(default ->
Int.MaxValue))` runs `updateExecRequestTimes`, whose delta is `Int.MaxValue -
0`, so `updateExecRequestTime` pushes `(Int.MaxValue, now)` onto
`execRequestTimes`. The `RegisterExecutor` handler dequeues the head and
re-prepends `(count - 1, sameTs)` while `count > 1`
(`CoarseGrainedSchedulerBackend.scala:298-312`), so that one entry outlives
every executor: from then on each registration records the resume timestamp as
its request time, which reaches the event log as `"Request Time"`
(`core/src/main/scala/org/apache/spark/util/JsonProtocol.scala:815`).
Only history data is affected, hence Minor. Simplest is to skip the
bookkeeping for the unbounded sentinel; tidier is to stop routing
`Int.MaxValue` through `requestTotalExecutors` at all — on standalone "restore
the unbounded default" is really "forget the requirement", which
`requestedTotalExecutorsPerResourceProfile.clear()` plus a publish says without
a magic number, and would make the saturating add at `:946` defensive rather
than required.
--
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]