This is an automated email from the ASF dual-hosted git repository.

dongjoon-hyun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/spark-kubernetes-operator.git


The following commit(s) were added to refs/heads/main by this push:
     new ebdd9281 [SPARK-60025] Support `spec.suspend` for a running 
`SparkApplication`
ebdd9281 is described below

commit ebdd928193aa2d1fb21c6105b007ed4b7972e3b9
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Oct 7 08:28:06 2026 -0700

    [SPARK-60025] Support `spec.suspend` for a running `SparkApplication`
    
    ### What changes were proposed in this pull request?
    
    This PR aims to support `spec.suspend` for a running `SparkApplication`.
    
    - Add `AppSuspendStep`, which moves a running application to `Suspended` 
and releases its driver, executors, and Kueue `Workload`. When `spec.suspend` 
is set back to `false`, it starts a new attempt from `Submitted` with 
`ApplicationStatus#resume`.
    - Route `Suspended` to `AppSuspendStep` in `SparkAppReconciler`, and skip 
the restart backoff for a resumed attempt in `AppInitStep`.
    - Update the documentation and the e2e tests.
    
    ### Why are the changes needed?
    
    Currently, `spec.suspend` takes effect only before the driver is requested. 
So a running attempt keeps its resources, including its Kueue quota, until it 
ends. A running `SparkCluster` can already be suspended.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, within the unreleased `spec.suspend`. Setting `spec.suspend` to `true` 
now stops the running attempt of a `SparkApplication`, and setting it back to 
`false` starts a new attempt. There is no change compared to 
[1.0.0](https://github.com/apache/spark-kubernetes-operator/releases/tag/1.0.0) 
(2026-07-23), which has no `spec.suspend`.
    
    ### How was this patch tested?
    
    Pass the CIs with the newly added test cases.
    
    ### Was this patch authored or co-authored using generative AI tooling?
    
    Generated-by: Claude Opus 5.5
    
    Closes #948 from dongjoon-hyun/SPARK-60025.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../kueue-validating-admission-policy.yaml         |   8 +-
 docs/architecture.md                               |   7 +
 docs/config_properties.md                          |   2 +-
 docs/configuration.md                              |  10 +-
 docs/spark_custom_resources.md                     |  80 ++-
 .../org/apache/spark/k8s/operator/Constants.java   |  16 +
 .../k8s/operator/config/SparkOperatorConf.java     |  15 +-
 .../operator/reconciler/SparkAppReconciler.java    |   4 +
 .../reconciler/reconcilesteps/AppInitStep.java     |   5 +-
 .../reconcilesteps/AppKueueEvictionStep.java       |   2 +-
 .../reconcilesteps/AppReconcileStep.java           |   2 +-
 .../reconciler/reconcilesteps/AppSuspendStep.java  | 280 ++++++++
 .../reconciler/reconcilesteps/SuspendUtils.java    |   5 +-
 .../spark/k8s/operator/utils/EventUtils.java       |   4 +-
 .../reconciler/SparkAppReconcilerTest.java         |  58 +-
 .../reconciler/reconcilesteps/AppInitStepTest.java |  36 +
 .../reconcilesteps/AppKueueEvictionStepTest.java   |   1 +
 .../reconcilesteps/AppSuspendStepTest.java         | 729 +++++++++++++++++++++
 tests/e2e/kueue/chainsaw-test.yaml                 |  70 ++
 tests/e2e/suspend/chainsaw-test.yaml               |  80 +++
 tests/e2e/suspend/spark-example-running.yaml       |  35 +
 21 files changed, 1387 insertions(+), 62 deletions(-)

diff --git 
a/build-tools/helm/spark-kubernetes-operator/templates/kueue-validating-admission-policy.yaml
 
b/build-tools/helm/spark-kubernetes-operator/templates/kueue-validating-admission-policy.yaml
index f89e3908..ef77bfe6 100644
--- 
a/build-tools/helm/spark-kubernetes-operator/templates/kueue-validating-admission-policy.yaml
+++ 
b/build-tools/helm/spark-kubernetes-operator/templates/kueue-validating-admission-policy.yaml
@@ -15,7 +15,7 @@
 
 {{/*
 Keep the queue-name label of a SparkApplication or SparkCluster from changing 
once it started,
-until the application is scheduled to restart or the cluster is Suspended. See 
the Kueue section
+until the application is scheduled to restart or the resource is Suspended. 
See the Kueue section
 of docs/spark_custom_resources.md.
 */}}
 {{- if .Values.operatorRbac.kueue.enabled }}
@@ -60,14 +60,14 @@ spec:
         ? oldObject.status.currentState.currentStateSummary : ''
   validations:
     # The states in which ApplicationStateSummary or 
ClusterStateSummary#isInitializing holds, i.e.
-    # before an attempt starts, and ClusterStateSummary.Suspended. Keep them 
in sync.
+    # before an attempt starts, and the Suspended state of both. Keep them in 
sync.
     - expression: >-
         variables.oldQueue == variables.newQueue
         || variables.oldState in ['', 'Submitted', 'ScheduledToRestart', 
'Suspended']
       message: >-
         The kueue.x-k8s.io/queue-name label can be changed only before the 
resource starts, i.e.
-        while it is Submitted or ScheduledToRestart, or while a SparkCluster 
is Suspended, since
-        the Kueue Workload admitted for it keeps its quota in that queue.
+        while it is Submitted or ScheduledToRestart, or while it is Suspended, 
since the Kueue
+        Workload admitted for it keeps its quota in that queue.
       reason: Forbidden
 ---
 apiVersion: admissionregistration.k8s.io/v1
diff --git a/docs/architecture.md b/docs/architecture.md
index 6b611286..7d4b70a1 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -138,6 +138,9 @@ stateDiagram-v2
     RunningWithPartialCapacity --> Succeeded
     RunningWithPartialCapacity --> Failed
 
+    RunningHealthy --> Suspended : spec.suspend=true
+    Suspended --> Submitted : spec.suspend=false, new attempt
+
     state Failures {
         SchedulingFailure
         DriverStartTimedOut
@@ -165,6 +168,10 @@ stateDiagram-v2
 * Once the driver is requested, an application moves to `Succeeded`, `Failed` 
or `DriverEvicted`
   from any of the states above whenever the driver pod terminates or is 
evicted. It also moves to
   `Failed` if the driver pod is removed unexpectedly.
+* Likewise, an application moves to `Suspended` from any of the states from 
`DriverRequested` to
+  `RunningWithBelowThresholdExecutors` when 
[`spec.suspend`](spark_custom_resources.md#suspend) is
+  set to `true`. Its driver and executors are released, and setting it back to 
`false` starts a
+  new attempt from `Submitted`, which does not count as a restart.
 * User may configure the app CR to time-out after given threshold of time if 
it cannot reach healthy
   state after given threshold. The timeout can be configured for different 
lifecycle stages,
   when driver starting, when driver becoming ready, and when requesting 
executor pods.
diff --git a/docs/config_properties.md b/docs/config_properties.md
index 60153a86..2a8285b9 100644
--- a/docs/config_properties.md
+++ b/docs/config_properties.md
@@ -53,7 +53,7 @@
  | spark.kubernetes.operator.reconciler.retry.initialIntervalSeconds | Integer 
| 5 | false | Initial interval(in seconds) of retries on unhandled controller 
errors. | 
  | spark.kubernetes.operator.reconciler.retry.intervalMultiplier | Double | 
1.5 | false | Interval multiplier of retries on unhandled controller errors. 
Setting this to 1 for linear retry. | 
  | spark.kubernetes.operator.reconciler.retry.maxIntervalSeconds | Integer | 
-1 | false | Max interval(in seconds) of retries on unhandled controller 
errors. Set to non-positive for unlimited. | 
- | spark.kubernetes.operator.reconciler.suspendHoldRequeueIntervalSeconds | 
Long | 1800 | true | Requeue interval (in seconds) at which a resource held by 
spec.suspend is reconciled, so that its SuspendHeld event is republished. A 
SparkCluster suspended while running publishes no such event, since its 
Suspended status says so, and uses this interval only to look at its released 
resources again. The hold ends only when a user clears spec.suspend, which 
arrives as a watch event and reconci [...]
+ | spark.kubernetes.operator.reconciler.suspendHoldRequeueIntervalSeconds | 
Long | 1800 | true | Requeue interval (in seconds) at which a resource held by 
spec.suspend is reconciled, so that its SuspendHeld event is republished. A 
SparkApplication or SparkCluster suspended while running publishes no such 
event, since its Suspended status says so, and uses this interval only to look 
at its released resources again. The hold ends only when a user clears 
spec.suspend, which arrives as a wat [...]
  | spark.kubernetes.operator.reconciler.terminationTimeoutSeconds | Integer | 
30 | false | Grace period for operator shutdown before reconciliation threads 
are killed. | 
  | spark.kubernetes.operator.reconciler.trimStateTransitionHistoryEnabled | 
Boolean | true | true | When enabled, operator would trim state transition 
history when a new attempt starts, keeping previous attempt summary only. It 
also drops the history of a suspended SparkCluster when it is resumed. | 
  | spark.kubernetes.operator.watchedNamespaces | String | default | true | 
Comma-separated list of namespaces that the operator would be watching for 
Spark resources. If set to '*', operator would watch all namespaces. | 
diff --git a/docs/configuration.md b/docs/configuration.md
index 591262c9..115d0815 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -100,9 +100,9 @@ replacing its `message`. A repeat carrying the same 
`message` may be paced by
 | `Normal` | All other states, e.g. `ScheduledToRestart`, `DriverRequested`, 
`DriverStarted`, `DriverReady`, `InitializedBelowThresholdExecutors`, 
`RunningHealthy`, `RunningWithPartialCapacity`, `Succeeded`, `ResourceReleased` 
and `Suspended` |
 
 The initial `Submitted` status of a new resource is not persisted to the API 
server on its own, so
-no event is published for it. A `Submitted` event is published only when a 
`SparkCluster` moves
-from `Suspended` back to `Submitted`, after `spec.suspend` is set back to 
`false` or after its
-eviction by Kueue.
+no event is published for it. A `Submitted` event is published only when a 
`SparkApplication` or a
+`SparkCluster` moves from `Suspended` back to `Submitted`, after 
`spec.suspend` is set back to
+`false`, or after the eviction of a `SparkCluster` by Kueue.
 
 In addition, the operator publishes the following `Warning` events.
 
@@ -113,11 +113,11 @@ In addition, the operator publishes the following 
`Warning` events.
 | `StatusUpdateFailed` | A status patch is rejected. Transport-level errors 
are skipped. |
 | `KueueAdmissionRequestFailed` | Creating, reading or deleting a stale Kueue 
`Workload` fails, or the driver or master cannot be read before the admission 
is requested. Transport-level errors are skipped and retried every 5 seconds, 
while a persistent failure is retried with the default interval. |
 | `ClusterRequestFailed` | Applying the Services, StatefulSets, NetworkPolicy, 
HorizontalPodAutoscaler or PodDisruptionBudget of a `SparkCluster` fails in a 
way which may yet succeed, such as a throttled request or an internal server 
error from an admission webhook, so it is retried with the default interval 
instead of failing the cluster. Transport-level errors are skipped. |
-| `SuspendReleaseFailed` | Deleting the master and worker StatefulSets, the 
HorizontalPodAutoscaler or PodDisruptionBudget of the workers, or the Kueue 
`Workload` of a `SparkCluster` suspended while running fails, or its pods 
cannot be listed. A failure which may clear on its own, such as a timeout, is 
skipped. Every failure is retried with the default interval. |
+| `SuspendReleaseFailed` | Deleting the driver pod (`SparkApplication`), the 
master and worker StatefulSets or the HorizontalPodAutoscaler or 
PodDisruptionBudget of the workers (`SparkCluster`), or the Kueue `Workload` of 
a resource suspended while running fails, or its pods cannot be listed. A 
failure which may clear on its own, such as a timeout, is skipped. Every 
failure is retried with the default interval. |
 | `KueueResourceFlavorReadFailed` | Reading the `ResourceFlavor`s which Kueue 
assigned to an admitted `Workload` fails, so the node selector and the 
tolerations of the flavors cannot be applied. Transport-level errors are 
skipped and retried every 5 seconds, while a persistent failure is retried with 
the default interval. |
 | `KueuePodsReadyUpdateFailed` | Recording the `PodsReady` condition on the 
Kueue `Workload` of a running `SparkApplication` or `SparkCluster` fails, e.g. 
without the permission for the `workloads/status` subresource, so Kueue may 
evict it by a `PodsReadyTimeout`, which releases a running `SparkCluster` and 
queues it again even if its master and workers are ready. Transport-level 
errors and conflicts are skipped. A `SparkCluster` retries them every 5 seconds 
and a persistent failure with [...]
 | `KueueDisabled` | A `SparkApplication` or `SparkCluster` labeled with 
`kueue.x-k8s.io/queue-name` is not queued, since 
`spark.kubernetes.operator.kueue.enabled` is disabled, so the driver (or master 
and worker) is requested without the Kueue admission. |
-| `KueueEvictionIgnored` | Kueue evicted or deactivated the `Workload` of a 
`SparkApplication` whose driver is requested and whose attempt has not stopped. 
The operator does not act on it, so the driver and executors keep running. An 
evicted `Workload` keeps holding its quota, while a deactivated one no longer 
counts against it. It is republished on every reconciliation while it lasts, 
subject to [`minIntervalSeconds`](#event-frequency). See 
[Kueue](spark_custom_resources.md#kueue). |
+| `KueueEvictionIgnored` | Kueue evicted or deactivated the `Workload` of a 
`SparkApplication` whose driver is requested and whose attempt has neither 
stopped nor been suspended. The operator does not act on it, so the driver and 
executors keep running. An evicted `Workload` keeps holding its quota, while a 
deactivated one no longer counts against it. It is republished on every 
reconciliation while it lasts, subject to 
[`minIntervalSeconds`](#event-frequency). See [Kueue](spark_custom_re [...]
 
 For a resource held by [`spec.suspend`](spark_custom_resources.md#suspend) or 
queued by
 [Kueue](spark_custom_resources.md#kueue), the operator also publishes the 
following `Normal`
diff --git a/docs/spark_custom_resources.md b/docs/spark_custom_resources.md
index ef0911ea..e5c19516 100644
--- a/docs/spark_custom_resources.md
+++ b/docs/spark_custom_resources.md
@@ -534,7 +534,7 @@ Both `SparkApplication` and `SparkCluster` support 
`.spec.suspend`. When it is s
 the driver pod or the master / worker StatefulSets are requested, the operator 
keeps the resource in
 its initializing state (`Submitted`, or `ScheduledToRestart` for an 
application that is scheduled to
 restart) and does not request them. Setting it back to `false` resumes the 
regular lifecycle. A
-running `SparkCluster` is stopped instead, as described below.
+running `SparkApplication` or `SparkCluster` is stopped instead, as described 
below.
 
 `Submitted` here is the operator's in-memory view. For a valid resource 
created with
 `suspend: true`, the initial `Submitted` status is not persisted to the API 
server, so
@@ -542,9 +542,10 @@ running `SparkCluster` is stopped instead, as described 
below.
 initialization resumes. Instead, the `SuspendHeld` 
[event](configuration.md#kubernetes-events) is
 published by default. Since the status is not there to fall back on, it is 
republished every 30
 minutes by default while the hold lasts, so that it outlives the event 
retention of the API
-server. An application held later, in `ScheduledToRestart`, keeps the status 
its previous attempt
-already wrote and gets the same event, since that status says that a restart 
is due, not that the
-next attempt is withheld.
+server. An application held later, in `ScheduledToRestart`, or in `Submitted` 
after it was resumed
+as described below, keeps the status which was already written and gets the 
same event, since that
+status says that a restart is due or that the application is resumed, not that 
the next attempt is
+withheld.
 
 ``` yaml
 apiVersion: spark.apache.org/v1
@@ -559,10 +560,31 @@ spec:
     sparkVersion: "4.2.0"
 ```
 
-* For a `SparkApplication`, `suspend` only takes effect before the driver 
resources are requested.
-  Setting it to `true` on a running application does not stop the current 
attempt. If the
-  application is configured to restart, the next attempt is held until 
`suspend` is set back to
-  `false`.
+* Setting it to `true` on a `SparkApplication` whose driver is requested, i.e. 
from
+  `DriverRequested` to `RunningWithBelowThresholdExecutors`, stops its current 
attempt. The
+  application enters `Suspended` first, and then the operator deletes its 
driver pod, which deletes
+  its executor pods and the other resources which the driver owns, e.g. its 
ConfigMaps and
+  Services, like at the end of an attempt, whatever its `resourceRetainPolicy` 
is. The progress of
+  the attempt is lost. An attempt whose driver has completed or failed by then 
ends as usual
+  instead, and if the application is configured to restart, its next attempt 
is held as above. A
+  failure to delete the driver pod or the Kueue `Workload`, other than one 
which may clear on its
+  own, publishes the `SuspendReleaseFailed` 
[event](configuration.md#kubernetes-events) by default.
+* Setting it back to `false` starts a new attempt of the application from 
`Submitted` once its
+  driver and executor pods are gone and its Kueue `Workload`, if any, is 
released. A pod which is
+  still terminating `forceTerminationGracePeriodMillis` after its grace period 
ended, e.g. on a lost
+  node, no longer holds the application, and a driver pod is force deleted 
then. This is measured
+  per pod, so that the pods are waited for even if deleting them failed or 
started late. The new
+  attempt runs the application again from scratch with its current spec. Like 
a restart, it gets
+  the next attempt ID, and with
+  `spark.kubernetes.operator.reconciler.trimStateTransitionHistoryEnabled`, 
the state transition
+  history of the suspended attempt moves to `previousAttemptSummary`. The 
attempt ID is part of the
+  names of its driver resources unless `spark.app.id` is set, while the 
resources which the spec
+  names, e.g. through `configMapSpecs`, keep their names. Unlike a restart, it 
starts without the
+  restart backoff and does not count against the restart limits, so the 
restart counters stay as
+  they are, and it starts even with `restartPolicy: Never`. Only the counter 
of consecutive
+  scheduling failures starts over, since the driver of the suspended attempt 
was requested.
+  `restartCounterResetMillis` and the timeouts of `applicationTimeoutConfig` 
apply to the new
+  attempt on its own, so neither the run before the suspension nor the time 
suspended counts.
 * Setting it to `true` on a running `SparkCluster` (`RunningHealthy`) stops 
it, since a cluster
   runs until it is deleted and has no other way to give its resources back. 
The cluster enters
   `Suspended` first, and then the operator deletes its master and worker 
StatefulSets, and the
@@ -584,11 +606,13 @@ spec:
   a lost node, no longer holds the Kueue `Workload`, so that it does not hold 
the quota forever. The
   cluster still stays `Suspended` until such a pod is gone, since it keeps the 
name of the master or
   worker to create, and the message of its `Suspended` state names such pods.
-* An operator version without the `Suspended` state cannot read a 
`SparkCluster` whose status
-  has it, even after the cluster is resumed. A resumed `SparkCluster` keeps it 
unless
-  `spark.kubernetes.operator.reconciler.trimStateTransitionHistoryEnabled` 
drops the history of
-  its previous run. So delete such clusters, or resume the suspended ones with 
that setting
-  enabled, before downgrading the operator.
+* An operator version without the `Suspended` state cannot read a 
`SparkApplication` or a
+  `SparkCluster` whose status has it, even after the resource is resumed. A 
resumed
+  `SparkApplication` keeps it in its state transition history, or with
+  `spark.kubernetes.operator.reconciler.trimStateTransitionHistoryEnabled` in 
that of
+  `previousAttemptSummary`, and a resumed `SparkCluster` keeps it unless that 
setting drops the
+  history of its previous run. So delete such applications, and delete such 
clusters or resume the
+  suspended ones with that setting enabled, before downgrading the operator.
 * Deleting a suspended resource works as usual.
 * This is the building block for external job queueing systems. See 
[Kueue](#kueue) for the
   built-in integration.
@@ -694,7 +718,9 @@ spec:
   read, the resource is held and the read is retried.
 * `spec.suspend` takes precedence. A suspended resource does not get a 
`Workload`, and suspending
   a queued resource deletes its `Workload` to release the quota. Suspending a 
running
-  `SparkCluster` deletes its `Workload` only after its master and worker pods 
are gone, or are still
+  `SparkApplication` deletes its `Workload` only after its driver and executor 
pods are gone, or
+  are still terminating `forceTerminationGracePeriodMillis` after their grace 
period ended, and
+  suspending a running `SparkCluster` only after its master and worker pods 
are gone, or are still
   terminating 5 minutes after their grace period ended, since terminating pods 
occupy the quota.
   The `Workload` is deleted even if the `kueue.x-k8s.io/queue-name` label was 
removed after the
   admission. The resource is queued again when it is resumed with the 
`kueue.x-k8s.io/queue-name`
@@ -705,10 +731,11 @@ spec:
   `SparkApplication` or a `SparkCluster` once its status says that it started, 
since the `Workload`
   admitted for it keeps its quota in that queue. The label may change again 
when a
   `SparkApplication` is `ScheduledToRestart`, whose next attempt is queued 
with a new `Workload`, or
-  when a `SparkCluster` is `Suspended`. So the label of a terminated resource, 
e.g. `Failed`, stays
-  as it was. To move a running `SparkCluster` to another queue, set 
`spec.suspend` to `true`, change
-  the label once the cluster is `Suspended`, and then resume it, while a 
running `SparkApplication`
-  moves with its next attempt. Before a resource starts, the operator follows 
the label as described
+  when a `SparkApplication` or a `SparkCluster` is `Suspended`. So the label 
of a terminated
+  resource, e.g. `Failed`, stays as it was. To move a running resource to 
another queue, set
+  `spec.suspend` to `true`, change the label once the resource is `Suspended`, 
and then resume it,
+  while a running `SparkApplication` also moves with its next attempt after a 
restart. Before a
+  resource starts, the operator follows the label as described
   above. The policy reads only the status, so a resource whose first status is 
not persisted yet,
   e.g. after the update following the request of its driver or master failed, 
looks like it has not
   started. Its label may still change then, while the operator keeps the 
admitted `Workload` of such
@@ -720,11 +747,11 @@ spec:
 * Once the integration is disabled or the access of the operator to 
`Workload`s is revoked, e.g.
   by disabling `operatorRbac.kueue.enabled`, the operator no longer deletes 
them, and a `Workload`
   left behind keeps its quota until its owner is deleted. While the 
integration stays enabled
-  without the access, a suspended `SparkCluster` does not resume, and a 
stopping
-  `SparkApplication` neither terminates nor restarts, until the access is 
restored or the
+  without the access, a suspended `SparkApplication` or `SparkCluster` does 
not resume, and a
+  stopping `SparkApplication` neither terminates nor restarts, until the 
access is restored or the
   integration is disabled, since the operator keeps deleting their `Workload`s 
by name, which is
   denied even once a `Workload` is gone. Before disabling either, let the 
queued
-  `SparkApplication`s finish or suspend those which have not started yet, and 
suspend the queued
+  `SparkApplication`s finish or suspend them, and suspend the queued
   `SparkCluster`s, so that the operator releases their `Workload`s itself. 
Then list the remaining
   ones with `kubectl get workloads -A -l spark.operator/spark-app-name` and
   `kubectl get workloads -A -l spark.operator/spark-cluster-name`, and delete 
only those whose
@@ -790,11 +817,12 @@ spec:
   is a `PodsReadyTimeout`, e.g. of an application which runs with fewer 
executors than
   `spark.executor.instances`, or a deactivation. The operator checks the 
admission only before it
   creates the resources, so after a later eviction the driver and executors 
keep running, and
-  until the attempt stops, the `KueueEvictionIgnored` 
[event](configuration.md#kubernetes-events)
-  is published instead by default. An evicted `Workload` keeps its Kueue quota 
until the driver
-  and executors are released, so a workload which preempts the application 
waits until then,
-  while a deactivated one no longer counts against it. Since `spec.suspend` 
does not stop a
-  running attempt, delete the application to release its quota earlier. With 
`blockAdmission`, an
+  until the attempt stops or is suspended, the `KueueEvictionIgnored`
+  [event](configuration.md#kubernetes-events) is published instead by default. 
An evicted
+  `Workload` keeps its Kueue quota until the driver and executors are 
released, so a workload
+  which preempts the application waits until then, while a deactivated one no 
longer counts
+  against it. Set `spec.suspend` to `true`, as described in 
[Suspend](#suspend), or delete the
+  application to release its quota earlier. With `blockAdmission`, an
   application whose pods are not all ready holds back every other workload 
until they are ready
   or its quota is released as described above.
 * To limit the execution time of a `SparkApplication`, the Spark native 
`spark.driver.timeout` is
diff --git 
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/Constants.java 
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/Constants.java
index de5eaa87..7286a29e 100644
--- 
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/Constants.java
+++ 
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/Constants.java
@@ -230,6 +230,22 @@ public final class Constants {
   public static final String EXECUTOR_LAUNCH_TIMEOUT_MESSAGE =
       "The Spark application failed to get enough executors in the given time 
threshold.";
 
+  /**
+   * Message indicating that the application is suspended by spec.suspend 
after its driver was
+   * requested.
+   */
+  public static final String APP_SUSPENDED_MESSAGE =
+      "Application is suspended by spec.suspend, so its driver and executors 
are being released. "
+          + "Setting spec.suspend to false resumes it with a new attempt once 
they are released.";
+
+  /**
+   * Message indicating that the suspended application is resumed by clearing 
spec.suspend, with a
+   * new attempt.
+   */
+  public static final String APP_RESUMED_MESSAGE =
+      "Application is resumed as spec.suspend is set to false, so a new 
attempt starts from "
+          + "scratch.";
+
   // Spark Cluster Messages
   /** Message indicating a failure to request the Spark cluster from the 
scheduler backend. */
   public static final String CLUSTER_SCHEDULE_FAILURE_MESSAGE =
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java
index 39e2ccf5..2cb9ec3f 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/config/SparkOperatorConf.java
@@ -264,11 +264,11 @@ public final class SparkOperatorConf {
 
   /**
    * Requeue interval (in seconds) at which a resource held by {@code 
spec.suspend} is reconciled,
-   * so that its {@code SuspendHeld} event is republished. A SparkCluster 
suspended while running
-   * publishes no such event, since its Suspended status says so, and uses 
this interval only to
-   * look at its released resources again. The hold ends only when a user 
clears
-   * {@code spec.suspend}, which arrives as a watch event and reconciles right 
away, so nothing
-   * waits for this interval. It is deliberately much coarser than {@link
+   * so that its {@code SuspendHeld} event is republished. A SparkApplication 
or SparkCluster
+   * suspended while running publishes no such event, since its Suspended 
status says so, and uses
+   * this interval only to look at its released resources again. The hold ends 
only when a user
+   * clears {@code spec.suspend}, which arrives as a watch event and 
reconciles right away, so
+   * nothing waits for this interval. It is deliberately much coarser than 
{@link
    * #RECONCILER_INTERVAL_SECONDS}, since a suspended resource has nothing to 
observe while each
    * republish costs a read and a write on the API server. Keep it below the 
{@code --event-ttl} of
    * the API server (one hour by default), or the event expires between 
repeats and the hold stops
@@ -282,8 +282,9 @@ public final class SparkOperatorConf {
           .enableDynamicOverride(true)
           .description(
               "Requeue interval (in seconds) at which a resource held by 
spec.suspend is "
-                  + "reconciled, so that its SuspendHeld event is republished. 
A SparkCluster "
-                  + "suspended while running publishes no such event, since 
its Suspended status "
+                  + "reconciled, so that its SuspendHeld event is republished. 
A SparkApplication "
+                  + "or SparkCluster suspended while running publishes no such 
event, since its "
+                  + "Suspended status "
                   + "says so, and uses this interval only to look at its 
released resources "
                   + "again. The hold ends only "
                   + "when a user clears spec.suspend, which arrives as a watch 
event and "
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconciler.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconciler.java
index 24ac7629..9e8cbf53 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconciler.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconciler.java
@@ -59,6 +59,7 @@ import 
org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppKueueEvictionS
 import 
org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppReconcileStep;
 import 
org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppResourceObserveStep;
 import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppRunningStep;
+import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppSuspendStep;
 import 
org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppUnknownStateStep;
 import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppValidateStep;
 import org.apache.spark.k8s.operator.utils.EventUtils;
@@ -212,6 +213,7 @@ public class SparkAppReconciler implements 
Reconciler<SparkApplication>, Cleaner
                 List.of(new AppDriverStartObserver(), new 
AppDriverReadyObserver())));
         steps.add(new AppResourceObserveStep(List.of(new 
AppDriverRunningObserver())));
         steps.add(new AppResourceObserveStep(List.of(new 
AppDriverTimeoutObserver())));
+        steps.add(new AppSuspendStep());
       }
       case DriverReady,
           InitializedBelowThresholdExecutors,
@@ -222,7 +224,9 @@ public class SparkAppReconciler implements 
Reconciler<SparkApplication>, Cleaner
         steps.add(new AppRunningStep());
         steps.add(new AppResourceObserveStep(List.of(new 
AppDriverRunningObserver())));
         steps.add(new AppResourceObserveStep(List.of(new 
AppDriverTimeoutObserver())));
+        steps.add(new AppSuspendStep());
       }
+      case Suspended -> steps.add(new AppSuspendStep());
       default -> steps.add(new AppUnknownStateStep());
     }
     return steps;
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
index b1bf58c8..e03851ec 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStep.java
@@ -91,7 +91,10 @@ public final class AppInitStep extends AppReconcileStep {
         return SuspendUtils.holdForSuspend(context, "driver");
       }
     }
-    if (app.getStatus().getPreviousAttemptSummary() != null) {
+    // Only a restarted attempt waits for the restart backoff, while the 
attempt which a suspended
+    // application starts on resume is Submitted.
+    if (ApplicationStateSummary.ScheduledToRestart == 
currentState.getCurrentStateSummary()
+        && app.getStatus().getPreviousAttemptSummary() != null) {
       Instant lastTransitionTime = 
Instant.parse(currentState.getLastTransitionTime());
       ApplicationAttemptSummary attemptSummary = 
app.getStatus().getPreviousAttemptSummary();
       SortedMap<Long, ApplicationState> attemptHistory = 
attemptSummary.getStateTransitionHistory();
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStep.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStep.java
index cbeb760e..26439d41 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStep.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStep.java
@@ -78,7 +78,7 @@ public final class AppKueueEvictionStep extends 
AppReconcileStep {
                 + cause
                 + ", which the operator does not act on once the driver is 
requested, so the "
                 + "driver and executors keep running and holding its quota 
until they are "
-                + "released. Delete the application to release them.");
+                + "released. Set spec.suspend to true or delete the 
application to release them.");
     return proceed();
   }
 }
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppReconcileStep.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppReconcileStep.java
index cb1d9db2..caea2e58 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppReconcileStep.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppReconcileStep.java
@@ -45,7 +45,7 @@ import 
org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
 @Log4j2
 public abstract sealed class AppReconcileStep
     permits AppCleanUpStep, AppInitStep, AppKueueEvictionStep, 
AppResourceObserveStep,
-        AppRunningStep, AppUnknownStateStep, AppValidateStep {
+        AppRunningStep, AppSuspendStep, AppUnknownStateStep, AppValidateStep {
   /**
    * Reconciles a specific step for a Spark application.
    *
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppSuspendStep.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppSuspendStep.java
new file mode 100644
index 00000000..9737b743
--- /dev/null
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppSuspendStep.java
@@ -0,0 +1,280 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.spark.k8s.operator.reconciler.reconcilesteps;
+
+import static org.apache.spark.k8s.operator.Constants.APP_RESUMED_MESSAGE;
+import static org.apache.spark.k8s.operator.Constants.APP_SUSPENDED_MESSAGE;
+import static 
org.apache.spark.k8s.operator.Constants.LABEL_SPARK_APPLICATION_NAME;
+import static 
org.apache.spark.k8s.operator.Constants.LABEL_SPARK_ROLE_DRIVER_VALUE;
+import static 
org.apache.spark.k8s.operator.Constants.LABEL_SPARK_ROLE_EXECUTOR_VALUE;
+import static org.apache.spark.k8s.operator.Constants.LABEL_SPARK_ROLE_NAME;
+import static 
org.apache.spark.k8s.operator.config.SparkOperatorConf.SUSPEND_HOLD_REQUEUE_INTERVAL_SECONDS;
+import static 
org.apache.spark.k8s.operator.config.SparkOperatorConf.TRIM_ATTEMPT_STATE_TRANSITION_HISTORY;
+import static 
org.apache.spark.k8s.operator.reconciler.ReconcileProgress.completeAndDefaultRequeue;
+import static 
org.apache.spark.k8s.operator.reconciler.ReconcileProgress.completeAndImmediateRequeue;
+import static 
org.apache.spark.k8s.operator.reconciler.ReconcileProgress.completeAndRequeueAfter;
+import static 
org.apache.spark.k8s.operator.reconciler.ReconcileProgress.proceed;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import io.fabric8.kubernetes.api.model.DeletionPropagation;
+import io.fabric8.kubernetes.api.model.Pod;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientException;
+import lombok.extern.slf4j.Slf4j;
+
+import org.apache.spark.k8s.operator.SparkApplication;
+import org.apache.spark.k8s.operator.context.SparkAppContext;
+import org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils;
+import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
+import 
org.apache.spark.k8s.operator.reconciler.observers.AppDriverRunningObserver;
+import org.apache.spark.k8s.operator.spec.ApplicationTimeoutConfig;
+import org.apache.spark.k8s.operator.status.ApplicationState;
+import org.apache.spark.k8s.operator.status.ApplicationStateSummary;
+import org.apache.spark.k8s.operator.utils.EventUtils;
+import org.apache.spark.k8s.operator.utils.ReconcilerUtils;
+import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
+
+/**
+ * Suspends an application whose driver is requested by spec.suspend, and 
resumes it. An
+ * application suspended before its driver is requested is held by {@link 
AppInitStep} instead.
+ *
+ * <p>An application enters Suspended before anything is released, and its 
driver is released only
+ * in Suspended. Releasing it first would leave an application without its 
driver in a running state
+ * when spec.suspend is cleared in the meantime, and the driver observers 
would fail it. A resumed
+ * application leaves Suspended only after everything is released, and then 
starts a new attempt
+ * from Submitted, where {@link AppInitStep} requests its driver and a new 
Kueue admission. Resuming
+ * earlier would let the new attempt run on the admitted Workload of the 
suspended one, whose name
+ * is fixed per application, or meet the driver resources of the suspended 
one, whose names stay
+ * the same if spark.app.id is set. Like a restart, the new attempt gets the 
next attempt id, while
+ * it does not count against the restart limits, see {@link
+ * org.apache.spark.k8s.operator.status.ApplicationStatus#resume}.
+ *
+ * <p>It runs after the driver is observed, so that an attempt whose driver 
completed or failed
+ * meanwhile ends as usual, rather than being suspended and run again from 
scratch on resume. The
+ * observers read the informer cache, which may not show such a driver yet, so 
the driver is
+ * observed once more on the API server before the application is suspended.
+ */
+@Slf4j
+public final class AppSuspendStep extends AppReconcileStep {
+  /** The states in which the driver is requested, and the attempt has not 
stopped. */
+  private static final Set<ApplicationStateSummary> DRIVER_REQUESTED_STATES =
+      EnumSet.range(
+          ApplicationStateSummary.DriverRequested,
+          ApplicationStateSummary.RunningWithBelowThresholdExecutors);
+
+  /**
+   * Reconciles an application whose driver is requested, or which is 
suspended, against
+   * spec.suspend.
+   *
+   * @param context The SparkAppContext for the application.
+   * @param statusRecorder The SparkAppStatusRecorder for recording status 
updates.
+   * @return The ReconcileProgress indicating the next step.
+   */
+  @Override
+  public ReconcileProgress reconcile(
+      SparkAppContext context, SparkAppStatusRecorder statusRecorder) {
+    SparkApplication app = context.getResource();
+    boolean suspend = app.getSpec().isSuspend();
+    ApplicationStateSummary summary = 
app.getStatus().getCurrentState().getCurrentStateSummary();
+    if (DRIVER_REQUESTED_STATES.contains(summary) && suspend) {
+      Optional<ApplicationState> termination;
+      try {
+        termination = observeDriverTermination(context);
+      } catch (KubernetesClientException e) {
+        log.warn("Failed to look up the driver of the application to suspend, 
will retry.", e);
+        return completeAndDefaultRequeue();
+      }
+      if (termination.isPresent()) {
+        return appendStateAndImmediateRequeue(context, statusRecorder, 
termination.get());
+      }
+      // A rejected update, e.g. of a state unknown to an older CRD, would be 
rejected again right
+      // away, so it is retried with the default interval, not immediately.
+      return statusRecorder.appendNewStateAndPersist(
+              context,
+              new ApplicationState(ApplicationStateSummary.Suspended, 
APP_SUSPENDED_MESSAGE))
+          ? completeAndImmediateRequeue()
+          : completeAndDefaultRequeue();
+    }
+    if (summary != ApplicationStateSummary.Suspended) {
+      return proceed();
+    }
+    Optional<ReconcileProgress> waiting = releaseResources(context);
+    if (waiting.isPresent()) {
+      return waiting.get();
+    }
+    if (suspend) {
+      return completeAndRequeueAfter(
+          
Duration.ofSeconds(SUSPEND_HOLD_REQUEUE_INTERVAL_SECONDS.getValue()));
+    }
+    return statusRecorder.persistStatus(
+            context,
+            app.getStatus()
+                .resume(APP_RESUMED_MESSAGE, 
TRIM_ATTEMPT_STATE_TRANSITION_HISTORY.getValue()))
+        ? completeAndImmediateRequeue()
+        : completeAndDefaultRequeue();
+  }
+
+  /**
+   * Observes whether the driver of the attempt completed or failed, as the 
API server knows it. A
+   * driver which is being deleted, e.g. one of an earlier attempt, is not 
looked at, see {@link
+   * SparkAppContext#isLiveDriverPod}.
+   *
+   * @param context The SparkAppContext for the application.
+   * @return The state which ends the attempt if its driver completed or 
failed, or empty.
+   * @throws KubernetesClientException if the driver pods cannot be listed.
+   */
+  private static Optional<ApplicationState> 
observeDriverTermination(SparkAppContext context) {
+    SparkApplication app = context.getResource();
+    AppDriverRunningObserver observer = new AppDriverRunningObserver();
+    return listPods(context, LABEL_SPARK_ROLE_DRIVER_VALUE).stream()
+        .filter(pod -> SparkAppContext.isLiveDriverPod(app, pod))
+        .map(pod -> observer.observe(pod, app.getSpec(), app.getStatus()))
+        .flatMap(Optional::stream)
+        .findFirst();
+  }
+
+  /**
+   * Releases the driver of a suspended application, and then its Kueue 
Workload. Like at the end
+   * of an attempt, only the driver pod is deleted, whatever the 
resourceRetainPolicy is, and its
+   * executors and the resources which it owns are deleted with it. The pods 
are listed on the API
+   * server, so that every driver pod is deleted, including one which the 
informer cache lost, or
+   * one besides a driver of an earlier attempt which is still terminating. 
Unlike at the end of an
+   * attempt, the deletion is not waited for here, since it is observed by the 
pod informer, which
+   * reconciles again.
+   *
+   * <p>The Workload is released only after the driver and executor pods are 
gone, since Kueue would
+   * admit another workload into the quota which the terminating pods still 
occupy, even if the
+   * queue label was removed after the admission. Every application waits for 
its pods, with or
+   * without a Workload, since the resumed attempt starts without a restart 
backoff, and its driver
+   * observers would take a driver of the suspended attempt which is still 
terminating for theirs.
+   * A pod which is still terminating `forceTerminationGracePeriodMillis` 
after its grace period
+   * ended, e.g. on a lost node, no longer holds the application, and a driver 
pod is force deleted
+   * then, so that it does not keep its pod object. Like for a suspended 
SparkCluster, it is
+   * measured per pod rather than since Suspended, so that a release which 
failed or was delayed
+   * for that long still waits for the pods it deletes.
+   *
+   * <p>Everything here is idempotent, so pods that are not gone yet, or a 
release that failed, are
+   * simply looked at again. A failure which is not expected to clear on its 
own is reported, since
+   * Suspended is a steady state which would not tell it apart from waiting 
for the pods to go.
+   *
+   * @param context The SparkAppContext for the application.
+   * @return Empty once everything is released, or the ReconcileProgress to 
retry with while pods
+   *     remain or a release failed.
+   */
+  private Optional<ReconcileProgress> releaseResources(SparkAppContext 
context) {
+    SparkApplication app = context.getResource();
+    ApplicationTimeoutConfig timeoutConfig =
+        
app.getSpec().getApplicationTolerations().getApplicationTimeoutConfig();
+    KubernetesClient client = context.getClient();
+    try {
+      List<Pod> pods =
+          listPods(context, LABEL_SPARK_ROLE_DRIVER_VALUE, 
LABEL_SPARK_ROLE_EXECUTOR_VALUE);
+      Instant now = Instant.now();
+      boolean waiting = false;
+      Instant nextDeadline = Instant.MAX;
+      for (Pod pod : pods) {
+        Instant deadline = getPodReleaseDeadline(pod, timeoutConfig);
+        boolean holding = now.isBefore(deadline);
+        boolean driver =
+            LABEL_SPARK_ROLE_DRIVER_VALUE.equals(
+                pod.getMetadata().getLabels().get(LABEL_SPARK_ROLE_NAME));
+        if (driver && !holding) {
+          client.resource(pod).withGracePeriod(0L).delete();
+        } else if (driver && pod.getMetadata().getDeletionTimestamp() == null) 
{
+          // A driver which is terminating already is not deleted again until 
it is force deleted
+          
client.resource(pod).withPropagationPolicy(DeletionPropagation.FOREGROUND).delete();
+        }
+        if (holding && deadline.isBefore(nextDeadline)) {
+          nextDeadline = deadline;
+        }
+        waiting |= holding;
+      }
+      if (waiting) {
+        // The deletion of each pod is observed by the pod informer, while the 
end of the wait for
+        // a pod which may send no more events is observed by the requeue
+        log.debug("Waiting for the driver and executor pods of the suspended 
app to be deleted.");
+        ReconcileProgress defaultRequeue = completeAndDefaultRequeue();
+        Duration remaining = Duration.between(now, nextDeadline);
+        return Optional.of(
+            remaining.compareTo(defaultRequeue.getRequeueAfterDuration()) < 0
+                ? completeAndRequeueAfter(remaining)
+                : defaultRequeue);
+      }
+      // A Workload admitted before the queue label was removed is released as 
well.
+      KueueWorkloadUtils.deleteWorkloadOf(client, app);
+    } catch (KubernetesClientException e) {
+      log.warn("Failed to release the resources of the suspended application, 
will retry.", e);
+      if (!ReconcilerUtils.isTransientError(e)) {
+        EventUtils.warn(
+            context.getEventRecorder(),
+            EventUtils.REASON_SUSPEND_RELEASE_FAILED,
+            "Failed to release the driver and executors or the Kueue Workload 
of the suspended "
+                + "application, will retry. "
+                + EventUtils.describe(e));
+      }
+      return Optional.of(completeAndDefaultRequeue());
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Lists the pods of the application with the given Spark roles on the API 
server, including the
+   * ones which are being deleted. Other pods which carry the application 
label do not count.
+   *
+   * @param context The SparkAppContext for the application.
+   * @param roles The values of the Spark role label of the pods to list.
+   * @return The pods of the application with the given roles.
+   * @throws KubernetesClientException if the pods cannot be listed.
+   */
+  private static List<Pod> listPods(SparkAppContext context, String... roles) {
+    SparkApplication app = context.getResource();
+    return ReconcilerUtils.podsOf(
+            context.getClient(),
+            app.getMetadata().getNamespace(),
+            LABEL_SPARK_APPLICATION_NAME,
+            app.getMetadata().getName(),
+            roles)
+        .list()
+        .getItems();
+  }
+
+  /**
+   * Returns when a driver or executor pod stops holding a suspended 
application, and a driver pod
+   * is force deleted.
+   *
+   * @param pod The driver or executor pod.
+   * @param timeoutConfig The ApplicationTimeoutConfig of the application.
+   * @return `forceTerminationGracePeriodMillis` after its deletionTimestamp, 
when its grace period
+   *     ends, or Instant.MAX while it is not being deleted yet.
+   */
+  private static Instant getPodReleaseDeadline(Pod pod, 
ApplicationTimeoutConfig timeoutConfig) {
+    String deletionTimestamp = pod.getMetadata().getDeletionTimestamp();
+    return deletionTimestamp == null
+        ? Instant.MAX
+        : Instant.parse(deletionTimestamp)
+            .plusMillis(timeoutConfig.getForceTerminationGracePeriodMillis());
+  }
+}
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/SuspendUtils.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/SuspendUtils.java
index 86f684fe..c303d681 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/SuspendUtils.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/SuspendUtils.java
@@ -50,8 +50,9 @@ final class SuspendUtils {
    * of the one Event instead of creating another and refreshes it, so that 
the hold stays visible
    * past the event retention of the API server. A suspended first attempt has 
no persisted status
    * to fall back on, since its initial Submitted state is never written. An 
application held
-   * later, in ScheduledToRestart, does have the status of the previous 
attempt, but that status
-   * does not say that the next attempt is withheld by spec.suspend, so it 
gets the same event.
+   * later, in ScheduledToRestart, or in Submitted after it was resumed from 
Suspended, does have a
+   * persisted status, but that status does not say that the next attempt is 
withheld by
+   * spec.suspend, so it gets the same event.
    *
    * <p>Unlike the Kueue hold, which ends when quota arrives, this one ends 
only when a user clears
    * spec.suspend, so the republishing is paced by {@link
diff --git 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
index a33384f3..376fa3a3 100644
--- 
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
+++ 
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/utils/EventUtils.java
@@ -68,8 +68,8 @@ public final class EventUtils {
   public static final String REASON_SUSPEND_HELD = "SuspendHeld";
 
   /**
-   * Reason for an event describing a failure to release the resources of a 
SparkCluster which is
-   * suspended while running.
+   * Reason for an event describing a failure to release the resources of a 
SparkApplication or a
+   * SparkCluster which is suspended while running.
    */
   public static final String REASON_SUSPEND_RELEASE_FAILED = 
"SuspendReleaseFailed";
 
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconcilerTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconcilerTest.java
index 572645dc..fc6ad9b5 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconcilerTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/SparkAppReconcilerTest.java
@@ -71,6 +71,8 @@ import 
io.javaoperatorsdk.operator.processing.event.ResourceID;
 import 
io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 import org.mockito.ArgumentCaptor;
 import org.mockito.MockedConstruction;
 import org.mockito.MockedStatic;
@@ -85,9 +87,11 @@ import 
org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils;
 import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
 import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadStatus;
 import org.apache.spark.k8s.operator.metrics.healthcheck.SentinelManager;
+import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppCleanUpStep;
 import 
org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppKueueEvictionStep;
 import 
org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppReconcileStep;
-import 
org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppResourceObserveStep;
+import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppSuspendStep;
+import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppValidateStep;
 import org.apache.spark.k8s.operator.spec.ApplicationSpec;
 import org.apache.spark.k8s.operator.spec.ApplicationTolerations;
 import org.apache.spark.k8s.operator.spec.RestartConfig;
@@ -102,6 +106,11 @@ import 
org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
 import org.apache.spark.k8s.operator.utils.Utils;
 
 class SparkAppReconcilerTest {
+  private static final Set<ApplicationStateSummary> DRIVER_REQUESTED_STATES =
+      EnumSet.range(
+          ApplicationStateSummary.DriverRequested,
+          ApplicationStateSummary.RunningWithBelowThresholdExecutors);
+
   private final SparkAppStatusRecorder mockRecorder = 
mock(SparkAppStatusRecorder.class);
   private final SentinelManager<SparkApplication> mockSentinelManager = 
mock(SentinelManager.class);
   private final KubernetesClient mockClient = mock(KubernetesClient.class);
@@ -529,10 +538,13 @@ class SparkAppReconcilerTest {
     }
   }
 
-  @Test
-  void 
observeStepsRecordDriverCompletedBeforeFirstObservationAsSucceededOnly() {
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void stepsRecordDriverCompletedBeforeFirstObservationAsSucceededOnly(boolean 
suspend) {
     app.setMetadata(new 
ObjectMetaBuilder().withName("app").withNamespace("default").build());
     app.setSpec(new ApplicationSpec());
+    // Even if suspended, the attempt ends as usual rather than being run 
again on resume
+    app.getSpec().setSuspend(suspend);
     Pod driver =
         new PodBuilder()
             .withNewMetadata()
@@ -565,14 +577,16 @@ class SparkAppReconcilerTest {
             });
 
     for (ApplicationStateSummary summary :
-        List.of(ApplicationStateSummary.DriverRequested, 
ApplicationStateSummary.DriverStarted)) {
+        List.of(
+            ApplicationStateSummary.DriverRequested,
+            ApplicationStateSummary.DriverStarted,
+            ApplicationStateSummary.RunningHealthy)) {
       app.setStatus(new ApplicationStatus().appendNewState(new 
ApplicationState(summary, "")));
       int previousSize = app.getStatus().getStateTransitionHistory().size();
       SparkAppContext context = new SparkAppContext(app, mockContext, 
mockWorker);
-      // Run the observe steps selected by the reconciler until one completes 
the reconciliation
+      // Run the steps selected by the reconciler until one completes the 
reconciliation
       for (AppReconcileStep step : reconciler.getReconcileSteps(app)) {
-        if (step instanceof AppResourceObserveStep
-            && step.reconcile(context, recorder).isCompleted()) {
+        if (step.reconcile(context, recorder).isCompleted()) {
           break;
         }
       }
@@ -586,16 +600,36 @@ class SparkAppReconcilerTest {
     }
   }
 
+  @Test
+  void suspendStepRunsLastOnceTheDriverIsRequestedAndAloneWhileSuspended() {
+    for (ApplicationStateSummary summary : ApplicationStateSummary.values()) {
+      app.setStatus(new ApplicationStatus().appendNewState(new 
ApplicationState(summary, "")));
+      List<AppReconcileStep> steps = reconciler.getReconcileSteps(app);
+      if (summary == ApplicationStateSummary.Suspended) {
+        // Not handled as an unknown state, which would fail the application
+        assertEquals(
+            List.of(AppValidateStep.class, AppCleanUpStep.class, 
AppSuspendStep.class),
+            steps.stream().map(Object::getClass).toList());
+      } else {
+        boolean expected = DRIVER_REQUESTED_STATES.contains(summary);
+        // After the observers, so that a driver which completed meanwhile is 
recorded rather than
+        // suspended and run again when the application is resumed
+        assertEquals(
+            expected, steps.get(steps.size() - 1) instanceof AppSuspendStep, 
summary.name());
+        assertEquals(
+            expected ? 1 : 0,
+            steps.stream().filter(AppSuspendStep.class::isInstance).count(),
+            summary.name());
+      }
+    }
+  }
+
   @Test
   void kueueEvictionIsReportedFromTheDriverRequestUntilTheAttemptStops() {
-    Set<ApplicationStateSummary> driverRequested =
-        EnumSet.range(
-            ApplicationStateSummary.DriverRequested,
-            ApplicationStateSummary.RunningWithBelowThresholdExecutors);
     for (ApplicationStateSummary summary : ApplicationStateSummary.values()) {
       app.setStatus(new ApplicationStatus().appendNewState(new 
ApplicationState(summary, "")));
       List<AppReconcileStep> steps = reconciler.getReconcileSteps(app);
-      boolean expected = driverRequested.contains(summary);
+      boolean expected = DRIVER_REQUESTED_STATES.contains(summary);
       // Right after the validation and the clean up, before a state 
transition ends the
       // reconciliation. Before the driver is requested, AppInitStep handles 
the eviction itself.
       assertEquals(expected, steps.get(2) instanceof AppKueueEvictionStep, 
summary.name());
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
index 5bcc3db6..ca185ff3 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppInitStepTest.java
@@ -409,6 +409,42 @@ class AppInitStepTest {
         application.getStatus().getCurrentState().getCurrentStateSummary());
   }
 
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void resumedAttemptDoesNotWaitForRestartBackoff(boolean 
trimStateTransitionHistory) {
+    // An attempt which starts as the application is resumed from Suspended is 
not a restart
+    AppInitStep appInitStep = new AppInitStep();
+    SparkAppContext mockContext = mock(SparkAppContext.class);
+    SparkAppStatusRecorder recorder = mock(SparkAppStatusRecorder.class);
+    SparkApplication application = new SparkApplication();
+    application.setMetadata(applicationMetadata);
+    application.getSpec().setApplicationTolerations(
+        ApplicationTolerations.builder()
+            
.restartConfig(RestartConfig.builder().restartBackoffMillis(60000L).build())
+            .build());
+    application.setStatus(
+        new ApplicationStatus()
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.DriverRequested, ""))
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.Suspended, ""))
+            .resume("resumed", trimStateTransitionHistory));
+    when(mockContext.getResource()).thenReturn(application);
+    when(mockContext.getDriverPreResourcesSpec()).thenReturn(List.of());
+    when(mockContext.getDriverPodSpec()).thenReturn(driverPodSpec);
+    when(mockContext.getDriverResourcesSpec()).thenReturn(List.of());
+    when(mockContext.getClient()).thenReturn(kubernetesClient);
+    when(recorder.persistStatus(any(), any())).thenAnswer(invocation -> {
+      application.setStatus(invocation.getArgument(1));
+      return true;
+    });
+
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndDefaultRequeue(),
+        appInitStep.reconcile(mockContext, recorder));
+    Assertions.assertEquals(
+        ApplicationStateSummary.DriverRequested,
+        application.getStatus().getCurrentState().getCurrentStateSummary());
+  }
+
   @Test
   void suspendedAppDoesNotRequestDriver() {
     AppInitStep appInitStep = new AppInitStep();
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStepTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStepTest.java
index fac5276b..810b656e 100644
--- 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStepTest.java
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStepTest.java
@@ -87,6 +87,7 @@ class AppKueueEvictionStepTest {
     // Kueue keeps counting the quota of an evicted Workload until the driver 
and executors are
     // released
     Assertions.assertTrue(message.contains("holding its quota"), message);
+    Assertions.assertTrue(message.contains("Set spec.suspend to true"), 
message);
     verifyNoInteractions(recorder);
   }
 
diff --git 
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppSuspendStepTest.java
 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppSuspendStepTest.java
new file mode 100644
index 00000000..5d8cde58
--- /dev/null
+++ 
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppSuspendStepTest.java
@@ -0,0 +1,729 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.spark.k8s.operator.reconciler.reconcilesteps;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
+import io.fabric8.kubernetes.api.model.Pod;
+import io.fabric8.kubernetes.api.model.PodBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientException;
+import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
+import io.fabric8.mockwebserver.http.RecordedRequest;
+import io.javaoperatorsdk.operator.api.event.EventRecord;
+import io.javaoperatorsdk.operator.api.event.EventType;
+import io.javaoperatorsdk.operator.api.event.ResourceEventRecorder;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.mockito.ArgumentCaptor;
+
+import org.apache.spark.k8s.operator.Constants;
+import org.apache.spark.k8s.operator.SparkApplication;
+import org.apache.spark.k8s.operator.config.SparkOperatorConf;
+import org.apache.spark.k8s.operator.context.SparkAppContext;
+import org.apache.spark.k8s.operator.kueue.KueueWorkloadFactory;
+import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
+import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
+import org.apache.spark.k8s.operator.spec.ResourceRetainPolicy;
+import org.apache.spark.k8s.operator.status.ApplicationState;
+import org.apache.spark.k8s.operator.status.ApplicationStateSummary;
+import org.apache.spark.k8s.operator.status.ApplicationStatus;
+import org.apache.spark.k8s.operator.utils.EventUtils;
+import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
+import org.apache.spark.k8s.operator.utils.TestUtils;
+import org.apache.spark.k8s.operator.utils.Utils;
+
+@EnableKubernetesMockClient(crud = true)
+class AppSuspendStepTest {
+  private static final String DRIVER = "app1-0-driver";
+  private static final String EXECUTOR = "app1-0-exec-1";
+  // The driver of the attempt after the one of DRIVER
+  private static final String NEXT_DRIVER = "app1-1-driver";
+  // Keeps a deleted pod in the mock server, like the grace period of a 
terminating pod
+  private static final String FINALIZER = "example.com/finalizer";
+
+  private KubernetesMockServer server;
+  private KubernetesClient kubernetesClient;
+
+  // The default of 
spark.kubernetes.operator.reconciler.suspendHoldRequeueIntervalSeconds
+  private static final ReconcileProgress SUSPEND_HOLD_PROGRESS =
+      ReconcileProgress.completeAndRequeueAfter(Duration.ofMinutes(30));
+
+  // The deletion of each pod is observed by the pod informer, which 
reconciles again
+  private static final ReconcileProgress WAITING_FOR_PODS_PROGRESS =
+      ReconcileProgress.completeAndDefaultRequeue();
+
+  private final SparkAppContext mockContext = mock(SparkAppContext.class);
+  private final SparkAppStatusRecorder recorder = 
mock(SparkAppStatusRecorder.class);
+  private final ResourceEventRecorder eventRecorder = 
mock(ResourceEventRecorder.class);
+
+  @BeforeEach
+  void enableKueue() {
+    TestUtils.setConfigKey(SparkOperatorConf.KUEUE_ENABLED, true);
+  }
+
+  @AfterEach
+  void disableKueue() {
+    TestUtils.setConfigKey(SparkOperatorConf.KUEUE_ENABLED, false);
+  }
+
+  @ParameterizedTest
+  @EnumSource(
+      value = ApplicationStateSummary.class,
+      from = "DriverRequested",
+      to = "RunningWithBelowThresholdExecutors")
+  void runningAppWithoutSuspendProceeds(ApplicationStateSummary summary) {
+    SparkApplication app = buildApp(summary, false);
+    stubContext(app);
+    createDriver(app);
+
+    Assertions.assertEquals(
+        ReconcileProgress.proceed(), new 
AppSuspendStep().reconcile(mockContext, recorder));
+
+    verifyNoInteractions(recorder);
+    Assertions.assertNotNull(getDriver());
+  }
+
+  @ParameterizedTest
+  @EnumSource(
+      value = ApplicationStateSummary.class,
+      from = "DriverRequested",
+      to = "RunningWithBelowThresholdExecutors")
+  void suspendingRunningAppEntersSuspendedBeforeReleasingResources(
+      ApplicationStateSummary summary) {
+    SparkApplication app = buildKueueApp(summary, true);
+    stubContext(app);
+    createDriver(app);
+    createWorkload(app);
+
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndImmediateRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    ApplicationState state = captureAppendedState();
+    Assertions.assertEquals(ApplicationStateSummary.Suspended, 
state.getCurrentStateSummary());
+    Assertions.assertEquals(Constants.APP_SUSPENDED_MESSAGE, 
state.getMessage());
+    // Nothing is released until Suspended is persisted, so that an 
application which is resumed
+    // in the meantime is never left running without its driver, which would 
fail it.
+    Assertions.assertNotNull(getDriver());
+    Assertions.assertNotNull(getWorkload());
+  }
+
+  @Test
+  void suspendedStateWhichIsNotPersistedIsRetriedLater() {
+    SparkApplication app = buildApp(ApplicationStateSummary.RunningHealthy, 
true);
+    stubContext(app);
+    createDriver(app);
+    when(recorder.appendNewStateAndPersist(any(), any())).thenReturn(false);
+
+    // A status which is rejected, e.g. by a CRD without the Suspended state, 
is not retried at once
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndDefaultRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    Assertions.assertNotNull(getDriver());
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"Succeeded", "Failed"})
+  void attemptWhoseDriverEndedMeanwhileEndsAsUsual(String phase) {
+    SparkApplication app = 
buildKueueApp(ApplicationStateSummary.RunningHealthy, true);
+    stubContext(app);
+    // The API server knows that the driver ended, while the informer cache 
which the driver
+    // observers read may not show it yet
+    kubernetesClient
+        .resource(new 
PodBuilder(driver(app)).withNewStatus().withPhase(phase).endStatus().build())
+        .create();
+    createWorkload(app);
+
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndImmediateRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    // The attempt ends rather than being suspended and run again from scratch 
on resume
+    Assertions.assertEquals(
+        ApplicationStateSummary.valueOf(phase), 
captureAppendedState().getCurrentStateSummary());
+    Assertions.assertNotNull(getDriver());
+    Assertions.assertNotNull(getWorkload());
+  }
+
+  @Test
+  void terminatingDriverOfEarlierAttemptDoesNotEndTheAttempt() {
+    SparkApplication app = buildApp(ApplicationStateSummary.RunningHealthy, 
true);
+    stubContext(app);
+    // The failed driver of an earlier attempt, which is still terminating
+    kubernetesClient
+        .resource(
+            withFinalizers(
+                new 
PodBuilder(driver(app)).withNewStatus().withPhase("Failed").endStatus().build(),
+                FINALIZER))
+        .create();
+    kubernetesClient.resource(driver(app)).delete();
+    kubernetesClient.resource(pod(NEXT_DRIVER, 
Utils.driverLabels(app))).create();
+
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndImmediateRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    Assertions.assertEquals(
+        ApplicationStateSummary.Suspended, 
captureAppendedState().getCurrentStateSummary());
+  }
+
+  @Test
+  void suspensionWaitsUntilTheDriverCanBeLookedUp() {
+    SparkApplication app = buildApp(ApplicationStateSummary.RunningHealthy, 
true);
+    KubernetesClient client = spy(kubernetesClient);
+    doThrow(new KubernetesClientException("Service Unavailable", 503, 
null)).when(client).pods();
+    stubContext(app, client);
+
+    // Whether the driver ended meanwhile is unknown, so the application is 
not suspended yet
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndDefaultRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    verifyNoInteractions(recorder, eventRecorder);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void suspendedAppKeepsKueueWorkloadWhilePodsRemain(boolean suspend) throws 
Exception {
+    // Resuming waits as well, so that the new attempt does not run on the 
admission of the
+    // suspended one
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
suspend);
+    stubContext(app);
+    createDriver(app, FINALIZER);
+    createWorkload(app);
+
+    Assertions.assertEquals(
+        WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+
+    // The driver is deleted with its executors and the resources it owns, 
without forcing it
+    // within the grace period
+    List<String> deletions = podDeleteOptions();
+    Assertions.assertEquals(1, deletions.size(), deletions.toString());
+    
Assertions.assertTrue(deletions.get(0).contains("\"propagationPolicy\":\"Foreground\""));
+    
Assertions.assertFalse(deletions.get(0).contains("\"gracePeriodSeconds\":0"));
+    Assertions.assertNotNull(getDriver().getMetadata().getDeletionTimestamp());
+    // The driver is still terminating, so it keeps holding the quota of the 
Workload
+    Assertions.assertNotNull(getWorkload());
+
+    // The terminating driver is not deleted again while it is waited for
+    Assertions.assertEquals(
+        WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+    Assertions.assertEquals(List.of(), podDeleteOptions());
+    verifyNoInteractions(recorder, eventRecorder);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void podsOfAppWithoutKueueAreWaitedForAsWell(boolean suspend) {
+    // A resumed application starts its new attempt without a restart backoff, 
and the observers
+    // of that attempt would take a driver of the suspended one for theirs
+    TestUtils.setConfigKey(SparkOperatorConf.KUEUE_ENABLED, false);
+    SparkApplication app = buildApp(ApplicationStateSummary.Suspended, 
suspend);
+    stubContext(app);
+    createDriver(app);
+    createExecutor(app);
+
+    Assertions.assertEquals(
+        WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+
+    Assertions.assertNull(getDriver());
+    verifyNoInteractions(recorder);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void podsAreWaitedForWhenTheReleaseStartsLate(boolean suspend) throws 
Exception {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
suspend);
+    // Suspended for longer than forceTerminationGracePeriodMillis, 5 minutes 
by default, e.g. as
+    // the operator was down meanwhile or the deletion kept failing
+    app.getStatus()
+        .getCurrentState()
+        
.setLastTransitionTime(Instant.now().minus(Duration.ofMinutes(6)).toString());
+    stubContext(app);
+    createDriver(app, FINALIZER);
+    createExecutor(app);
+    createWorkload(app);
+
+    Assertions.assertEquals(
+        WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+
+    // The wait is measured per pod, so the driver still gets its grace 
period, and the quota which
+    // its executors occupy is not given back yet
+    List<String> deletions = podDeleteOptions();
+    Assertions.assertEquals(1, deletions.size(), deletions.toString());
+    
Assertions.assertTrue(deletions.get(0).contains("\"propagationPolicy\":\"Foreground\""));
+    
Assertions.assertFalse(deletions.get(0).contains("\"gracePeriodSeconds\":0"));
+    Assertions.assertNotNull(getWorkload());
+    verifyNoInteractions(recorder, eventRecorder);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void liveDriverIsReleasedBesideTerminatingDriverOfEarlierAttempt(boolean 
suspend)
+      throws Exception {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
suspend);
+    stubContext(app);
+    // The driver of an earlier attempt, which is still terminating, e.g. on a 
lost node
+    createDriver(app, FINALIZER);
+    kubernetesClient.resource(driver(app)).delete();
+    kubernetesClient.resource(pod(NEXT_DRIVER, 
Utils.driverLabels(app))).create();
+    createWorkload(app);
+    Assertions.assertEquals(1, podDeleteOptions().size());
+
+    Assertions.assertEquals(
+        WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+
+    // Every driver is looked at, so the one of the suspended attempt is 
deleted as well, while the
+    // terminating one is not deleted again within its grace period
+    List<String> deletions = podDeleteOptions();
+    Assertions.assertEquals(1, deletions.size(), deletions.toString());
+    
Assertions.assertTrue(deletions.get(0).contains("\"propagationPolicy\":\"Foreground\""));
+    Assertions.assertNull(getPod(NEXT_DRIVER));
+    Assertions.assertNotNull(getWorkload());
+    verifyNoInteractions(recorder, eventRecorder);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void podsWhichOutliveTheGracePeriodNoLongerHoldTheRelease(boolean suspend) 
throws Exception {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
suspend);
+    // The wait for a pod ends this long after its own grace period ended, at 
its deletionTimestamp
+    app.getSpec()
+        .getApplicationTolerations()
+        .getApplicationTimeoutConfig()
+        .setForceTerminationGracePeriodMillis(0L);
+    stubContext(app);
+    createDriver(app, FINALIZER);
+    createExecutor(app, FINALIZER);
+    createWorkload(app);
+    // Pods which are still terminating, e.g. on a lost node
+    kubernetesClient.resource(driver(app)).delete();
+    kubernetesClient.resource(executor(app)).delete();
+    Assertions.assertEquals(2, podDeleteOptions().size());
+
+    Assertions.assertEquals(
+        suspend ? SUSPEND_HOLD_PROGRESS : 
ReconcileProgress.completeAndImmediateRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    // The terminating driver is force deleted, so that it no longer keeps its 
pod object
+    List<String> deletions = podDeleteOptions();
+    Assertions.assertEquals(1, deletions.size(), deletions.toString());
+    
Assertions.assertTrue(deletions.get(0).contains("\"gracePeriodSeconds\":0"));
+    Assertions.assertNull(getWorkload());
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void waitIsRequeuedAtTheNextPodDeadline(boolean suspend) {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
suspend);
+    app.getSpec()
+        .getApplicationTolerations()
+        .getApplicationTimeoutConfig()
+        
.setForceTerminationGracePeriodMillis(Duration.ofMinutes(1).toMillis());
+    stubContext(app);
+    // A driver which is still terminating, e.g. on a lost node, and its 
executor which is not
+    // deleted yet
+    createDriver(app, FINALIZER);
+    kubernetesClient.resource(driver(app)).delete();
+    createExecutor(app);
+    createWorkload(app);
+
+    ReconcileProgress progress = new AppSuspendStep().reconcile(mockContext, 
recorder);
+
+    // The wait is requeued to end when the driver has been terminating for 
the timeout, rather
+    // than after the default interval, since a driver on a lost node may send 
no more events
+    Assertions.assertTrue(progress.isCompleted());
+    Duration requeueAfter = progress.getRequeueAfterDuration();
+    Assertions.assertTrue(
+        requeueAfter.compareTo(Duration.ofSeconds(30)) > 0
+            && requeueAfter.compareTo(Duration.ofMinutes(1)) <= 0,
+        requeueAfter::toString);
+    Assertions.assertNotNull(getWorkload());
+    verifyNoInteractions(recorder, eventRecorder);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void waitIsNotRequeuedAtPassedPodDeadlines(boolean suspend) {
+    SparkApplication app = buildApp(ApplicationStateSummary.Suspended, 
suspend);
+    app.getSpec()
+        .getApplicationTolerations()
+        .getApplicationTimeoutConfig()
+        .setForceTerminationGracePeriodMillis(0L);
+    stubContext(app);
+    createDriver(app);
+    // An executor which is still terminating, e.g. on a lost node
+    createExecutor(app, FINALIZER);
+    kubernetesClient.resource(executor(app)).delete();
+
+    // Only the driver, whose deletion is observed by the pod informer, holds 
the application, so
+    // the executor which no longer holds it does not requeue the wait at once
+    Assertions.assertEquals(
+        WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+    Assertions.assertNull(getDriver());
+  }
+
+  @ParameterizedTest
+  @EnumSource(ResourceRetainPolicy.class)
+  void suspendedAppReleasesKueueWorkloadAfterPodsAreGone(ResourceRetainPolicy 
policy) {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
true);
+    app.getSpec().getApplicationTolerations().setResourceRetainPolicy(policy);
+    stubContext(app);
+    createDriver(app);
+    createWorkload(app);
+
+    // The driver which is deleted is waited for
+    Assertions.assertEquals(
+        WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+    Assertions.assertNull(getDriver());
+    Assertions.assertNotNull(getWorkload());
+
+    // The quota is given back whatever the retain policy is, and a resumed 
application starts a
+    // new attempt, which does not reuse the resources of the suspended one
+    Assertions.assertEquals(
+        SUSPEND_HOLD_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+
+    Assertions.assertNull(getWorkload());
+    // Unlike an application held before its driver is requested, its status 
says that it is
+    // suspended, so no SuspendHeld event is published
+    verifyNoInteractions(recorder, eventRecorder);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void workloadIsReleasedAfterQueueLabelIsRemoved(boolean suspend) {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
suspend);
+    createWorkload(app);
+    app.getMetadata().setLabels(Map.of());
+    stubContext(app);
+
+    new AppSuspendStep().reconcile(mockContext, recorder);
+
+    // The Workload admitted before the label was removed neither keeps the 
quota while suspended,
+    // nor lends its admission to a resumed application whose label is added 
back
+    Assertions.assertNull(getWorkload());
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  void kueueWorkloadIsNotAccessedWhenKueueIsDisabled(boolean suspend) {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
suspend);
+    createWorkload(app);
+    KubernetesClient client = spy(kubernetesClient);
+    stubContext(app, client);
+    TestUtils.setConfigKey(SparkOperatorConf.KUEUE_ENABLED, false);
+
+    Assertions.assertEquals(
+        suspend ? SUSPEND_HOLD_PROGRESS : 
ReconcileProgress.completeAndImmediateRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    // The queue label is ignored, and neither the suspended nor the resumed 
application calls Kueue
+    verify(client, never()).resources(Workload.class);
+    Assertions.assertNotNull(getWorkload());
+  }
+
+  @ParameterizedTest
+  @ValueSource(ints = {403, 429, 500})
+  void persistentReleaseFailureIsReportedAndRetried(int code) {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
false);
+    createDriver(app);
+    KubernetesClient client = spy(kubernetesClient);
+    // Unlike a timeout or a 503, these do not clear on their own, e.g. an 
admission webhook which
+    // is down keeps failing with 500, so they are reported
+    doThrow(new KubernetesClientException("failed", code, null))
+        .when(client)
+        .resource(any(Pod.class));
+    stubContext(app, client);
+
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndDefaultRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    EventRecord event = captureEvent();
+    Assertions.assertEquals(EventType.WARNING, event.type());
+    Assertions.assertEquals(EventUtils.REASON_SUSPEND_RELEASE_FAILED, 
event.reason());
+    // It stays Suspended rather than resuming on what it still holds
+    verify(client, never()).resources(Workload.class);
+    verifyNoInteractions(recorder);
+  }
+
+  @Test
+  void transientReleaseFailureIsRetriedWithoutReport() {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
false);
+    createDriver(app);
+    KubernetesClient client = spy(kubernetesClient);
+    doThrow(new KubernetesClientException("Service Unavailable", 503, null))
+        .when(client)
+        .resource(any(Pod.class));
+    stubContext(app, client);
+
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndDefaultRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    verify(client, never()).resources(Workload.class);
+    verifyNoInteractions(recorder, eventRecorder);
+  }
+
+  @Test
+  void failedPodListIsRetriedWithoutReleasingKueueWorkload() {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
false);
+    createWorkload(app);
+    KubernetesClient client = spy(kubernetesClient);
+    doThrow(new KubernetesClientException("Service Unavailable", 503, 
null)).when(client).pods();
+    stubContext(app, client);
+
+    // Whether the pods are gone is unknown, so the Workload is kept and 
nothing is resumed
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndDefaultRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    Assertions.assertNotNull(getWorkload());
+    verifyNoInteractions(recorder, eventRecorder);
+  }
+
+  @Test
+  void failedKueueWorkloadReleaseIsReportedAndRetried() {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
false);
+    stubContext(app);
+    createWorkload(app);
+    server
+        .expect()
+        .delete()
+        
.withPath("/apis/kueue.x-k8s.io/v1beta2/namespaces/default/workloads/sparkapplication-app1")
+        .andReturn(403, null)
+        .once();
+
+    // The quota is not given back yet, so the application is neither held for 
the hold interval
+    // nor resumed on the admission of the suspended attempt
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndDefaultRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    Assertions.assertNotNull(getWorkload());
+    Assertions.assertEquals(EventUtils.REASON_SUSPEND_RELEASE_FAILED, 
captureEvent().reason());
+    verifyNoInteractions(recorder);
+  }
+
+  @Test
+  void resumingSuspendedAppStartsNewAttemptOnceEverythingIsReleased() {
+    SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended, 
false);
+    stubContext(app);
+    // Resumed before anything was released: the driver and the Workload are 
still there
+    createDriver(app);
+    createWorkload(app);
+    long lastStateId = app.getStatus().getStateTransitionHistory().lastKey();
+    long attemptId = 
app.getStatus().getCurrentAttemptSummary().getAttemptInfo().getId();
+
+    // The driver which is deleted is waited for, along with the Workload
+    Assertions.assertEquals(
+        WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext, 
recorder));
+    Assertions.assertNull(getDriver());
+    Assertions.assertNotNull(getWorkload());
+    verifyNoInteractions(recorder);
+
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndImmediateRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+
+    ArgumentCaptor<ApplicationStatus> status = 
ArgumentCaptor.forClass(ApplicationStatus.class);
+    verify(recorder).persistStatus(eq(mockContext), status.capture());
+    ApplicationState state = status.getValue().getCurrentState();
+    Assertions.assertEquals(ApplicationStateSummary.Submitted, 
state.getCurrentStateSummary());
+    Assertions.assertEquals(Constants.APP_RESUMED_MESSAGE, state.getMessage());
+    // The history of the suspended attempt moves to the previous attempt, 
like on a restart
+    Assertions.assertEquals(
+        Map.of(lastStateId + 1, state), 
status.getValue().getStateTransitionHistory());
+    // A new attempt, see ApplicationStatus#resume
+    Assertions.assertEquals(
+        attemptId + 1, 
status.getValue().getCurrentAttemptSummary().getAttemptInfo().getId());
+    // Whatever the Workload was admitted for, AppInitStep requests a new 
admission from scratch
+    Assertions.assertNull(getWorkload());
+  }
+
+  @Test
+  void resumeWhichIsNotPersistedIsRetriedLater() {
+    SparkApplication app = buildApp(ApplicationStateSummary.Suspended, false);
+    stubContext(app);
+    when(recorder.persistStatus(any(), any())).thenReturn(false);
+
+    Assertions.assertEquals(
+        ReconcileProgress.completeAndDefaultRequeue(),
+        new AppSuspendStep().reconcile(mockContext, recorder));
+  }
+
+  @ParameterizedTest
+  @EnumSource(
+      value = ApplicationStateSummary.class,
+      mode = EnumSource.Mode.EXCLUDE,
+      names = {
+        "DriverRequested",
+        "DriverStarted",
+        "DriverReady",
+        "InitializedBelowThresholdExecutors",
+        "RunningHealthy",
+        "RunningWithPartialCapacity",
+        "RunningWithBelowThresholdExecutors",
+        "Suspended"
+      })
+  void otherStatesProceed(ApplicationStateSummary summary) {
+    // An initializing application is held by AppInitStep, and a stopping one 
stops as usual
+    SparkApplication app = buildApp(summary, true);
+    stubContext(app);
+    createDriver(app);
+
+    Assertions.assertEquals(
+        ReconcileProgress.proceed(), new 
AppSuspendStep().reconcile(mockContext, recorder));
+
+    verifyNoInteractions(recorder);
+    Assertions.assertNotNull(getDriver());
+  }
+
+  private void stubContext(SparkApplication app) {
+    stubContext(app, kubernetesClient);
+  }
+
+  private void stubContext(SparkApplication app, KubernetesClient client) {
+    when(recorder.appendNewStateAndPersist(any(), any())).thenReturn(true);
+    when(recorder.persistStatus(any(), any())).thenReturn(true);
+    when(mockContext.getResource()).thenReturn(app);
+    when(mockContext.getClient()).thenReturn(client);
+    when(mockContext.getEventRecorder()).thenReturn(eventRecorder);
+  }
+
+  private ApplicationState captureAppendedState() {
+    ArgumentCaptor<ApplicationState> captor = 
ArgumentCaptor.forClass(ApplicationState.class);
+    verify(recorder).appendNewStateAndPersist(eq(mockContext), 
captor.capture());
+    verify(recorder, never()).persistStatus(any(), any());
+    return captor.getValue();
+  }
+
+  private EventRecord captureEvent() {
+    ArgumentCaptor<EventRecord> event = 
ArgumentCaptor.forClass(EventRecord.class);
+    verify(eventRecorder).record(event.capture());
+    return event.getValue();
+  }
+
+  /** Returns the delete options of the pod deletions which the mock server 
received. */
+  private List<String> podDeleteOptions() throws InterruptedException {
+    List<String> deletions = new ArrayList<>();
+    RecordedRequest request = server.takeRequest(0, TimeUnit.SECONDS);
+    while (request != null) {
+      if ("DELETE".equals(request.getMethod()) && 
request.getPath().contains("/pods/")) {
+        deletions.add(request.getUtf8Body());
+      }
+      request = server.takeRequest(0, TimeUnit.SECONDS);
+    }
+    return deletions;
+  }
+
+  private Pod getDriver() {
+    return getPod(DRIVER);
+  }
+
+  private Pod getPod(String name) {
+    return kubernetesClient.pods().inNamespace("default").withName(name).get();
+  }
+
+  private Workload getWorkload() {
+    return kubernetesClient
+        .resources(Workload.class)
+        .inNamespace("default")
+        .withName("sparkapplication-app1")
+        .get();
+  }
+
+  private void createDriver(SparkApplication app, String... finalizers) {
+    kubernetesClient.resource(withFinalizers(driver(app), 
finalizers)).create();
+  }
+
+  private void createExecutor(SparkApplication app, String... finalizers) {
+    kubernetesClient.resource(withFinalizers(executor(app), 
finalizers)).create();
+  }
+
+  private void createWorkload(SparkApplication app) {
+    
kubernetesClient.resource(KueueWorkloadFactory.buildWorkload(app)).create();
+  }
+
+  private static Pod driver(SparkApplication app) {
+    return pod(DRIVER, Utils.driverLabels(app));
+  }
+
+  private static Pod executor(SparkApplication app) {
+    return pod(EXECUTOR, Utils.executorLabels(app));
+  }
+
+  private static Pod pod(String name, Map<String, String> labels) {
+    return new PodBuilder()
+        .withNewMetadata()
+        .withName(name)
+        .withNamespace("default")
+        .withLabels(labels)
+        .endMetadata()
+        .build();
+  }
+
+  private static Pod withFinalizers(Pod pod, String... finalizers) {
+    return new 
PodBuilder(pod).editMetadata().withFinalizers(finalizers).endMetadata().build();
+  }
+
+  private static SparkApplication buildApp(ApplicationStateSummary summary, 
boolean suspend) {
+    SparkApplication app = new SparkApplication();
+    app.setMetadata(
+        new ObjectMetaBuilder()
+            .withName("app1")
+            .withNamespace("default")
+            .withUid("app-uid")
+            .build());
+    app.getSpec().setSuspend(suspend);
+    app.setStatus(app.getStatus().appendNewState(new ApplicationState(summary, 
"")));
+    return app;
+  }
+
+  private static SparkApplication buildKueueApp(ApplicationStateSummary 
summary, boolean suspend) {
+    SparkApplication app = buildApp(summary, suspend);
+    app.getMetadata().setLabels(Map.of(Constants.LABEL_QUEUE_NAME, 
"test-queue"));
+    return app;
+  }
+}
diff --git a/tests/e2e/kueue/chainsaw-test.yaml 
b/tests/e2e/kueue/chainsaw-test.yaml
index 3ae9a944..17981438 100644
--- a/tests/e2e/kueue/chainsaw-test.yaml
+++ b/tests/e2e/kueue/chainsaw-test.yaml
@@ -1052,6 +1052,76 @@ spec:
           status:
             currentState:
               currentStateSummary: RunningHealthy
+    # As the event advises, suspending the application releases its driver and 
executors, and then
+    # the quota which its evicted Workload keeps
+    - script:
+        timeout: 30s
+        content: |
+          kubectl patch sparkapplication 
spark-job-kueue-executors-not-ready-test -n default --type merge -p 
'{"spec":{"suspend":true}}'
+    - assert:
+        timeout: 60s
+        resource:
+          apiVersion: spark.apache.org/v1
+          kind: SparkApplication
+          metadata:
+            name: spark-job-kueue-executors-not-ready-test
+            namespace: default
+          status:
+            currentState:
+              currentStateSummary: Suspended
+    - error:
+        timeout: 120s
+        resource:
+          apiVersion: v1
+          kind: Pod
+          metadata:
+            namespace: default
+            labels:
+              spark.operator/spark-app-name: 
spark-job-kueue-executors-not-ready-test
+    - error:
+        timeout: 120s
+        resource:
+          apiVersion: kueue.x-k8s.io/v1beta2
+          kind: Workload
+          metadata:
+            name: sparkapplication-spark-job-kueue-executors-not-ready-test
+            namespace: default
+    - assert:
+        timeout: 60s
+        resource:
+          apiVersion: kueue.x-k8s.io/v1beta2
+          kind: ClusterQueue
+          metadata:
+            name: ($CLUSTER_QUEUE)
+          status:
+            admittedWorkloads: 0
+            reservingWorkloads: 0
+    # Resuming queues it again with a new attempt and a new Workload, which 
Kueue admits into the
+    # quota it gave back
+    - script:
+        timeout: 30s
+        content: |
+          kubectl patch sparkapplication 
spark-job-kueue-executors-not-ready-test -n default --type merge -p 
'{"spec":{"suspend":false}}'
+    - assert:
+        timeout: 5m
+        resource:
+          apiVersion: spark.apache.org/v1
+          kind: SparkApplication
+          metadata:
+            name: spark-job-kueue-executors-not-ready-test
+            namespace: default
+          status:
+            currentState:
+              currentStateSummary: RunningHealthy
+    - assert:
+        timeout: 60s
+        resource:
+          apiVersion: kueue.x-k8s.io/v1beta2
+          kind: ClusterQueue
+          metadata:
+            name: ($CLUSTER_QUEUE)
+          status:
+            admittedWorkloads: 1
     catch:
     - describe:
         apiVersion: spark.apache.org/v1
diff --git a/tests/e2e/suspend/chainsaw-test.yaml 
b/tests/e2e/suspend/chainsaw-test.yaml
index bff80456..e0601c85 100644
--- a/tests/e2e/suspend/chainsaw-test.yaml
+++ b/tests/e2e/suspend/chainsaw-test.yaml
@@ -78,6 +78,86 @@ spec:
         timeout: 30s
         content: |
           kubectl delete sparkapplication spark-job-suspend-test -n default 
--ignore-not-found=true
+  # Suspending a running application releases its driver and executors, and 
resuming it starts a
+  # new attempt, which runs the application again from scratch.
+  - try:
+    - apply:
+        file: spark-example-running.yaml
+    - assert:
+        timeout: 120s
+        resource:
+          apiVersion: spark.apache.org/v1
+          kind: SparkApplication
+          metadata:
+            name: spark-job-suspend-running-test
+            namespace: default
+          status:
+            currentState:
+              currentStateSummary: RunningHealthy
+    - script:
+        timeout: 30s
+        content: |
+          kubectl patch sparkapplication spark-job-suspend-running-test -n 
default --type merge -p '{"spec":{"suspend":true}}'
+    - assert:
+        timeout: 60s
+        resource:
+          apiVersion: spark.apache.org/v1
+          kind: SparkApplication
+          metadata:
+            name: spark-job-suspend-running-test
+            namespace: default
+          status:
+            currentState:
+              currentStateSummary: Suspended
+    - error:
+        timeout: 120s
+        resource:
+          apiVersion: v1
+          kind: Pod
+          metadata:
+            namespace: default
+            labels:
+              spark.operator/spark-app-name: spark-job-suspend-running-test
+    # The executor is made schedulable while the application is suspended, so 
that the resumed
+    # attempt runs with the changed spec and completes.
+    - script:
+        timeout: 30s
+        content: |
+          kubectl patch sparkapplication spark-job-suspend-running-test -n 
default --type merge -p 
'{"spec":{"suspend":false,"sparkConf":{"spark.kubernetes.executor.node.selector.spark.apache.org/e2e-unschedulable":null}}}'
+    - assert:
+        timeout: 180s
+        resource:
+          apiVersion: spark.apache.org/v1
+          kind: SparkApplication
+          metadata:
+            name: spark-job-suspend-running-test
+            namespace: default
+          status:
+            currentState:
+              currentStateSummary: ResourceReleased
+            # The new attempt does not count as a restart, even with the 
default restartPolicy
+            # Never
+            currentAttemptSummary:
+              attemptInfo:
+                id: 1
+                restartCounter: 0
+            stateTransitionHistory:
+              (contains(*.currentStateSummary, 'Succeeded')): true
+            previousAttemptSummary:
+              stateTransitionHistory:
+                (contains(*.currentStateSummary, 'Suspended')): true
+    catch:
+    - describe:
+        apiVersion: spark.apache.org/v1
+        kind: SparkApplication
+        namespace: default
+    - events:
+        namespace: default
+    finally:
+    - script:
+        timeout: 30s
+        content: |
+          kubectl delete sparkapplication spark-job-suspend-running-test -n 
default --ignore-not-found=true
   - try:
     - apply:
         bindings:
diff --git a/tests/e2e/suspend/spark-example-running.yaml 
b/tests/e2e/suspend/spark-example-running.yaml
new file mode 100644
index 00000000..5bbfaab2
--- /dev/null
+++ b/tests/e2e/suspend/spark-example-running.yaml
@@ -0,0 +1,35 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+apiVersion: spark.apache.org/v1
+kind: SparkApplication
+metadata:
+  name: spark-job-suspend-running-test
+  namespace: default
+spec:
+  mainClass: "org.apache.spark.examples.SparkPi"
+  jars: "local:///opt/spark/examples/jars/spark-examples.jar"
+  sparkConf:
+    spark.executor.instances: "1"
+    # No node has this label, so the executor is never scheduled and the job 
waits for it, while
+    # the application is RunningHealthy without executor thresholds. The test 
removes it while the
+    # application is suspended, so that the resumed attempt completes.
+    
spark.kubernetes.executor.node.selector.spark.apache.org/e2e-unschedulable: 
"true"
+    spark.kubernetes.container.image: "apache/spark:{{SPARK_VERSION}}-scala"
+    spark.kubernetes.authenticate.driver.serviceAccountName: "spark"
+  runtimeVersions:
+    sparkVersion: "4.2.0"


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

Reply via email to