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 eb66854a [SPARK-60018] Add `ApplicationStateSummary.Suspended` and 
`ApplicationStatus#resume`
eb66854a is described below

commit eb66854a61f79b99b0244d6d3a5f19b056892d1e
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Tue Oct 6 23:13:59 2026 -0700

    [SPARK-60018] Add `ApplicationStateSummary.Suspended` and 
`ApplicationStatus#resume`
    
    ### What changes were proposed in this pull request?
    
    This PR aims to add `ApplicationStateSummary.Suspended` and 
`ApplicationStatus#resume`.
    
    - Add `Suspended` to `ApplicationStateSummary`, and make `isStopping()` 
exclude it. Otherwise, `AppCleanUpStep` would end a suspended application. The 
result for the existing states is unchanged.
    - Add `ApplicationStatus#resume`, which starts the next attempt of a 
`Suspended` application from `Submitted`. Unlike a restart, it does not 
increase the restart counters, and it resets only 
`schedulingFailureRestartCounter`.
    - Add `Suspended` to the `SparkApplication` CRD of `spark.apache.org/v1`, 
and update `docs/migration_guide.md`.
    
    ### Why are the changes needed?
    
    To support `spec.suspend` for a running `SparkApplication` in a follow-up 
PR. Currently, it takes effect only before the driver is requested.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes, in the API only. Unlike 
[1.0.0](https://github.com/apache/spark-kubernetes-operator/releases/tag/1.0.0) 
(2026-07-23), the `SparkApplication` CRD accepts the `Suspended` state, an 
exhaustive `switch` over `ApplicationStateSummary` needs a case for 
`Suspended`, and `ApplicationStatus` has the new public method `resume`. There 
is no behavior change, since nothing uses them until the follow-up PR.
    
    ### 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 #946 from dongjoon-hyun/SPARK-60018.
    
    Authored-by: Dongjoon Hyun <[email protected]>
    Signed-off-by: Dongjoon Hyun <[email protected]>
---
 .../sparkapplications.spark.apache.org-v1.yaml     |   4 +
 docs/migration_guide.md                            |   8 +-
 .../operator/status/ApplicationStateSummary.java   |  17 ++-
 .../k8s/operator/status/ApplicationStatus.java     |  57 +++++++++-
 .../status/ApplicationStateSummaryTest.java        |  13 +++
 .../k8s/operator/status/ApplicationStatusTest.java | 121 +++++++++++++++++++++
 6 files changed, 209 insertions(+), 11 deletions(-)

diff --git 
a/build-tools/helm/spark-kubernetes-operator/crds/sparkapplications.spark.apache.org-v1.yaml
 
b/build-tools/helm/spark-kubernetes-operator/crds/sparkapplications.spark.apache.org-v1.yaml
index f8b5aaf1..3c799123 100644
--- 
a/build-tools/helm/spark-kubernetes-operator/crds/sparkapplications.spark.apache.org-v1.yaml
+++ 
b/build-tools/helm/spark-kubernetes-operator/crds/sparkapplications.spark.apache.org-v1.yaml
@@ -16556,6 +16556,7 @@ spec:
                               - SchedulingFailure
                               - Submitted
                               - Succeeded
+                              - Suspended
                               - TerminatedWithoutReleaseResources
                             type: string
                           lastObservedDriverStatus:
@@ -17231,6 +17232,7 @@ spec:
                         - SchedulingFailure
                         - Submitted
                         - Succeeded
+                        - Suspended
                         - TerminatedWithoutReleaseResources
                       type: string
                     lastObservedDriverStatus:
@@ -17918,6 +17920,7 @@ spec:
                               - SchedulingFailure
                               - Submitted
                               - Succeeded
+                              - Suspended
                               - TerminatedWithoutReleaseResources
                             type: string
                           lastObservedDriverStatus:
@@ -18594,6 +18597,7 @@ spec:
                           - SchedulingFailure
                           - Submitted
                           - Succeeded
+                          - Suspended
                           - TerminatedWithoutReleaseResources
                         type: string
                       lastObservedDriverStatus:
diff --git a/docs/migration_guide.md b/docs/migration_guide.md
index 8a2b0c76..696804b7 100644
--- a/docs/migration_guide.md
+++ b/docs/migration_guide.md
@@ -33,8 +33,8 @@ affect building the project, CI, tests or examples.
 - Since 1.1.0, `spark.apache.org/v1beta1` of the CRDs is deprecated and 
scheduled for removal in
   2.0.0. Its schema is no longer updated, e.g. it lacks `spec.suspend`. Use 
`spark.apache.org/v1`.
 - Since 1.1.0, K8s 1.35 or newer is recommended instead of 1.34.
-- Since 1.1.0, the CRDs have `spec.suspend` for `SparkApplication` and 
`SparkCluster`, and the
-  `Suspended` state for `SparkCluster`. Since `helm upgrade` does not update 
CRDs, replace them
+- Since 1.1.0, the CRDs have `spec.suspend` and the `Suspended` state for 
`SparkApplication` and
+  `SparkCluster`. Since `helm upgrade` does not update CRDs, replace them
   before upgrading the operator, e.g. with the CRDs of the Helm chart `1.9.0`, 
which deploys
   1.1.0. Otherwise, the API server rejects or drops `spec.suspend`, and 
rejects the `Suspended`
   state.
@@ -209,5 +209,5 @@ is recreated, e.g. by suspending and resuming it with 
[`spec.suspend`](spark_cus
   is deprecated for removal. It ignores `resourceRetainPolicy`, so it no 
longer returns
   `TerminatedWithoutReleaseResources`. Use `terminateOrRestart(RestartConfig, 
String, boolean)`
   instead.
-- Since 1.1.0, `ClusterStateSummary` has the `Suspended` constant, so an 
exhaustive `switch` over
-  it needs a case for it.
+- Since 1.1.0, `ApplicationStateSummary` and `ClusterStateSummary` have the 
`Suspended` constant,
+  so an exhaustive `switch` over either needs a case for it.
diff --git 
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationStateSummary.java
 
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationStateSummary.java
index 1ef8c688..48186b6d 100644
--- 
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationStateSummary.java
+++ 
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationStateSummary.java
@@ -109,7 +109,15 @@ public enum ApplicationStateSummary implements 
BaseStateSummary {
    * If configured, operator may mark app as terminated without releasing 
resources. While this can
    * be helpful in dev phase, it shall not be enabled for prod use cases
    */
-  TerminatedWithoutReleaseResources;
+  TerminatedWithoutReleaseResources,
+
+  /**
+   * The application is suspended by spec.suspend after its driver was 
requested, so its driver
+   * and executors are released until it is resumed with a new attempt from 
Submitted. It is
+   * declared last, after the terminated states, so that the ordinal ranges of 
{@link
+   * #isStarting()} and {@link #isStopping()} exclude it.
+   */
+  Suspended;
 
   public static final Set<ApplicationStateSummary> infrastructureFailures =
       Set.of(DriverStartTimedOut, ExecutorsStartTimedOut, SchedulingFailure);
@@ -161,12 +169,13 @@ public enum ApplicationStateSummary implements 
BaseStateSummary {
    * Checks if the application is in the `stopping` state. The operator 
releases its resources based
    * on retain policy and perform retry based on retry policy.
    *
-   * @return True if the state is past RunningWithBelowThresholdExecutors but 
not yet terminated,
-   *     false otherwise.
+   * @return True if the state is past RunningWithBelowThresholdExecutors and 
before the terminated
+   *     states, false otherwise.
    * @since 0.1.0
    */
   public boolean isStopping() {
-    return RunningWithBelowThresholdExecutors.ordinal() < this.ordinal() && 
!isTerminated();
+    return RunningWithBelowThresholdExecutors.ordinal() < this.ordinal()
+        && this.ordinal() < ResourceReleased.ordinal();
   }
 
   /**
diff --git 
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationStatus.java
 
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationStatus.java
index 50e185dd..3114f4ec 100644
--- 
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationStatus.java
+++ 
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationStatus.java
@@ -205,10 +205,61 @@ public class ApplicationStatus
           currentAttemptSummary);
     }
 
-    ApplicationAttemptSummary nextAttemptSummary = new 
ApplicationAttemptSummary(nextAttemptInfo);
-    ApplicationState state =
-        new ApplicationState(ApplicationStateSummary.ScheduledToRestart, 
stateMessageOverride);
+    return startNextAttempt(
+        new ApplicationState(ApplicationStateSummary.ScheduledToRestart, 
stateMessageOverride),
+        nextAttemptInfo,
+        trimStateTransitionHistory);
+  }
 
+  /**
+   * Starts a new attempt of a suspended application which is resumed. Like a 
restart, the new
+   * attempt gets the next attempt id, and the history of the suspended 
attempt moves to the
+   * previous attempt summary if trimStateTransitionHistory is set. Unlike a 
restart, it keeps the
+   * restart counters, since the suspended attempt neither failed nor 
finished, so it does not count
+   * against the restart limits, whatever the restart policy is. Only the 
counter of consecutive
+   * scheduling failures starts over, since the driver of the suspended 
attempt was requested, like
+   * after an attempt which failed otherwise.
+   *
+   * <p>It only creates the status of the new attempt. Like {@link
+   * #terminateOrRestart(RestartConfig, String, boolean)}, the resources of 
the suspended attempt
+   * are expected to be released already.
+   *
+   * @param stateMessage The message of the Submitted state which the new 
attempt starts with.
+   * @param trimStateTransitionHistory If true, the state transition history 
will be trimmed.
+   * @return An updated ApplicationStatus object.
+   * @throws IllegalStateException if the application is not Suspended.
+   * @since 1.1.0
+   */
+  public ApplicationStatus resume(String stateMessage, boolean 
trimStateTransitionHistory) {
+    if (ApplicationStateSummary.Suspended != 
currentState.getCurrentStateSummary()) {
+      throw new IllegalStateException(
+          "Spark application cannot be resumed unless it is suspended, current 
state is: "
+              + currentState);
+    }
+    ApplicationAttemptInfo attemptInfo = 
currentAttemptSummary.getAttemptInfo();
+    return startNextAttempt(
+        new ApplicationState(ApplicationStateSummary.Submitted, stateMessage),
+        new ApplicationAttemptInfo(
+            attemptInfo.getId() + 1L,
+            attemptInfo.getRestartCounter(),
+            attemptInfo.getFailureRestartCounter(),
+            0L),
+        trimStateTransitionHistory);
+  }
+
+  /**
+   * Starts the next attempt with the given state as its first state.
+   *
+   * @param state The first state of the next attempt.
+   * @param nextAttemptInfo The attempt info of the next attempt.
+   * @param trimStateTransitionHistory If true, the state transition history 
will be trimmed.
+   * @return An updated ApplicationStatus object.
+   */
+  private ApplicationStatus startNextAttempt(
+      ApplicationState state,
+      ApplicationAttemptInfo nextAttemptInfo,
+      boolean trimStateTransitionHistory) {
+    ApplicationAttemptSummary nextAttemptSummary = new 
ApplicationAttemptSummary(nextAttemptInfo);
     if (trimStateTransitionHistory) {
       // when truncating, put all previous history entries into previous 
attempt summary
       ApplicationAttemptSummary newPrevSummary =
diff --git 
a/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ApplicationStateSummaryTest.java
 
b/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ApplicationStateSummaryTest.java
index 0a1e0f76..d904936f 100644
--- 
a/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ApplicationStateSummaryTest.java
+++ 
b/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ApplicationStateSummaryTest.java
@@ -81,6 +81,19 @@ class ApplicationStateSummaryTest {
     assertFalse(ApplicationStateSummary.DriverRequested.isStopping());
   }
 
+  @Test
+  void testSuspendedIsNeitherStoppingNorTerminated() {
+    // A suspended application is reconciled again to be resumed, so it must 
not be terminated, and
+    // it must not be mistaken for a stopping attempt, which the clean up 
would end, by the ordinal
+    // comparison of isStopping
+    assertFalse(ApplicationStateSummary.Suspended.isInitializing());
+    assertFalse(ApplicationStateSummary.Suspended.isStarting());
+    assertFalse(ApplicationStateSummary.Suspended.isStopping());
+    assertFalse(ApplicationStateSummary.Suspended.isTerminated());
+    assertFalse(ApplicationStateSummary.Suspended.isFailure());
+    assertFalse(ApplicationStateSummary.Suspended.isInfrastructureFailure());
+  }
+
   @Test
   void testIsTerminated() {
     assertTrue(ApplicationStateSummary.ResourceReleased.isTerminated());
diff --git 
a/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ApplicationStatusTest.java
 
b/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ApplicationStatusTest.java
index 0727163e..dcc20385 100644
--- 
a/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ApplicationStatusTest.java
+++ 
b/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ApplicationStatusTest.java
@@ -24,6 +24,7 @@ import static 
org.apache.spark.k8s.operator.status.ApplicationStateSummary.Succe
 import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.time.Duration;
@@ -417,6 +418,126 @@ class ApplicationStatusTest {
     }
   }
 
+  @Test
+  void testResumeStartsNextAttemptWithoutCountingARestart() {
+    // The second attempt, after a restart on a scheduling failure, runs until 
it is suspended
+    ApplicationAttemptInfo attemptInfo = new ApplicationAttemptInfo(1L, 1L, 
1L, 1L);
+    ApplicationStatus suspended =
+        new ApplicationStatus(
+                new 
ApplicationState(ApplicationStateSummary.ScheduledToRestart, ""),
+                Map.of(4L, new 
ApplicationState(ApplicationStateSummary.ScheduledToRestart, "")),
+                new ApplicationAttemptSummary(),
+                new ApplicationAttemptSummary(attemptInfo))
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.DriverRequested, ""))
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.RunningHealthy, ""))
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.Suspended, ""));
+
+    for (boolean trimStateTransitionHistory : new boolean[] {false, true}) {
+      ApplicationStatus resumed = suspended.resume("resumed", 
trimStateTransitionHistory);
+
+      ApplicationState state = resumed.getCurrentState();
+      assertEquals(Submitted, state.getCurrentStateSummary());
+      assertEquals("resumed", state.getMessage());
+      assertEquals(state, resumed.getStateTransitionHistory().get(8L));
+      // The restart counters stay, since the suspended attempt neither failed 
nor finished, while
+      // its driver was requested, which ends a run of consecutive scheduling 
failures
+      assertEquals(
+          new ApplicationAttemptInfo(2L, 1L, 1L, 0L),
+          resumed.getCurrentAttemptSummary().getAttemptInfo());
+      if (trimStateTransitionHistory) {
+        // Like a restart, the history of the suspended attempt moves to the 
previous attempt
+        assertEquals(1, resumed.getStateTransitionHistory().size());
+        assertEquals(attemptInfo, 
resumed.getPreviousAttemptSummary().getAttemptInfo());
+        assertEquals(
+            suspended.getStateTransitionHistory(),
+            resumed.getPreviousAttemptSummary().getStateTransitionHistory());
+      } else {
+        assertEquals(5, resumed.getStateTransitionHistory().size());
+        assertEquals(suspended.getCurrentAttemptSummary(), 
resumed.getPreviousAttemptSummary());
+      }
+    }
+  }
+
+  @Test
+  void testOnlySuspendedApplicationIsResumed() {
+    for (ApplicationStateSummary summary : ApplicationStateSummary.values()) {
+      if (summary != ApplicationStateSummary.Suspended) {
+        ApplicationStatus status =
+            new ApplicationStatus().appendNewState(new 
ApplicationState(summary, ""));
+        assertThrows(
+            IllegalStateException.class,
+            () -> assertNotNull(status.resume("", true)),
+            summary.name());
+      }
+    }
+  }
+
+  @Test
+  void testResumeEndsConsecutiveSchedulingFailures() {
+    RestartConfig config =
+        RestartConfig.builder()
+            .restartPolicy(RestartPolicy.Always)
+            .maxRestartAttempts(10L)
+            .maxRestartOnSchedulingFailure(1L)
+            .build();
+    // The first attempt fails to be scheduled, and the second one gets its 
driver and runs until
+    // it is suspended
+    ApplicationStatus suspended =
+        new ApplicationStatus()
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.SchedulingFailure, ""))
+            .terminateOrRestart(config, null, true)
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.DriverRequested, ""))
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.RunningHealthy, ""))
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.Suspended, ""));
+
+    // A scheduling failure of the resumed attempt does not follow the first 
one, so it restarts
+    ApplicationStatus restarted =
+        suspended
+            .resume("", true)
+            .appendNewState(new 
ApplicationState(ApplicationStateSummary.SchedulingFailure, ""))
+            .terminateOrRestart(config, null, true);
+    assertEquals(
+        ApplicationStateSummary.ScheduledToRestart,
+        restarted.getCurrentState().getCurrentStateSummary());
+  }
+
+  @Test
+  void testRunBeforeSuspensionDoesNotResetRestartCounter() {
+    RestartConfig config = new RestartConfig();
+    config.setRestartPolicy(RestartPolicy.Always);
+    config.setMaxRestartAttempts(1L);
+    config.setRestartCounterResetMillis(3600000L); // 1 hour
+
+    Instant now = Instant.now();
+    Instant fourHoursAgo = now.minus(Duration.ofHours(4));
+    // After its only permitted restart, the attempt runs for two hours until 
it is suspended, and
+    // stays suspended for two hours
+    ApplicationStatus suspended =
+        new ApplicationStatus(
+                stateAt(ApplicationStateSummary.ScheduledToRestart, 
fourHoursAgo),
+                Map.of(4L, stateAt(ApplicationStateSummary.ScheduledToRestart, 
fourHoursAgo)),
+                new ApplicationAttemptSummary(),
+                new ApplicationAttemptSummary(new ApplicationAttemptInfo(1L, 
1L, 0L, 0L)))
+            .appendNewState(stateAt(ApplicationStateSummary.DriverRequested, 
fourHoursAgo))
+            .appendNewState(
+                stateAt(ApplicationStateSummary.Suspended, 
now.minus(Duration.ofHours(2))));
+
+    for (boolean trimStateTransitionHistory : new boolean[] {false, true}) {
+      // The resumed attempt fails right away. Neither the run before the 
suspension nor the
+      // suspension counts as a run of the resumed attempt, so no more restart 
is permitted.
+      ApplicationStatus failed =
+          suspended
+              .resume("", trimStateTransitionHistory)
+              .appendNewState(stateAt(ApplicationStateSummary.DriverRequested, 
now))
+              .appendNewState(stateAt(ApplicationStateSummary.Failed, now));
+      ApplicationStatus terminated =
+          failed.terminateOrRestart(config, null, trimStateTransitionHistory);
+      assertEquals(
+          ApplicationStateSummary.ResourceReleased,
+          terminated.getCurrentState().getCurrentStateSummary());
+    }
+  }
+
   private ApplicationState stateAt(ApplicationStateSummary summary, Instant 
time) {
     ApplicationState state = new ApplicationState(summary, "");
     state.setLastTransitionTime(time.toString());


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

Reply via email to