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 ac338832 [SPARK-60057] Add `suspendReason` to `Suspended` states
ac338832 is described below
commit ac33883212ecb47d0e848c56b9defe60f9fdd2ac
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Oct 7 15:15:24 2026 -0700
[SPARK-60057] Add `suspendReason` to `Suspended` states
### What changes were proposed in this pull request?
This PR adds `status.currentState.suspendReason` (`SpecSuspend` or
`KueueEviction`), which is set on `Suspended` states, and uses it in
`AppSuspendStep` and `ClusterSuspendStep` instead of the prefix of the state
message.
- `SuspendReason` and the nullable field in `BaseState`. The existing 3-arg
constructor is kept.
- `isSuspendedByEviction` only checks the reason of the current state. The
cluster's walk through the history is removed. The stuck-pods state keeps the
reason of the state before it.
- A `Suspended` state without a reason is treated as `SpecSuspend`.
- Regenerate the `v1` CRDs (`v1beta1` is frozen), and document the field in
`docs/spark_custom_resources.md`.
### Why are the changes needed?
The message prefix became a control protocol, as pointed out in the review
of #952. If the wording changes, a resource suspended by an eviction by an
older version is taken for a manual suspend, so `keepKueueWorkload()` is
skipped and the remaining requeue backoff is ignored.
No message-prefix fallback is needed. `Suspended` is not in 1.0.0, and the
migration guide already requires replacing the CRDs for 1.1.0, which ships the
new field.
### Does this PR introduce _any_ user-facing change?
No for the released versions. `suspendReason` is a new field within the
unreleased 1.1.0 changes, and the state messages are unchanged.
### How was this patch tested?
Pass the CIs. `gradle build` passes locally, with new and updated tests in
`ApplicationStatusTest`, `ClusterStatusTest`, `AppSuspendStepTest`,
`ClusterSuspendStepTest` and `ClusterInitStepTest`.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Sonnet 5.5
Closes #954 from dongjoon-hyun/SPARK-60057.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
---
.../sparkapplications.spark.apache.org-v1.yaml | 20 +++++++++++
.../crds/sparkclusters.spark.apache.org-v1.yaml | 20 +++++++++++
docs/spark_custom_resources.md | 6 ++++
.../org/apache/spark/k8s/operator/Constants.java | 4 +--
.../k8s/operator/status/ApplicationState.java | 10 ++++++
.../spark/k8s/operator/status/BaseState.java | 14 ++++++++
.../spark/k8s/operator/status/ClusterState.java | 10 ++++++
.../status/{BaseState.java => SuspendReason.java} | 27 +++++----------
.../k8s/operator/status/ApplicationStatusTest.java | 25 ++++++++++++++
.../k8s/operator/status/ClusterStatusTest.java | 21 ++++++++++++
.../reconciler/reconcilesteps/AppSuspendStep.java | 16 +++++----
.../reconciler/reconcilesteps/ClusterInitStep.java | 5 ++-
.../reconcilesteps/ClusterSuspendStep.java | 40 +++++++++++-----------
.../reconcilesteps/AppSuspendStepTest.java | 27 +++++++++++++++
.../reconcilesteps/ClusterInitStepTest.java | 2 ++
.../reconcilesteps/ClusterSuspendStepTest.java | 35 ++++++++++++++++++-
16 files changed, 233 insertions(+), 49 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 3c799123..87077ca4 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
@@ -17208,6 +17208,11 @@ spec:
type: string
message:
type: string
+ suspendReason:
+ enum:
+ - KueueEviction
+ - SpecSuspend
+ type: string
type: object
type: object
type: object
@@ -17884,6 +17889,11 @@ spec:
type: string
message:
type: string
+ suspendReason:
+ enum:
+ - KueueEviction
+ - SpecSuspend
+ type: string
type: object
previousAttemptSummary:
properties:
@@ -18572,6 +18582,11 @@ spec:
type: string
message:
type: string
+ suspendReason:
+ enum:
+ - KueueEviction
+ - SpecSuspend
+ type: string
type: object
type: object
type: object
@@ -19249,6 +19264,11 @@ spec:
type: string
message:
type: string
+ suspendReason:
+ enum:
+ - KueueEviction
+ - SpecSuspend
+ type: string
type: object
type: object
type: object
diff --git
a/build-tools/helm/spark-kubernetes-operator/crds/sparkclusters.spark.apache.org-v1.yaml
b/build-tools/helm/spark-kubernetes-operator/crds/sparkclusters.spark.apache.org-v1.yaml
index e2ccacca..9bef5365 100644
---
a/build-tools/helm/spark-kubernetes-operator/crds/sparkclusters.spark.apache.org-v1.yaml
+++
b/build-tools/helm/spark-kubernetes-operator/crds/sparkclusters.spark.apache.org-v1.yaml
@@ -14760,6 +14760,11 @@ spec:
type: string
message:
type: string
+ suspendReason:
+ enum:
+ - KueueEviction
+ - SpecSuspend
+ type: string
type: object
type: object
type: object
@@ -14778,6 +14783,11 @@ spec:
type: string
message:
type: string
+ suspendReason:
+ enum:
+ - KueueEviction
+ - SpecSuspend
+ type: string
type: object
previousAttemptSummary:
properties:
@@ -14804,6 +14814,11 @@ spec:
type: string
message:
type: string
+ suspendReason:
+ enum:
+ - KueueEviction
+ - SpecSuspend
+ type: string
type: object
type: object
type: object
@@ -14823,6 +14838,11 @@ spec:
type: string
message:
type: string
+ suspendReason:
+ enum:
+ - KueueEviction
+ - SpecSuspend
+ type: string
type: object
type: object
type: object
diff --git a/docs/spark_custom_resources.md b/docs/spark_custom_resources.md
index 97fd7e2c..36270370 100644
--- a/docs/spark_custom_resources.md
+++ b/docs/spark_custom_resources.md
@@ -606,6 +606,12 @@ 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.
+* The `Suspended` state says why the resource is suspended in
`status.currentState.suspendReason`:
+ `SpecSuspend` if it is held by `spec.suspend`, or `KueueEviction` if the
eviction of its Kueue
+ `Workload` released it, see [Kueue](#kueue). The operator relies on this
field, not on the
+ message, which is for users to read and may be reworded. `spec.suspend`
takes precedence, so
+ setting it on a resource suspended by an eviction records another
`Suspended` state with
+ `SpecSuspend`. A `Suspended` state without it is treated as `SpecSuspend`.
* 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
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 c0ad3a05..fe36c015 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
@@ -248,9 +248,7 @@ public final class Constants {
/**
* Message indicating that the application is evicted by Kueue after its
driver was requested,
- * which is followed by the reason and the message of the eviction. The
operator tells such an
- * application apart from one suspended by spec.suspend by this prefix of
its persisted state
- * message, so a changed wording no longer matches the applications
suspended before.
+ * which is followed by the reason and the message of the eviction.
*/
public static final String APP_EVICTED_MESSAGE =
"Application is evicted by Kueue, so its driver and executors are being
released before its "
diff --git
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationState.java
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationState.java
index ec76cef4..60698bf8 100644
---
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationState.java
+++
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ApplicationState.java
@@ -62,4 +62,14 @@ public class ApplicationState extends
BaseState<ApplicationStateSummary> impleme
public ApplicationState(ApplicationStateSummary currentStateSummary, String
message) {
super(currentStateSummary, Instant.now().toString(), message);
}
+
+ /**
+ * Constructs a new ApplicationState with the given state summary, message
and suspend reason.
+ *
+ * @since 1.1.0
+ */
+ public ApplicationState(
+ ApplicationStateSummary currentStateSummary, String message,
SuspendReason suspendReason) {
+ super(currentStateSummary, Instant.now().toString(), message,
suspendReason);
+ }
}
diff --git
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/BaseState.java
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/BaseState.java
index 0ae4c03b..d5997a66 100644
---
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/BaseState.java
+++
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/BaseState.java
@@ -40,4 +40,18 @@ public class BaseState<T> implements Serializable {
protected T currentStateSummary;
protected String lastTransitionTime;
protected String message;
+
+ /** Why the resource is suspended, only set on a Suspended state. */
+ protected SuspendReason suspendReason;
+
+ /**
+ * Constructs a new BaseState without a suspend reason.
+ *
+ * @param currentStateSummary The current state summary.
+ * @param lastTransitionTime The time of the last transition.
+ * @param message The message of the state.
+ */
+ public BaseState(T currentStateSummary, String lastTransitionTime, String
message) {
+ this(currentStateSummary, lastTransitionTime, message, null);
+ }
}
diff --git
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ClusterState.java
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ClusterState.java
index f2e4d77c..cc7f5480 100644
---
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ClusterState.java
+++
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/ClusterState.java
@@ -56,4 +56,14 @@ public class ClusterState extends
BaseState<ClusterStateSummary> implements Seri
public ClusterState(ClusterStateSummary currentStateSummary, String message)
{
super(currentStateSummary, Instant.now().toString(), message);
}
+
+ /**
+ * Constructs a new ClusterState with the given state summary, message and
suspend reason.
+ *
+ * @since 1.1.0
+ */
+ public ClusterState(
+ ClusterStateSummary currentStateSummary, String message, SuspendReason
suspendReason) {
+ super(currentStateSummary, Instant.now().toString(), message,
suspendReason);
+ }
}
diff --git
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/BaseState.java
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/SuspendReason.java
similarity index 60%
copy from
spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/BaseState.java
copy to
spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/SuspendReason.java
index 0ae4c03b..e30e106b 100644
---
a/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/BaseState.java
+++
b/spark-operator-api/src/main/java/org/apache/spark/k8s/operator/status/SuspendReason.java
@@ -19,25 +19,16 @@
package org.apache.spark.k8s.operator.status;
-import java.io.Serializable;
-
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
-import com.fasterxml.jackson.annotation.JsonInclude;
-import lombok.AllArgsConstructor;
-import lombok.Data;
-
/**
- * Base class for state information.
+ * Reason of a Suspended state, which tells the suspend by spec.suspend apart
from the one by the
+ * eviction of the Kueue Workload.
*
- * @param <T> The type of the current state summary.
- * @since 0.1.0
+ * @since 1.1.0
*/
-@Data
-@AllArgsConstructor
-@JsonInclude(JsonInclude.Include.NON_NULL)
-@JsonIgnoreProperties(ignoreUnknown = true)
-public class BaseState<T> implements Serializable {
- protected T currentStateSummary;
- protected String lastTransitionTime;
- protected String message;
+public enum SuspendReason {
+ /** The resource is held by spec.suspend. */
+ SpecSuspend,
+
+ /** The resource is released after Kueue evicted its Workload, and is queued
again then. */
+ KueueEviction
}
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 dcc20385..1edc8532 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
@@ -23,7 +23,9 @@ import static
org.apache.spark.k8s.operator.status.ApplicationStateSummary.Submi
import static
org.apache.spark.k8s.operator.status.ApplicationStateSummary.Succeeded;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -927,4 +929,27 @@ class ApplicationStatusTest {
assertTrue(deserialized.getStateTransitionHistory().isEmpty());
assertEquals(Submitted,
deserialized.getCurrentState().getCurrentStateSummary());
}
+
+ @Test
+ void testSuspendReasonJsonRoundTrip() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+ ApplicationState suspended =
+ new ApplicationState(
+ ApplicationStateSummary.Suspended, "test",
SuspendReason.KueueEviction);
+
+ String json = mapper.writeValueAsString(suspended);
+ assertTrue(json.contains("\"suspendReason\":\"KueueEviction\""));
+ assertEquals(suspended, mapper.readValue(json, ApplicationState.class));
+
+ // The reason is set only on a Suspended state, and a status without one
still reads
+ ApplicationState submitted = new ApplicationState();
+ assertNull(submitted.getSuspendReason());
+
assertFalse(mapper.writeValueAsString(submitted).contains("suspendReason"));
+ assertNull(
+ mapper
+ .readValue(
+ "{\"currentStateSummary\":\"Suspended\",\"message\":\"test\"}",
+ ApplicationState.class)
+ .getSuspendReason());
+ }
}
diff --git
a/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ClusterStatusTest.java
b/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ClusterStatusTest.java
index 96270f93..d50edf1c 100644
---
a/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ClusterStatusTest.java
+++
b/spark-operator-api/src/test/java/org/apache/spark/k8s/operator/status/ClusterStatusTest.java
@@ -22,6 +22,7 @@ package org.apache.spark.k8s.operator.status;
import static
org.apache.spark.k8s.operator.status.ClusterStateSummary.Submitted;
import static org.junit.jupiter.api.Assertions.*;
+import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
class ClusterStatusTest {
@@ -60,4 +61,24 @@ class ClusterStatusTest {
assertEquals(4, kept.getStateTransitionHistory().size());
assertEquals(resumed, kept.getStateTransitionHistory().get(3L));
}
+
+ @Test
+ void testSuspendReasonJsonRoundTrip() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+ ClusterState suspended =
+ new ClusterState(ClusterStateSummary.Suspended, "test",
SuspendReason.SpecSuspend);
+
+ String json = mapper.writeValueAsString(suspended);
+ assertTrue(json.contains("\"suspendReason\":\"SpecSuspend\""));
+ assertEquals(suspended, mapper.readValue(json, ClusterState.class));
+
+ // The reason is set only on a Suspended state, and a status without one
still reads
+ assertFalse(mapper.writeValueAsString(new
ClusterState()).contains("suspendReason"));
+ assertNull(
+ mapper
+ .readValue(
+ "{\"currentStateSummary\":\"Suspended\",\"message\":\"test\"}",
+ ClusterState.class)
+ .getSuspendReason());
+ }
}
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
index b9ec67d8..9257a7a3 100644
---
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
@@ -57,6 +57,7 @@ import
org.apache.spark.k8s.operator.reconciler.observers.AppDriverRunningObserv
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.status.SuspendReason;
import org.apache.spark.k8s.operator.utils.EventUtils;
import org.apache.spark.k8s.operator.utils.ReconcilerUtils;
import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
@@ -128,8 +129,10 @@ public final class AppSuspendStep extends AppReconcileStep
{
}
// 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.
+ SuspendReason reason = suspend ? SuspendReason.SpecSuspend :
SuspendReason.KueueEviction;
return statusRecorder.appendNewStateAndPersist(
- context, new ApplicationState(ApplicationStateSummary.Suspended,
message.get()))
+ context,
+ new ApplicationState(ApplicationStateSummary.Suspended,
message.get(), reason))
? completeAndImmediateRequeue()
: completeAndDefaultRequeue();
}
@@ -143,7 +146,8 @@ public final class AppSuspendStep extends AppReconcileStep {
return appendStateAndImmediateRequeue(
context,
statusRecorder,
- new ApplicationState(ApplicationStateSummary.Suspended,
APP_SUSPENDED_MESSAGE));
+ new ApplicationState(
+ ApplicationStateSummary.Suspended, APP_SUSPENDED_MESSAGE,
SuspendReason.SpecSuspend));
}
Optional<Duration> keepWorkload = evicted ? keepKueueWorkload(context) :
Optional.empty();
Optional<ReconcileProgress> waiting = releaseResources(context,
keepWorkload.isEmpty());
@@ -191,16 +195,16 @@ public final class AppSuspendStep extends
AppReconcileStep {
/**
* Checks whether the application is suspended by the eviction of its Kueue
Workload rather than
- * by spec.suspend. Unlike a suspended cluster, which may name its stuck
pods in a later state, a
- * suspended application appends no other Suspended state, so its current
one says why.
+ * by spec.suspend, by the reason of its current Suspended state. A
Suspended state without a
+ * reason, e.g. one whose reason the API server pruned for a CRD which does
not have it yet, is
+ * not taken for an eviction.
*
* @param app The suspended SparkApplication.
* @return True if the application was suspended by an eviction and not held
by spec.suspend
* since.
*/
private static boolean isSuspendedByEviction(SparkApplication app) {
- String message = app.getStatus().getCurrentState().getMessage();
- return message != null && message.startsWith(APP_EVICTED_MESSAGE);
+ return app.getStatus().getCurrentState().getSuspendReason() ==
SuspendReason.KueueEviction;
}
/**
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
index d5eb1859..c9f7347f 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStep.java
@@ -46,6 +46,7 @@ import org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils;
import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
import org.apache.spark.k8s.operator.status.ClusterState;
import org.apache.spark.k8s.operator.status.ClusterStatus;
+import org.apache.spark.k8s.operator.status.SuspendReason;
import org.apache.spark.k8s.operator.utils.EventUtils;
import org.apache.spark.k8s.operator.utils.ReconcilerUtils;
import org.apache.spark.k8s.operator.utils.SparkClusterStatusRecorder;
@@ -87,7 +88,9 @@ public final class ClusterInitStep extends
ClusterReconcileStep {
// where ClusterSuspendStep releases whatever it holds only after its
pods are gone.
if (cluster.getStatus().getStateTransitionHistory().lastKey() > 0) {
return appendStateAndImmediateRequeue(
- context, statusRecorder, new ClusterState(Suspended,
CLUSTER_SUSPENDED_MESSAGE));
+ context,
+ statusRecorder,
+ new ClusterState(Suspended, CLUSTER_SUSPENDED_MESSAGE,
SuspendReason.SpecSuspend));
}
return SuspendUtils.holdForSuspend(context, "master and workers");
}
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterSuspendStep.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterSuspendStep.java
index 91818074..15871973 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterSuspendStep.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterSuspendStep.java
@@ -41,7 +41,6 @@ import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
-import java.util.NavigableMap;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -58,6 +57,7 @@ import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
import org.apache.spark.k8s.operator.status.ClusterState;
import org.apache.spark.k8s.operator.status.ClusterStateSummary;
+import org.apache.spark.k8s.operator.status.SuspendReason;
import org.apache.spark.k8s.operator.utils.EventUtils;
import org.apache.spark.k8s.operator.utils.ReconcilerUtils;
import org.apache.spark.k8s.operator.utils.SparkClusterStatusRecorder;
@@ -105,7 +105,8 @@ public final class ClusterSuspendStep extends
ClusterReconcileStep {
return appendStateAndImmediateRequeue(
context,
statusRecorder,
- new ClusterState(ClusterStateSummary.Suspended,
CLUSTER_SUSPENDED_MESSAGE));
+ new ClusterState(
+ ClusterStateSummary.Suspended, CLUSTER_SUSPENDED_MESSAGE,
SuspendReason.SpecSuspend));
}
if (summary == ClusterStateSummary.RunningHealthy) {
return suspendOnKueueEviction(context, statusRecorder);
@@ -118,7 +119,10 @@ public final class ClusterSuspendStep extends
ClusterReconcileStep {
return appendStateAndImmediateRequeue(
context,
statusRecorder,
- new ClusterState(ClusterStateSummary.Suspended,
CLUSTER_SUSPENDED_MESSAGE));
+ new ClusterState(
+ ClusterStateSummary.Suspended,
+ CLUSTER_SUSPENDED_MESSAGE,
+ SuspendReason.SpecSuspend));
}
Optional<Duration> keepWorkload = evicted ? keepKueueWorkload(context) :
Optional.empty();
Optional<ReconcileProgress> waiting =
@@ -181,31 +185,24 @@ public final class ClusterSuspendStep extends
ClusterReconcileStep {
return appendStateAndImmediateRequeue(
context,
statusRecorder,
- new ClusterState(ClusterStateSummary.Suspended,
CLUSTER_EVICTED_MESSAGE + " " + cause));
+ new ClusterState(
+ ClusterStateSummary.Suspended,
+ CLUSTER_EVICTED_MESSAGE + " " + cause,
+ SuspendReason.KueueEviction));
}
/**
* Checks whether the cluster is suspended by the eviction of its Kueue
Workload rather than by
- * spec.suspend. The Suspended state which names its stuck pods, if any,
follows the one which
- * says why it is suspended.
+ * spec.suspend, by the reason of its current Suspended state, which the
Suspended state naming
+ * its stuck pods keeps from the one before. A Suspended state without a
reason, e.g. one whose
+ * reason the API server pruned for a CRD which does not have it yet, is not
taken for an
+ * eviction.
*
* @param cluster The suspended SparkCluster.
* @return True if the cluster was suspended by an eviction and not held by
spec.suspend since.
*/
private static boolean isSuspendedByEviction(SparkCluster cluster) {
- NavigableMap<Long, ClusterState> history =
- (NavigableMap<Long, ClusterState>)
cluster.getStatus().getStateTransitionHistory();
- for (ClusterState state : history.descendingMap().values()) {
- String message = state.getMessage();
- if (state.getCurrentStateSummary() != ClusterStateSummary.Suspended
- || CLUSTER_SUSPENDED_MESSAGE.equals(message)) {
- return false;
- }
- if (message != null && message.startsWith(CLUSTER_EVICTED_MESSAGE)) {
- return true;
- }
- }
- return false;
+ return cluster.getStatus().getCurrentState().getSuspendReason() ==
SuspendReason.KueueEviction;
}
/**
@@ -347,7 +344,10 @@ public final class ClusterSuspendStep extends
ClusterReconcileStep {
appendStateAndRequeueAfter(
context,
statusRecorder,
- new ClusterState(ClusterStateSummary.Suspended, message),
+ new ClusterState(
+ ClusterStateSummary.Suspended,
+ message,
+ cluster.getStatus().getCurrentState().getSuspendReason()),
holdInterval));
}
} catch (KubernetesClientException e) {
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
index bb1a7c3a..7752a9f7 100644
---
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
@@ -21,6 +21,7 @@ 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.clearInvocations;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -72,6 +73,7 @@ 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.status.SuspendReason;
import org.apache.spark.k8s.operator.utils.EventUtils;
import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
import org.apache.spark.k8s.operator.utils.TestUtils;
@@ -147,6 +149,7 @@ class AppSuspendStepTest {
ApplicationState state = captureAppendedState();
Assertions.assertEquals(ApplicationStateSummary.Suspended,
state.getCurrentStateSummary());
Assertions.assertEquals(Constants.APP_SUSPENDED_MESSAGE,
state.getMessage());
+ Assertions.assertEquals(SuspendReason.SpecSuspend,
state.getSuspendReason());
// 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());
@@ -617,6 +620,7 @@ class AppSuspendStepTest {
Assertions.assertEquals(
Constants.APP_EVICTED_MESSAGE + " " + reason + ": " + reason + " by
the test",
state.getMessage());
+ Assertions.assertEquals(SuspendReason.KueueEviction,
state.getSuspendReason());
// Like spec.suspend, nothing is released until Suspended is persisted,
and nothing is reported
// but the Suspended state
Assertions.assertNotNull(getDriver());
@@ -698,6 +702,7 @@ class AppSuspendStepTest {
ApplicationState state = captureAppendedState();
Assertions.assertEquals(Constants.APP_SUSPENDED_MESSAGE,
state.getMessage());
+ Assertions.assertEquals(SuspendReason.SpecSuspend,
state.getSuspendReason());
// Once everything is released, the application is held rather than queued
again
app.setStatus(app.getStatus().appendNewState(state));
@@ -724,6 +729,7 @@ class AppSuspendStepTest {
ApplicationState state = captureAppendedState();
Assertions.assertEquals(ApplicationStateSummary.Suspended,
state.getCurrentStateSummary());
Assertions.assertEquals(Constants.APP_SUSPENDED_MESSAGE,
state.getMessage());
+ Assertions.assertEquals(SuspendReason.SpecSuspend,
state.getSuspendReason());
// Once that state is persisted, the application is held rather than
suspended again, and its
// Workload is released like on spec.suspend
@@ -734,6 +740,26 @@ class AppSuspendStepTest {
Assertions.assertNull(getWorkload());
}
+ @Test
+ void evictionIsToldByTheSuspendReasonRatherThanByTheMessage() {
+ // A reworded message of an application suspended by an eviction before
still says so
+ SparkApplication evicted = buildEvictedApp(false);
+ evicted.getStatus().getCurrentState().setMessage("Reworded in another
version");
+ stubContext(evicted);
+ createWorkload(evicted, evictedStatus("Preempted"), true);
+ new AppSuspendStep().reconcile(mockContext, recorder);
+ Assertions.assertEquals(Constants.APP_REQUEUED_MESSAGE,
capturePersistedState().getMessage());
+
+ // So does a Suspended state without a reason, even if its message looks
like an eviction
+ SparkApplication unknown = buildEvictedApp(false);
+ unknown.getStatus().getCurrentState().setSuspendReason(null);
+ stubContext(unknown);
+ createWorkload(unknown, evictedStatus("Preempted"), true);
+ clearInvocations(recorder);
+ new AppSuspendStep().reconcile(mockContext, recorder);
+ Assertions.assertEquals(Constants.APP_RESUMED_MESSAGE,
capturePersistedState().getMessage());
+ }
+
@Test
void evictedThenSuspendedAppIsResumedRatherThanQueuedAgain() {
SparkApplication app = buildEvictedApp(true);
@@ -1051,6 +1077,7 @@ class AppSuspendStepTest {
app.getStatus()
.getCurrentState()
.setMessage(Constants.APP_EVICTED_MESSAGE + " Preempted: Preempted by
the test");
+
app.getStatus().getCurrentState().setSuspendReason(SuspendReason.KueueEviction);
return app;
}
}
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
index 1f136cb6..c32511c8 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterInitStepTest.java
@@ -95,6 +95,7 @@ import
org.apache.spark.k8s.operator.spec.WorkerInstanceConfig;
import org.apache.spark.k8s.operator.status.ClusterState;
import org.apache.spark.k8s.operator.status.ClusterStateSummary;
import org.apache.spark.k8s.operator.status.ClusterStatus;
+import org.apache.spark.k8s.operator.status.SuspendReason;
import org.apache.spark.k8s.operator.utils.EventUtils;
import org.apache.spark.k8s.operator.utils.SparkClusterStatusRecorder;
import org.apache.spark.k8s.operator.utils.TestUtils;
@@ -342,6 +343,7 @@ class ClusterInitStepTest {
ClusterStateSummary.Suspended,
stateCaptor.getValue().getCurrentStateSummary());
Assertions.assertEquals(
Constants.CLUSTER_SUSPENDED_MESSAGE,
stateCaptor.getValue().getMessage());
+ Assertions.assertEquals(SuspendReason.SpecSuspend,
stateCaptor.getValue().getSuspendReason());
verify(mockContext, never()).getEventRecorder();
}
diff --git
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterSuspendStepTest.java
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterSuspendStepTest.java
index 5e318ff7..c0d37d58 100644
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterSuspendStepTest.java
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/ClusterSuspendStepTest.java
@@ -22,6 +22,7 @@ package
org.apache.spark.k8s.operator.reconciler.reconcilesteps;
import static java.net.HttpURLConnection.HTTP_CONFLICT;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -89,6 +90,7 @@ import
org.apache.spark.k8s.operator.spec.WorkerInstanceConfig;
import org.apache.spark.k8s.operator.status.ClusterState;
import org.apache.spark.k8s.operator.status.ClusterStateSummary;
import org.apache.spark.k8s.operator.status.ClusterStatus;
+import org.apache.spark.k8s.operator.status.SuspendReason;
import org.apache.spark.k8s.operator.utils.EventUtils;
import org.apache.spark.k8s.operator.utils.SparkClusterStatusRecorder;
import org.apache.spark.k8s.operator.utils.TestUtils;
@@ -148,6 +150,7 @@ class ClusterSuspendStepTest {
ClusterState state = captureAppendedState();
Assertions.assertEquals(ClusterStateSummary.Suspended,
state.getCurrentStateSummary());
Assertions.assertEquals(Constants.CLUSTER_SUSPENDED_MESSAGE,
state.getMessage());
+ Assertions.assertEquals(SuspendReason.SpecSuspend,
state.getSuspendReason());
// Nothing is released until Suspended is persisted, so that a cluster
which is resumed in the
// meantime is never left in RunningHealthy without its master and workers.
Assertions.assertNotNull(get(masterStatefulSetSpec));
@@ -211,6 +214,8 @@ class ClusterSuspendStepTest {
@ValueSource(booleans = {true, false})
void stuckPodsReleaseKueueWorkloadAndAreReportedOnce(boolean suspend) {
SparkCluster cluster = buildKueueCluster(ClusterStateSummary.Suspended,
suspend);
+ SuspendReason reason = suspend ? SuspendReason.SpecSuspend :
SuspendReason.KueueEviction;
+ cluster.getStatus().getCurrentState().setSuspendReason(reason);
KubernetesClient client = spy(kubernetesClient);
// Pods stuck in terminating, e.g. on a lost node, do not hold the quota
forever
stubStuckPods(client);
@@ -231,6 +236,8 @@ class ClusterSuspendStepTest {
Constants.CLUSTER_SUSPENDED_WITH_STUCK_PODS_MESSAGE,
"cluster1-master-0, cluster1-worker-0"),
state.getMessage());
+ // The state which names the stuck pods keeps the reason of the one before
+ Assertions.assertEquals(reason, state.getSuspendReason());
// The same state is not appended again on every requeue
cluster.setStatus(cluster.getStatus().appendNewState(state));
@@ -526,6 +533,7 @@ class ClusterSuspendStepTest {
Assertions.assertEquals(
Constants.CLUSTER_EVICTED_MESSAGE + " " + reason + ": " + reason + "
by the test",
state.getMessage());
+ Assertions.assertEquals(SuspendReason.KueueEviction,
state.getSuspendReason());
// Like spec.suspend, nothing is released until Suspended is persisted,
and the Workload is
// released only after the master and worker pods are gone
Assertions.assertNotNull(get(masterStatefulSetSpec));
@@ -567,7 +575,8 @@ class ClusterSuspendStepTest {
ClusterStateSummary.Suspended,
String.format(
Constants.CLUSTER_SUSPENDED_WITH_STUCK_PODS_MESSAGE,
- "cluster1-master-0"))));
+ "cluster1-master-0"),
+ SuspendReason.KueueEviction)));
stubContext(cluster);
Assertions.assertEquals(
@@ -713,6 +722,7 @@ class ClusterSuspendStepTest {
ClusterState state = captureAppendedState();
Assertions.assertEquals(ClusterStateSummary.Suspended,
state.getCurrentStateSummary());
Assertions.assertEquals(Constants.CLUSTER_SUSPENDED_MESSAGE,
state.getMessage());
+ Assertions.assertEquals(SuspendReason.SpecSuspend,
state.getSuspendReason());
// Once that state is persisted, the cluster is held rather than suspended
again
cluster.setStatus(cluster.getStatus().appendNewState(state));
@@ -721,6 +731,28 @@ class ClusterSuspendStepTest {
verify(recorder).appendNewStateAndPersist(any(), any());
}
+ @Test
+ void evictionIsToldByTheSuspendReasonRatherThanByTheMessage() {
+ // A reworded message of a cluster suspended by an eviction before still
says so
+ SparkCluster evicted = buildEvictedCluster(false);
+ evicted.getStatus().getCurrentState().setMessage("Reworded in another
version");
+ stubContext(evicted);
+ createWorkload(evicted, evictedStatus("Preempted"), true);
+ new ClusterSuspendStep().reconcile(mockContext, recorder);
+ Assertions.assertEquals(
+ Constants.CLUSTER_REQUEUED_MESSAGE,
capturePersistedState().getMessage());
+
+ // So does a Suspended state without a reason, even if its message looks
like an eviction
+ SparkCluster unknown = buildEvictedCluster(false);
+ unknown.getStatus().getCurrentState().setSuspendReason(null);
+ stubContext(unknown);
+ createWorkload(unknown, evictedStatus("Preempted"), true);
+ clearInvocations(recorder);
+ new ClusterSuspendStep().reconcile(mockContext, recorder);
+ Assertions.assertEquals(
+ Constants.CLUSTER_RESUMED_MESSAGE,
capturePersistedState().getMessage());
+ }
+
@Test
void evictedThenSuspendedClusterIsResumedRatherThanQueuedAgain() {
SparkCluster cluster = buildEvictedCluster(true);
@@ -938,6 +970,7 @@ class ClusterSuspendStepTest {
.getStatus()
.getCurrentState()
.setMessage(Constants.CLUSTER_EVICTED_MESSAGE + " Preempted: Preempted
by the test");
+
cluster.getStatus().getCurrentState().setSuspendReason(SuspendReason.KueueEviction);
return cluster;
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]