This is an automated email from the ASF dual-hosted git repository.
dongjoon-hyun pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 3e4995134437 [SPARK-59916][CORE] Support `hold` and `resume` in REST
Submission API
3e4995134437 is described below
commit 3e4995134437f1931b54b309bfcf4a2983c58650
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Sep 30 21:59:17 2026 -0700
[SPARK-59916][CORE] Support `hold` and `resume` in REST Submission API
### What changes were proposed in this pull request?
This PR adds `hold` and `resume` actions to the REST Submission API of the
Spark Standalone Master:
- `POST /v1/submissions/hold/<app-id>`
- `POST /v1/submissions/resume/<app-id>`
They do what the Master web UI's **Hold** and **Resume** buttons
(SPARK-59061) do, for scripts:
- https://github.com/apache/spark/pull/58361
Unlike `kill` and `status`, these actions take an application ID rather
than a submission ID, so they also work for applications submitted in client
mode. This is the first REST Submission API action keyed by an application ID.
A submission ID cannot be used here: the Master does not link a driver to the
application it registers, and a client-mode application has no submission ID.
```mermaid
sequenceDiagram
participant R as REST client
participant S as StandaloneRestServer
participant M as Master
participant C as StandaloneAppClient (driver)
R->>S: POST /v1/submissions/hold/<app-id>
S->>M: RequestApplicationHold (ask)
Note over M: check the Master's and the
application's<br/>spark.ui.holdEnabled, and the reported hold support
M->>C: SetApplicationHold (ask, not awaited, unchanged)
M-->>S: ApplicationHoldResponse(success, message)
S-->>R: HoldApplicationResponse (JSON)
C-->>M: ApplicationHoldUpdated (held/draining in /json/)
```
- `RequestApplicationHold` can now be asked as well as sent. When it is
asked, the Master replies with a new `ApplicationHoldResponse(success,
message)`. The Master web UI keeps `send`ing it, so the UI behaves as before.
Like `kill`, a Master that is not `ALIVE` rejects the request.
- The shared `Master.handleApplicationHold` now returns that response. It
rejects a request when either the Master or the application disables
`spark.ui.holdEnabled`, when the application has not reported that it can be
held, or when the application is unknown or finished. Otherwise it forwards the
request to the driver without waiting for the driver's answer, as before.
- Like `kill`, the response only tells whether the request was forwarded to
the driver, not its outcome. The driver drains its executors asynchronously,
and the outcome shows up in the `held` and `draining` fields of the Master's
`/json/` endpoint.
- No new configuration. The Master-wide `spark.ui.holdEnabled` gates both
the web UI controls and these actions.
- On the REST side, `HoldRequestServlet` and `StandaloneHoldRequestServlet`
relay the request to the Master the way the `kill` servlets do. A new
`HoldApplicationResponse` message carries `appId`, `success`, and `message`.
```bash
$ curl -XPOST http://IP:PORT/v1/submissions/hold/app-20260930120000-0000
{
"action" : "HoldApplicationResponse",
"appId" : "app-20260930120000-0000",
"message" : "Requesting application app-20260930120000-0000 to hold.",
"serverSparkVersion" : "4.4.0",
"success" : true
}
```
The docs add the two actions to the REST API table and describe them in
**Held Applications** next to the scripted web UI requests documented by
SPARK-59915:
- https://github.com/apache/spark/pull/59176
### Why are the changes needed?
Since SPARK-59353, the Master web UI's hold and resume endpoints require
the per-UI CSRF token, so a script has to read the token from the page before
sending the request:
- https://github.com/apache/spark/pull/58640
The REST Submission API is the Master's API for scripts and already offers
`kill`, `killall`, and `clear`. Holding is the graceful alternative to killing:
the application gives back its executors but keeps its driver and the shuffle
output already written. It belongs in the same API, without the token round
trip. These actions also reach client-mode applications, which the existing
REST `kill` cannot, because `kill` takes a submission ID.
### Does this PR introduce _any_ user-facing change?
Yes. The Standalone Master REST Submission API gets the new `hold` and
`resume` actions. Since hold and resume are new in the unreleased Apache Spark
4.4.0, there is no behavior change compared to the released versions.
Within the unreleased branches, the Master now rejects a hold request for
an application that has not reported that it can be held, instead of forwarding
it to the driver. The Master web UI never offers the controls for such an
application, so the UI is unaffected.
Like the other REST actions, `hold` and `resume` check no ACLs. The docs
recommend protecting the REST API with `JWSFilter` or disabling it with
`spark.master.rest.enabled=false`.
### How was this patch tested?
Pass the CIs with the newly added test cases:
- `StandaloneRestSubmitSuite`: `SPARK-59916: hold and resume an application`
- Both actions relay the Master's reply together with the application ID.
- A request without an application ID is rejected with 400.
- `AppClientSuite`: `SPARK-59916: hold and resume an application through an
ask to the Master`
- A real Master rejects the request, without reaching the driver, for an
application that has not reported that it can be held and for an unknown
application.
- Once the application reports that it can be held, the Master accepts
both requests and both reach the driver.
- `MasterSuite`: `SPARK-59916: Reject the hold and resume requests when the
Master disables holding`
The existing `SPARK-59061: hold and resume an application from the Master`
test now reports the hold support first, since the Master requires it. The JSON
in the documentation example is the actual output of `HoldApplicationResponse`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5.5
Closes #59178 from dongjoon-hyun/SPARK-59916.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
---
.../org/apache/spark/deploy/DeployMessage.scala | 4 ++
.../org/apache/spark/deploy/master/Master.scala | 55 +++++++++++++++++-----
.../spark/deploy/rest/RestSubmissionServer.scala | 35 +++++++++++++-
.../spark/deploy/rest/StandaloneRestServer.scala | 30 ++++++++++++
.../deploy/rest/SubmitRestProtocolResponse.scala | 12 +++++
.../org/apache/spark/internal/config/UI.scala | 3 +-
.../spark/deploy/client/AppClientSuite.scala | 49 ++++++++++++++++++-
.../apache/spark/deploy/master/MasterSuite.scala | 11 +++++
.../deploy/rest/StandaloneRestSubmitSuite.scala | 39 +++++++++++++++
docs/configuration.md | 4 +-
docs/spark-standalone.md | 36 ++++++++++++++
11 files changed, 260 insertions(+), 18 deletions(-)
diff --git a/core/src/main/scala/org/apache/spark/deploy/DeployMessage.scala
b/core/src/main/scala/org/apache/spark/deploy/DeployMessage.scala
index 28001f06d46f..1d6657b57f7e 100644
--- a/core/src/main/scala/org/apache/spark/deploy/DeployMessage.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/DeployMessage.scala
@@ -294,6 +294,10 @@ private[deploy] object DeployMessages {
def restUri: Option[String] = restPort.map { p => "spark://" + host + ":"
+ p }
}
+ // Reply to RequestApplicationHold when it is asked, as the REST server
does, telling whether
+ // the request was forwarded to the driver.
+ case class ApplicationHoldResponse(success: Boolean, message: String)
+
// WorkerWebUI to Worker
case object RequestWorkerState
diff --git a/core/src/main/scala/org/apache/spark/deploy/master/Master.scala
b/core/src/main/scala/org/apache/spark/deploy/master/Master.scala
index 1c3188b91407..58ae3fd8529e 100644
--- a/core/src/main/scala/org/apache/spark/deploy/master/Master.scala
+++ b/core/src/main/scala/org/apache/spark/deploy/master/Master.scala
@@ -34,6 +34,7 @@ import org.apache.spark.deploy.master.ui.MasterWebUI
import org.apache.spark.deploy.rest.StandaloneRestServer
import org.apache.spark.internal.Logging
import org.apache.spark.internal.LogKeys
+import org.apache.spark.internal.MessageWithContext
import org.apache.spark.internal.config._
import org.apache.spark.internal.config.Deploy._
import org.apache.spark.internal.config.Deploy.WorkerSelectionPolicy._
@@ -129,6 +130,8 @@ private[deploy] class Master(
val historyServerUrl = conf.get(MASTER_UI_HISTORY_SERVER_URL)
val useAppNameAsAppId = conf.get(MASTER_USE_APP_NAME_AS_APP_ID)
val useDriverIdAsAppName = conf.get(MASTER_USE_DRIVER_ID_AS_APP_NAME)
+ // Whether applications can be held through this Master, from its web UI or
REST server
+ private val holdEnabled = conf.get(UI_HOLD_ENABLED)
// Alternative application submission gateway that is stable across Spark
versions
private val restServerEnabled = conf.get(MASTER_REST_SERVER_ENABLED)
@@ -534,6 +537,15 @@ private[deploy] class Master(
val formattedExecutorIds = formatExecutorIds(executorIds)
context.reply(handleKillExecutors(appId, formattedExecutorIds))
+ case RequestApplicationHold(appId, hold) =>
+ if (state != RecoveryState.ALIVE) {
+ val msg = s"${Utils.BACKUP_STANDALONE_MASTER_PREFIX}: $state. " +
+ "Can only hold or resume applications in ALIVE state."
+ context.reply(ApplicationHoldResponse(success = false, msg))
+ } else {
+ context.reply(handleApplicationHold(appId, hold))
+ }
+
case DecommissionWorkersOnHosts(hostnames) =>
if (state != RecoveryState.STANDBY) {
context.reply(decommissionWorkersOnHosts(hostnames))
@@ -1235,25 +1247,41 @@ private[deploy] class Master(
}
/**
- * Handle a hold or resume request made from the Master UI by forwarding it
to the driver,
- * which owns the hold: see `SparkContext.holdExecutors()`.
+ * Handle a hold or resume request made from the Master UI or the REST
server by forwarding it
+ * to the driver, which owns the hold: see `SparkContext.holdExecutors()`.
The request is
+ * rejected unless both this Master and the application enable
`spark.ui.holdEnabled`, and the
+ * application reported that it can be held.
*
- * The driver drains its executors before answering, so the ask is not
waited on here -- that
- * would block the dispatcher. The resulting state arrives separately as
- * [[ApplicationHoldUpdated]] and the UI renders that; like the kill links,
the request itself
- * gets no UI feedback, so the outcome is only logged.
+ * The driver asks this Master to lower its executor requirement before
answering, so the ask
+ * is not waited on here -- that would block the dispatcher. The resulting
state arrives
+ * separately as [[ApplicationHoldUpdated]] and the UI renders that; like
the kill links, the
+ * request itself gets no UI feedback, so the outcome is only logged. The
returned response,
+ * which the REST server relays, tells only whether the request was
forwarded.
*/
- private def handleApplicationHold(appId: String, hold: Boolean): Unit = {
+ private def handleApplicationHold(appId: String, hold: Boolean):
ApplicationHoldResponse = {
val action = if (hold) "hold" else "resume"
+ def reject(msg: MessageWithContext): ApplicationHoldResponse = {
+ logWarning(msg)
+ ApplicationHoldResponse(success = false, msg.message)
+ }
idToApp.get(appId) match {
+ case _ if !holdEnabled =>
+ // The Master UI offers no control then, but the REST server still
takes requests.
+ reject(log"Ignoring the ${MDC(LogKeys.OPERATION, action)} request for
application " +
+ log"${MDC(LogKeys.APP_ID, appId)}: ${MDC(LogKeys.CONFIG,
UI_HOLD_ENABLED.key)} is " +
+ log"disabled on the Master.")
case Some(app) if !app.desc.holdEnabled =>
- // The UI offers no control for such an application, so this is a
stale or hand-crafted
- // request. The driver would reject it too; answer here without the
round trip.
- logWarning(log"Ignoring the ${MDC(LogKeys.OPERATION, action)} request
for application " +
+ // The driver would reject it too; answer here without the round trip.
+ reject(log"Ignoring the ${MDC(LogKeys.OPERATION, action)} request for
application " +
log"${MDC(LogKeys.APP_ID, appId)}, which disabled holding.")
+ case Some(app) if !app.isFinished && !app.holdSupported =>
+ // Act only once the driver reports that it can be held, as the UI
controls do.
+ reject(log"Ignoring the ${MDC(LogKeys.OPERATION, action)} request for
application " +
+ log"${MDC(LogKeys.APP_ID, appId)}, which has not reported that it
can be held.")
case Some(app) if !app.isFinished =>
- logInfo(log"Requesting application ${MDC(LogKeys.APP_ID, appId)} to " +
- log"${MDC(LogKeys.OPERATION, action)}.")
+ val msg = log"Requesting application ${MDC(LogKeys.APP_ID, appId)} to
" +
+ log"${MDC(LogKeys.OPERATION, action)}."
+ logInfo(msg)
app.driver.ask[Boolean](SetApplicationHold(hold)).onComplete {
case Success(acknowledged) =>
if (!acknowledged) {
@@ -1264,8 +1292,9 @@ private[deploy] class Master(
logWarning(log"Failed to ${MDC(LogKeys.OPERATION, action)}
application " +
log"${MDC(LogKeys.APP_ID, appId)}", t)
}(ThreadUtils.sameThread)
+ ApplicationHoldResponse(success = true, msg.message)
case _ =>
- logWarning(log"Ignoring the ${MDC(LogKeys.OPERATION, action)} request
for unknown or " +
+ reject(log"Ignoring the ${MDC(LogKeys.OPERATION, action)} request for
unknown or " +
log"finished application ${MDC(LogKeys.APP_ID, appId)}.")
}
}
diff --git
a/core/src/main/scala/org/apache/spark/deploy/rest/RestSubmissionServer.scala
b/core/src/main/scala/org/apache/spark/deploy/rest/RestSubmissionServer.scala
index 2957be2c4372..88d98d0236a6 100644
---
a/core/src/main/scala/org/apache/spark/deploy/rest/RestSubmissionServer.scala
+++
b/core/src/main/scala/org/apache/spark/deploy/rest/RestSubmissionServer.scala
@@ -60,6 +60,8 @@ private[spark] abstract class RestSubmissionServer(
protected val submitRequestServlet: SubmitRequestServlet
protected val killRequestServlet: KillRequestServlet
protected val killAllRequestServlet: KillAllRequestServlet
+ protected val holdRequestServlet: HoldRequestServlet
+ protected val resumeRequestServlet: HoldRequestServlet
protected val statusRequestServlet: StatusRequestServlet
protected val clearRequestServlet: ClearRequestServlet
protected val readyzRequestServlet: ReadyzRequestServlet
@@ -73,6 +75,8 @@ private[spark] abstract class RestSubmissionServer(
s"$baseContext/create/*" -> submitRequestServlet,
s"$baseContext/kill/*" -> killRequestServlet,
s"$baseContext/killall/*" -> killAllRequestServlet,
+ s"$baseContext/hold/*" -> holdRequestServlet,
+ s"$baseContext/resume/*" -> resumeRequestServlet,
s"$baseContext/status/*" -> statusRequestServlet,
s"$baseContext/clear/*" -> clearRequestServlet,
s"$baseContext/readyz/*" -> readyzRequestServlet,
@@ -287,6 +291,33 @@ private[rest] abstract class KillAllRequestServlet extends
RestServlet {
protected def handleKillAll(): KillAllSubmissionResponse
}
+/**
+ * A servlet for handling hold or resume requests passed to the
[[RestSubmissionServer]].
+ *
+ * @param hold whether this servlet holds or resumes the application
+ */
+private[rest] abstract class HoldRequestServlet(hold: Boolean) extends
RestServlet {
+
+ /**
+ * If an application ID is specified in the URL, have the Master forward the
hold or resume
+ * request to the corresponding driver and return an appropriate response to
the client.
+ * Otherwise, return error.
+ */
+ protected override def doPost(
+ request: HttpServletRequest,
+ response: HttpServletResponse): Unit = {
+ val appId = parseSubmissionId(request.getPathInfo)
+ val responseMessage = appId.map(handleHold).getOrElse {
+ response.setStatus(HttpServletResponse.SC_BAD_REQUEST)
+ val action = if (hold) "hold" else "resume"
+ handleError(s"Application ID is missing in $action request.")
+ }
+ sendResponse(responseMessage, response)
+ }
+
+ protected def handleHold(appId: String): HoldApplicationResponse
+}
+
/**
* A servlet for handling clear requests passed to the
[[RestSubmissionServer]].
*/
@@ -439,8 +470,8 @@ private class ErrorServlet extends RestServlet {
"Missing the /submissions prefix."
case `serverVersion` :: "submissions" :: tail =>
// http://host:port/correct-version/submissions/*
- "Missing an action: please specify one of /create, /kill, /killall,
/clear, /status, " +
- "or /readyz."
+ "Missing an action: please specify one of /create, /kill, /killall,
/hold, /resume, " +
+ "/clear, /status, or /readyz."
case unknownVersion :: tail =>
// http://host:port/unknown-version/*
versionMismatch = true
diff --git
a/core/src/main/scala/org/apache/spark/deploy/rest/StandaloneRestServer.scala
b/core/src/main/scala/org/apache/spark/deploy/rest/StandaloneRestServer.scala
index 79a54bae4048..14c6704ca3d7 100644
---
a/core/src/main/scala/org/apache/spark/deploy/rest/StandaloneRestServer.scala
+++
b/core/src/main/scala/org/apache/spark/deploy/rest/StandaloneRestServer.scala
@@ -67,6 +67,10 @@ private[deploy] class StandaloneRestServer(
new StandaloneKillRequestServlet(masterEndpoint, masterConf)
protected override val killAllRequestServlet =
new StandaloneKillAllRequestServlet(masterEndpoint, masterConf)
+ protected override val holdRequestServlet =
+ new StandaloneHoldRequestServlet(masterEndpoint, masterConf, hold = true)
+ protected override val resumeRequestServlet =
+ new StandaloneHoldRequestServlet(masterEndpoint, masterConf, hold = false)
protected override val statusRequestServlet =
new StandaloneStatusRequestServlet(masterEndpoint, masterConf)
protected override val clearRequestServlet =
@@ -110,6 +114,32 @@ private[rest] class
StandaloneKillAllRequestServlet(masterEndpoint: RpcEndpointR
}
}
+/**
+ * A servlet for handling hold or resume requests passed to the
[[StandaloneRestServer]].
+ *
+ * The Master checks `spark.ui.holdEnabled` on both itself and the
application, like for the
+ * Master UI controls. The response tells only whether the request was
forwarded to the driver:
+ * the resulting hold status is reported by the Master's `/json/` endpoint
once the driver acts
+ * on it.
+ */
+private[rest] class StandaloneHoldRequestServlet(
+ masterEndpoint: RpcEndpointRef,
+ conf: SparkConf,
+ hold: Boolean)
+ extends HoldRequestServlet(hold) {
+
+ protected def handleHold(appId: String): HoldApplicationResponse = {
+ val response =
masterEndpoint.askSync[DeployMessages.ApplicationHoldResponse](
+ DeployMessages.RequestApplicationHold(appId, hold))
+ val h = new HoldApplicationResponse
+ h.serverSparkVersion = sparkVersion
+ h.message = response.message
+ h.appId = appId
+ h.success = response.success
+ h
+ }
+}
+
/**
* A servlet for handling status requests passed to the
[[StandaloneRestServer]].
*/
diff --git
a/core/src/main/scala/org/apache/spark/deploy/rest/SubmitRestProtocolResponse.scala
b/core/src/main/scala/org/apache/spark/deploy/rest/SubmitRestProtocolResponse.scala
index 4c484419d3be..239e800ea8af 100644
---
a/core/src/main/scala/org/apache/spark/deploy/rest/SubmitRestProtocolResponse.scala
+++
b/core/src/main/scala/org/apache/spark/deploy/rest/SubmitRestProtocolResponse.scala
@@ -65,6 +65,18 @@ private[spark] class KillAllSubmissionResponse extends
SubmitRestProtocolRespons
}
}
+/**
+ * A response to a hold or resume request in the REST application submission
protocol.
+ */
+private[spark] class HoldApplicationResponse extends
SubmitRestProtocolResponse {
+ var appId: String = null
+ protected override def doValidate(): Unit = {
+ super.doValidate()
+ assertFieldIsSet(appId, "appId")
+ assertFieldIsSet(success, "success")
+ }
+}
+
/**
* A response to a clear request in the REST application submission protocol.
*/
diff --git a/core/src/main/scala/org/apache/spark/internal/config/UI.scala
b/core/src/main/scala/org/apache/spark/internal/config/UI.scala
index e5596d0c6bfc..4c2e328bf0c0 100644
--- a/core/src/main/scala/org/apache/spark/internal/config/UI.scala
+++ b/core/src/main/scala/org/apache/spark/internal/config/UI.scala
@@ -99,7 +99,8 @@ private[spark] object UI {
"spark.decommission.enabled is true, the shuffle data is kept outside
the executors " +
"(through either spark.shuffle.service.enabled or a ShuffleDataIO with
reliable " +
"storage), and the cluster manager can hold executors: Standalone, YARN,
and " +
- "Kubernetes with spark.kubernetes.allocation.pods.allocator=direct.")
+ "Kubernetes with spark.kubernetes.allocation.pods.allocator=direct. On a
Standalone " +
+ "Master, this also gates the hold and resume actions of the Master REST
API.")
.version("4.4.0")
.withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
.booleanConf
diff --git
a/core/src/test/scala/org/apache/spark/deploy/client/AppClientSuite.scala
b/core/src/test/scala/org/apache/spark/deploy/client/AppClientSuite.scala
index 814d68587a83..6d8e8b0944d6 100644
--- a/core/src/test/scala/org/apache/spark/deploy/client/AppClientSuite.scala
+++ b/core/src/test/scala/org/apache/spark/deploy/client/AppClientSuite.scala
@@ -28,7 +28,7 @@ import org.scalatest.concurrent.{Eventually, ScalaFutures}
import org.apache.spark._
import org.apache.spark.deploy.{ApplicationDescription, Command,
DeployTestUtils}
-import org.apache.spark.deploy.DeployMessages.{MasterStateResponse,
RequestApplicationHold, RequestMasterState, WorkerDecommissioning}
+import org.apache.spark.deploy.DeployMessages.{ApplicationHoldResponse,
MasterStateResponse, RequestApplicationHold, RequestMasterState,
WorkerDecommissioning}
import org.apache.spark.deploy.master.{ApplicationInfo, Master}
import org.apache.spark.deploy.worker.Worker
import org.apache.spark.internal.{config, Logging}
@@ -264,6 +264,10 @@ class AppClientSuite
assert(getApplications().length === 1, "master should have 1
registered app")
}
val appId = getApplications().head.id
+ ci.client.reportHoldStatus(supported = true, held = false)
+ eventually(timeout(10.seconds), interval(10.millis)) {
+ assert(getApplications().head.holdSupported)
+ }
// A hold requested from the Master UI reaches the driver.
master.self.send(RequestApplicationHold(appId, hold = true))
@@ -310,6 +314,49 @@ class AppClientSuite
}
}
+ test("SPARK-59916: hold and resume an application through an ask to the
Master") {
+ Utils.tryWithResource(new AppClientInst(masterRpcEnv.address.toSparkURL))
{ ci =>
+ ci.client.start()
+
+ eventually(timeout(10.seconds), interval(10.millis)) {
+ assert(getApplications().length === 1, "master should have 1
registered app")
+ }
+ val appId = getApplications().head.id
+
+ // The REST server asks rather than sends, and the reply tells whether
the request was
+ // forwarded to the driver.
+ def askHold(id: String, hold: Boolean): ApplicationHoldResponse =
+
master.self.askSync[ApplicationHoldResponse](RequestApplicationHold(id, hold))
+
+ // Rejected until the driver reports that it can be held.
+ val unsupported = askHold(appId, hold = true)
+ assert(!unsupported.success)
+ assert(unsupported.message.contains("has not reported that it can be
held"))
+
+ val unknown = askHold("unknown-app", hold = true)
+ assert(!unknown.success)
+ assert(unknown.message.contains("unknown or finished application"))
+ assert(ci.listener.holdRequestList.isEmpty)
+
+ ci.client.reportHoldStatus(supported = true, held = false)
+ eventually(timeout(10.seconds), interval(10.millis)) {
+ assert(getApplications().head.holdSupported)
+ }
+ Seq(true, false).foreach { hold =>
+ assert(askHold(appId, hold).success)
+ }
+ eventually(timeout(10.seconds), interval(10.millis)) {
+ assert(ci.listener.holdRequestList.asScala.toSeq === Seq(true, false))
+ }
+
+ ci.client.stop()
+
+ eventually(timeout(10.seconds), interval(10.millis)) {
+ assert(getApplications().isEmpty, "master should have 0 registered
apps")
+ }
+ }
+ }
+
test("request from AppClient before initialized with master") {
Utils.tryWithResource(new AppClientInst(masterRpcEnv.address.toSparkURL))
{ ci =>
diff --git
a/core/src/test/scala/org/apache/spark/deploy/master/MasterSuite.scala
b/core/src/test/scala/org/apache/spark/deploy/master/MasterSuite.scala
index 2b68f3db1b44..4325c83444ec 100644
--- a/core/src/test/scala/org/apache/spark/deploy/master/MasterSuite.scala
+++ b/core/src/test/scala/org/apache/spark/deploy/master/MasterSuite.scala
@@ -28,6 +28,7 @@ import org.apache.spark.deploy._
import org.apache.spark.deploy.DeployMessages._
import org.apache.spark.internal.config._
import org.apache.spark.internal.config.Deploy._
+import org.apache.spark.internal.config.UI.UI_HOLD_ENABLED
import org.apache.spark.resource.ResourceProfile
import org.apache.spark.rpc.{RpcAddress, RpcEndpoint, RpcEnv}
@@ -284,6 +285,16 @@ class MasterSuite extends MasterSuiteBase {
assert(appInfo.numDrainingExecutors === 0)
}
+ test("SPARK-59916: Reject the hold and resume requests when the Master
disables holding") {
+ val master = makeAliveMaster(new SparkConf().set(UI_HOLD_ENABLED, false))
+ Seq(true, false).foreach { hold =>
+ val response = master.self.askSync[ApplicationHoldResponse](
+ RequestApplicationHold("app-1", hold))
+ assert(!response.success)
+ assert(response.message.contains(s"${UI_HOLD_ENABLED.key} is disabled on
the Master"))
+ }
+ }
+
test("SPARK-57451: Allows REST server and spark.authenticate.secret to be
enabled together") {
val conf = new SparkConf()
.set(MASTER_REST_SERVER_ENABLED, true)
diff --git
a/core/src/test/scala/org/apache/spark/deploy/rest/StandaloneRestSubmitSuite.scala
b/core/src/test/scala/org/apache/spark/deploy/rest/StandaloneRestSubmitSuite.scala
index 39ec9fc5aa7e..5ab6503afadb 100644
---
a/core/src/test/scala/org/apache/spark/deploy/rest/StandaloneRestSubmitSuite.scala
+++
b/core/src/test/scala/org/apache/spark/deploy/rest/StandaloneRestSubmitSuite.scala
@@ -267,6 +267,30 @@ class StandaloneRestSubmitSuite extends SparkFunSuite {
assert(errorResponse.message.contains("Master is not ready."))
}
+ test("SPARK-59916: hold and resume an application") {
+ val masterUrl = startDummyServer()
+ val httpUrl = masterUrl.replace("spark://", "http://")
+ val v = RestSubmissionServer.PROTOCOL_VERSION
+ Seq("hold" -> "held", "resume" -> "resumed").foreach { case (action,
message) =>
+ val path = s"$httpUrl/$v/submissions/$action"
+ val (response, code) = sendHttpRequestWithResponse(s"$path/my-app",
"POST")
+ assert(code === HttpServletResponse.SC_OK)
+ val holdResponse = getHoldResponse(response)
+ assert(holdResponse.action === Utils.getFormattedClassName(holdResponse))
+ assert(holdResponse.serverSparkVersion === SPARK_VERSION)
+ assert(holdResponse.message === message)
+ assert(holdResponse.appId === "my-app")
+ assert(holdResponse.success)
+
+ // A request without an application ID is rejected.
+ Seq(path, s"$path/").foreach { badPath =>
+ val (badResponse, badCode) = sendHttpRequestWithResponse(badPath,
"POST")
+ assert(badCode === HttpServletResponse.SC_BAD_REQUEST)
+ getErrorResponse(badResponse)
+ }
+ }
+ }
+
/* ---------------------------------------- *
| Aberrant client / server behavior |
* ---------------------------------------- */
@@ -780,6 +804,15 @@ class StandaloneRestSubmitSuite extends SparkFunSuite {
}
}
+ /** Return the response as a hold response, or fail with error otherwise. */
+ private def getHoldResponse(response: SubmitRestProtocolResponse):
HoldApplicationResponse = {
+ response match {
+ case h: HoldApplicationResponse => h
+ case e: ErrorResponse => fail(s"Server returned error: ${e.message}")
+ case r => fail(s"Expected hold response. Actual: ${r.toJson}")
+ }
+ }
+
/** Return the response as a clear response, or fail with error otherwise. */
private def getClearResponse(response: SubmitRestProtocolResponse):
ClearResponse = {
response match {
@@ -874,6 +907,8 @@ private class DummyMaster(
context.reply(KillDriverResponse(self, driverId, success = true,
killMessage))
case RequestKillAllDrivers =>
context.reply(KillAllDriversResponse(self, success = true, killMessage))
+ case RequestApplicationHold(_, hold) =>
+ context.reply(ApplicationHoldResponse(success = true, if (hold) "held"
else "resumed"))
case RequestDriverStatus(driverId) =>
context.reply(DriverStatusResponse(found = true, Some(state), None,
None, exception))
case RequestClearCompletedDriversAndApps =>
@@ -939,6 +974,10 @@ private class FaultyStandaloneRestServer(
protected override val submitRequestServlet = new MalformedSubmitServlet
protected override val killRequestServlet = new InvalidKillServlet
protected override val killAllRequestServlet = new InvalidKillAllServlet
+ protected override val holdRequestServlet =
+ new StandaloneHoldRequestServlet(masterEndpoint, masterConf, hold = true)
+ protected override val resumeRequestServlet =
+ new StandaloneHoldRequestServlet(masterEndpoint, masterConf, hold = false)
protected override val statusRequestServlet = new ExplodingStatusServlet
protected override val clearRequestServlet = new ExplodingClearServlet
protected override val readyzRequestServlet = new ExplodingReadyzServlet
diff --git a/docs/configuration.md b/docs/configuration.md
index fef5d4b3ba2a..c85e1f58f1aa 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -1750,7 +1750,9 @@ Apart from these, the following properties are also
available, and may be useful
executors (through either <code>spark.shuffle.service.enabled</code> or a
<code>ShuffleDataIO</code> with reliable storage), and the cluster manager
can hold
executors: Standalone, YARN, and Kubernetes with
- <code>spark.kubernetes.allocation.pods.allocator=direct</code>.
+ <code>spark.kubernetes.allocation.pods.allocator=direct</code>. On a
Standalone Master,
+ this also gates the <code>hold</code> and <code>resume</code> actions of
the
+ <a href="spark-standalone.html#rest-api">Master REST API</a>.
</td>
<td>4.4.0</td>
</tr>
diff --git a/docs/spark-standalone.md b/docs/spark-standalone.md
index 1024f0f67052..2e588a8f1657 100644
--- a/docs/spark-standalone.md
+++ b/docs/spark-standalone.md
@@ -653,6 +653,20 @@ via
<code>http://[host:port]/[version]/submissions/[action]</code> where
<td>Kill all running Spark drivers.</td>
<td>4.0.0</td>
</tr>
+ <tr>
+ <td><code>hold</code></td>
+ <td>POST</td>
+ <td>Hold a running Spark application, given its application ID rather than
a submission ID.
+ See <a href="#held-applications">Held Applications</a>.</td>
+ <td>4.4.0</td>
+ </tr>
+ <tr>
+ <td><code>resume</code></td>
+ <td>POST</td>
+ <td>Resume a held Spark application, given its application ID rather than
a submission ID.
+ See <a href="#held-applications">Held Applications</a>.</td>
+ <td>4.4.0</td>
+ </tr>
<tr>
<td><code>status</code></td>
<td>GET</td>
@@ -819,6 +833,28 @@ If the Master web UI requires authentication, reading the
token and sending the
need the same credentials. The request is forwarded to the driver
asynchronously, so its outcome
shows up afterwards in the `held` and `draining` fields of the Master's
`/json/` endpoint.
+Scripts can also use the `hold` and `resume` actions of the [REST
API](#rest-api), which need no
+CSRF token. They take the application ID rather than a submission ID, so they
work for
+applications submitted in client mode too, and they are gated the same way, by
+`spark.ui.holdEnabled` on the Master and on the application. The response only
tells whether the
+request was forwarded to the driver; poll the `held` and `draining` fields of
the Master's
+`/json/` endpoint for the outcome.
+
+```bash
+$ curl -XPOST http://IP:PORT/v1/submissions/hold/app-20260930120000-0000
+{
+ "action" : "HoldApplicationResponse",
+ "appId" : "app-20260930120000-0000",
+ "message" : "Requesting application app-20260930120000-0000 to hold.",
+ "serverSparkVersion" : "4.4.0",
+ "success" : true
+}
+```
+
+Like the other actions of the REST API, `hold` and `resume` check no ACLs, and
a browser can be
+made to send them from another site. Require a signed `Authorization` header
with `JWSFilter` as
+described in [REST API](#rest-api), or disable the REST API with
`spark.master.rest.enabled=false`.
+
# Running Alongside Hadoop
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]