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 f83312e7 [SPARK-60052] Suspend and requeue a running
`SparkApplication` on Kueue eviction
f83312e7 is described below
commit f83312e78641ee6749997a93955d6caf1f6f4c70
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Wed Oct 7 14:05:32 2026 -0700
[SPARK-60052] Suspend and requeue a running `SparkApplication` on Kueue
eviction
### What changes were proposed in this pull request?
This PR aims to suspend a running `SparkApplication` whose Kueue `Workload`
is evicted or deactivated, and to queue it again with a new attempt, like a
running `SparkCluster`.
- `AppSuspendStep` moves such an application to `Suspended` and releases
its driver and executors. Then it deletes the `Workload` after the requeue
backoff, or keeps a deactivated one until it is reactivated.
- The new attempt starts with `ApplicationStatus#resume`, so it does not
count as a restart.
- A `PodsReadyTimeout` is handled like any other eviction, and
`spec.suspend` takes precedence.
- `AppKueueEvictionStep` and the `KueueEvictionIgnored` event are removed.
### Why are the changes needed?
Currently, the driver and executors keep running after an eviction. So
preemption, cohort quota reclamation and `waitForPodsReady` do not work, and a
deactivated `Workload` over-commits the quota.
### Does this PR introduce _any_ user-facing change?
No for the released versions, because the Kueue integration is not released
yet in
[1.0.0](https://github.com/apache/spark-kubernetes-operator/releases/tag/1.0.0)
(2026-07-23).
### 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 #952 from dongjoon-hyun/SPARK-60052.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
---
docs/architecture.md | 9 +-
docs/config_properties.md | 2 +-
docs/configuration.md | 5 +-
docs/spark_custom_resources.md | 32 +-
.../org/apache/spark/k8s/operator/Constants.java | 16 +
.../operator/status/ApplicationStateSummary.java | 8 +-
.../k8s/operator/config/SparkOperatorConf.java | 21 +-
.../k8s/operator/kueue/KueueWorkloadUtils.java | 2 +-
.../operator/reconciler/SparkAppReconciler.java | 3 -
.../reconcilesteps/AppKueueEvictionStep.java | 84 ------
.../reconcilesteps/AppReconcileStep.java | 4 +-
.../reconciler/reconcilesteps/AppSuspendStep.java | 116 +++++++-
.../spark/k8s/operator/utils/EventUtils.java | 6 -
.../reconciler/SparkAppReconcilerTest.java | 20 +-
.../reconcilesteps/AppKueueEvictionStepTest.java | 150 ----------
.../reconcilesteps/AppSuspendStepTest.java | 327 +++++++++++++++++++++
tests/e2e/kueue/chainsaw-test.yaml | 221 ++++++++++----
tests/e2e/kueue/spark-example-preempted.yaml | 42 +++
18 files changed, 700 insertions(+), 368 deletions(-)
diff --git a/docs/architecture.md b/docs/architecture.md
index 7d4b70a1..bfccdc5a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -138,8 +138,8 @@ stateDiagram-v2
RunningWithPartialCapacity --> Succeeded
RunningWithPartialCapacity --> Failed
- RunningHealthy --> Suspended : spec.suspend=true
- Suspended --> Submitted : spec.suspend=false, new attempt
+ RunningHealthy --> Suspended : spec.suspend=true or Kueue eviction
+ Suspended --> Submitted : spec.suspend=false or after Kueue eviction, new
attempt
state Failures {
SchedulingFailure
@@ -171,7 +171,10 @@ stateDiagram-v2
* 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.
+ new attempt from `Submitted`, which does not count as a restart. An
application queued by
+ [Kueue](spark_custom_resources.md#kueue) is suspended as well when Kueue
evicts its `Workload`,
+ and starts such a new attempt once its driver and executors are released and
the requeue
+ backoff of its `Workload` elapsed, or once its `Workload` is reactivated if
it was deactivated.
* 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 2a8285b9..7ededc17 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
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.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 115d0815..ece6014a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -102,7 +102,7 @@ replacing its `message`. A repeat carrying the same
`message` may be paced by
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
`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.
+`false`, or after its eviction by Kueue.
In addition, the operator publishes the following `Warning` events.
@@ -115,9 +115,8 @@ In addition, the operator publishes the following `Warning`
events.
| `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 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 [...]
+| `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 it and queues it again, a
`SparkApplication` with a new attempt, even if its pods are ready.
Transport-level errors and conflicts are skipped. A `SparkCluster` retries them
every 5 seconds and a persistent failur [...]
| `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 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 5ffcb9ac..97fd7e2c 100644
--- a/docs/spark_custom_resources.md
+++ b/docs/spark_custom_resources.md
@@ -829,18 +829,26 @@ spec:
condition cannot be recorded, is admitted and evicted again and again until
it is fixed,
suspended or deleted. With `blockAdmission`, it holds back every other
workload from each
admission until its `Workload` is deleted after the backoff.
-* Preemption is not honored yet after the driver of a `SparkApplication` is
requested, and neither
- 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 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.
+* A `SparkApplication` whose driver is requested, i.e. from `DriverRequested`
to
+ `RunningWithBelowThresholdExecutors`, is released like a running
`SparkCluster` above when Kueue
+ evicts its `Workload`, e.g. to preempt it or by a `PodsReadyTimeout`, or
deactivates it. The
+ application enters `Suspended` with a message giving the reason of the
eviction, and the
+ operator deletes its driver pod, which deletes its executor pods. Like for a
cluster, the
+ `Workload` is deleted once the pods are gone and its requeue backoff
elapsed, or kept until it is
+ reactivated if it was deactivated, and then the application starts a new
attempt from
+ `Submitted`, which is queued again with a new `Workload`. Like a resumed
one, the new attempt
+ runs the application again from scratch and does not count against the
restart limits, so it
+ starts even with `restartPolicy: Never`. An attempt whose driver has
completed or failed by then
+ ends as usual instead, and `spec.suspend` takes precedence, so an
application which is suspended
+ by it as well stays `Suspended` until it is resumed rather than being queued
again.
+* Unlike without Kueue, a `SparkApplication` whose driver and
`spark.executor.instances` executors
+ are not all ready within the `waitForPodsReady` timeout, e.g. since some
executors cannot be
+ scheduled, does not keep running with fewer executors, even if its
`applicationTolerations`
+ allow it, e.g. in `RunningWithPartialCapacity`. Its `Workload` requested the
quota for all of
+ them, so the `PodsReadyTimeout` releases the application as above, and it
runs again from
+ scratch with a new attempt, which waits for the quota of all of them again.
Like such a cluster,
+ an application whose executors never get ready is admitted and evicted again
and again, with a
+ new attempt each time, until it is fixed, suspended or deleted.
* To limit the execution time of a `SparkApplication`, the Spark native
`spark.driver.timeout` is
recommended instead of the `kueue.x-k8s.io/max-exec-time-seconds` label,
which is not copied to
the `Workload`. It requires
`spark.plugins=org.apache.spark.deploy.DriverTimeoutPlugin`, as in
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 7286a29e..c0ad3a05 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
@@ -246,6 +246,22 @@ public final class Constants {
"Application is resumed as spec.suspend is set to false, so a new
attempt starts from "
+ "scratch.";
+ /**
+ * 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.
+ */
+ public static final String APP_EVICTED_MESSAGE =
+ "Application is evicted by Kueue, so its driver and executors are being
released before its "
+ + "Workload. It is queued again with a new attempt then, or once the
Workload is "
+ + "reactivated if it was deactivated.";
+
+ /** Message indicating that the application evicted by Kueue is submitted
again. */
+ public static final String APP_REQUEUED_MESSAGE =
+ "Application is submitted again after its eviction by Kueue, 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-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 48186b6d..8f994109 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
@@ -112,10 +112,10 @@ public enum ApplicationStateSummary implements
BaseStateSummary {
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.
+ * The application is suspended by spec.suspend, or by the eviction of its
Kueue Workload, 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;
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 2cb9ec3f..737c798b 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
@@ -268,13 +268,15 @@ public final class SparkOperatorConf {
* 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
- * being visible, and keep {@link #KUBERNETES_EVENTS_MIN_INTERVAL_SECONDS}
within the rest of
- * that TTL, that is at most the TTL minus this interval, or a repeat is
dropped and the next
- * one lands after the event expired.
+ * nothing waits for this interval. It also paces a SparkApplication or
SparkCluster which is
+ * suspended by a Kueue eviction and whose Workload is deactivated. That
hold ends when the
+ * Workload is reactivated, which arrives as a watch event as well. 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 being visible, and keep {@link
+ * #KUBERNETES_EVENTS_MIN_INTERVAL_SECONDS} within the rest of that TTL,
that is at most the TTL
+ * minus this interval, or a repeat is dropped and the next one lands after
the event expired.
*/
public static final ConfigOption<Long> SUSPEND_HOLD_REQUEUE_INTERVAL_SECONDS
=
ConfigOption.<Long>builder()
@@ -288,7 +290,10 @@ public final class SparkOperatorConf {
+ "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 "
- + "reconciles right away, so nothing waits for this
interval. It is "
+ + "reconciles right away, so nothing waits for this
interval. It also paces a "
+ + "SparkApplication or SparkCluster which is suspended by a
Kueue eviction and "
+ + "whose Workload is deactivated. That hold ends when the
Workload is "
+ + "reactivated, which arrives as a watch event as well. It
is "
+ "deliberately much coarser than "
+ "spark.kubernetes.operator.reconciler.intervalSeconds,
since a suspended "
+ "resource has nothing to observe while each republish
costs a read and a "
diff --git
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
index 482fdba1..b06cd5a1 100644
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java
@@ -923,7 +923,7 @@ public final class KueueWorkloadUtils {
* timeout of Kueue does not evict it, and with `blockAdmission`, other
workloads are admitted.
* Unlike the built-in Kueue integrations, the condition is not set back to
`False` when a pod is
* lost later, e.g. an executor which Spark replaces, since an eviction
would restart a running
- * cluster with its applications, and is not acted on for a running
application.
+ * cluster with its applications, or the attempt of a running application
from scratch.
*
* <p>The informer cache answers whether the condition is still missing, so
that a resource which
* reports it already, or whose pods are not ready yet, costs no request.
Like {@link
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 9e8cbf53..0b894045 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
@@ -55,7 +55,6 @@ import
org.apache.spark.k8s.operator.reconciler.observers.AppDriverStartObserver
import
org.apache.spark.k8s.operator.reconciler.observers.AppDriverTimeoutObserver;
import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppCleanUpStep;
import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppInitStep;
-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.AppRunningStep;
@@ -207,7 +206,6 @@ public class SparkAppReconciler implements
Reconciler<SparkApplication>, Cleaner
switch (app.getStatus().getCurrentState().getCurrentStateSummary()) {
case Submitted, ScheduledToRestart -> steps.add(new AppInitStep());
case DriverRequested, DriverStarted -> {
- steps.add(new AppKueueEvictionStep());
steps.add(
new AppResourceObserveStep(
List.of(new AppDriverStartObserver(), new
AppDriverReadyObserver())));
@@ -220,7 +218,6 @@ public class SparkAppReconciler implements
Reconciler<SparkApplication>, Cleaner
RunningHealthy,
RunningWithPartialCapacity,
RunningWithBelowThresholdExecutors -> {
- steps.add(new AppKueueEvictionStep());
steps.add(new AppRunningStep());
steps.add(new AppResourceObserveStep(List.of(new
AppDriverRunningObserver())));
steps.add(new AppResourceObserveStep(List.of(new
AppDriverTimeoutObserver())));
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
deleted file mode 100644
index 26439d41..00000000
---
a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStep.java
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
- * 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.reconciler.ReconcileProgress.proceed;
-
-import java.util.Optional;
-
-import io.fabric8.kubernetes.api.model.Condition;
-
-import org.apache.spark.k8s.operator.context.SparkAppContext;
-import org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils;
-import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
-import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
-import org.apache.spark.k8s.operator.utils.EventUtils;
-import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
-
-/**
- * Reports an eviction or a deactivation of the Kueue Workload of an
application whose driver is
- * requested, which the operator does not act on yet. Unlike before the driver
is requested, see
- * {@link AppInitStep}, the driver and executors keep running. The status does
not show it, so the
- * event is published on every reconciliation while it lasts, like the pending
event of a queued
- * application, and its message stays the same meanwhile, so that {@link
- *
org.apache.spark.k8s.operator.config.SparkOperatorConf#KUBERNETES_EVENTS_MIN_INTERVAL_SECONDS}
- * paces the repeats.
- */
-public final class AppKueueEvictionStep extends AppReconcileStep {
- /**
- * Publishes the event if needed and always proceeds, so that the driver is
still observed.
- *
- * @param context The SparkAppContext for the application.
- * @param statusRecorder The SparkAppStatusRecorder for recording status
updates.
- * @return The ReconcileProgress to proceed with.
- */
- @Override
- public ReconcileProgress reconcile(
- SparkAppContext context, SparkAppStatusRecorder statusRecorder) {
- Optional<Workload> workload = context.getCachedKueueWorkload();
- // Kueue marks a deactivated Workload as evicted too, but only once it
sees the deactivation
- Optional<Condition> eviction =
workload.flatMap(KueueWorkloadUtils::findEviction);
- boolean deactivated =
workload.map(KueueWorkloadUtils::isDeactivated).orElse(false);
- if (eviction.isEmpty() && !deactivated) {
- return proceed();
- }
- String name = workload.get().getMetadata().getName();
- String cause =
- eviction.map(c -> " (" + c.getReason() + ": " + c.getMessage() +
")").orElse("");
- EventUtils.warn(
- context.getEventRecorder(),
- EventUtils.REASON_KUEUE_EVICTION_IGNORED,
- deactivated
- ? "Kueue Workload "
- + name
- + " is deactivated"
- + cause
- + ", which the operator does not act on once the driver is
requested, so the "
- + "driver and executors keep running although Kueue no longer
counts them against "
- + "its quota."
- : "Kueue evicted Workload "
- + name
- + 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. 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 caea2e58..7b4edf38 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
@@ -44,8 +44,8 @@ import
org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
/** Basic reconcile step for application. */
@Log4j2
public abstract sealed class AppReconcileStep
- permits AppCleanUpStep, AppInitStep, AppKueueEvictionStep,
AppResourceObserveStep,
- AppRunningStep, AppSuspendStep, AppUnknownStateStep, AppValidateStep {
+ permits AppCleanUpStep, AppInitStep, AppResourceObserveStep,
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
index 9737b743..b9ec67d8 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
@@ -19,6 +19,8 @@
package org.apache.spark.k8s.operator.reconciler.reconcilesteps;
+import static org.apache.spark.k8s.operator.Constants.APP_EVICTED_MESSAGE;
+import static org.apache.spark.k8s.operator.Constants.APP_REQUEUED_MESSAGE;
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;
@@ -39,6 +41,7 @@ import java.util.List;
import java.util.Optional;
import java.util.Set;
+import io.fabric8.kubernetes.api.model.Condition;
import io.fabric8.kubernetes.api.model.DeletionPropagation;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.client.KubernetesClient;
@@ -48,6 +51,7 @@ 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.kueue.v1beta2.Workload;
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;
@@ -58,8 +62,9 @@ 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.
+ * Suspends an application whose driver is requested by spec.suspend, or by
the eviction of its
+ * Kueue Workload, and resumes it. An application suspended before its driver
is requested is held
+ * by {@link AppInitStep} instead, which handles an eviction before then as
well.
*
* <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
@@ -76,6 +81,12 @@ import
org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
* 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.
+ *
+ * <p>An application whose Kueue Workload is evicted, e.g. to preempt it, is
suspended the same way
+ * without spec.suspend, so that it is queued again with a new attempt once
everything is released.
+ * Like a resumed one, that attempt does not count against the restart limits,
since an eviction
+ * is a requeue rather than a failure. spec.suspend takes precedence, so that
an application which
+ * is suspended by both is held until it is resumed, rather than queued again.
*/
@Slf4j
public final class AppSuspendStep extends AppReconcileStep {
@@ -87,7 +98,7 @@ public final class AppSuspendStep extends AppReconcileStep {
/**
* Reconciles an application whose driver is requested, or which is
suspended, against
- * spec.suspend.
+ * spec.suspend and the eviction of its Kueue Workload.
*
* @param context The SparkAppContext for the application.
* @param statusRecorder The SparkAppStatusRecorder for recording status
updates.
@@ -99,7 +110,12 @@ public final class AppSuspendStep extends AppReconcileStep {
SparkApplication app = context.getResource();
boolean suspend = app.getSpec().isSuspend();
ApplicationStateSummary summary =
app.getStatus().getCurrentState().getCurrentStateSummary();
- if (DRIVER_REQUESTED_STATES.contains(summary) && suspend) {
+ if (DRIVER_REQUESTED_STATES.contains(summary)) {
+ Optional<String> message =
+ suspend ? Optional.of(APP_SUSPENDED_MESSAGE) :
findKueueEviction(context);
+ if (message.isEmpty()) {
+ return proceed();
+ }
Optional<ApplicationState> termination;
try {
termination = observeDriverTermination(context);
@@ -113,30 +129,104 @@ 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.
return statusRecorder.appendNewStateAndPersist(
- context,
- new ApplicationState(ApplicationStateSummary.Suspended,
APP_SUSPENDED_MESSAGE))
+ context, new ApplicationState(ApplicationStateSummary.Suspended,
message.get()))
? completeAndImmediateRequeue()
: completeAndDefaultRequeue();
}
if (summary != ApplicationStateSummary.Suspended) {
return proceed();
}
- Optional<ReconcileProgress> waiting = releaseResources(context);
+ boolean evicted = isSuspendedByEviction(app);
+ if (evicted && suspend) {
+ // The application is held by spec.suspend from now on, so that its
status no longer says
+ // that it is queued again, and a later resume is reported as such.
+ return appendStateAndImmediateRequeue(
+ context,
+ statusRecorder,
+ new ApplicationState(ApplicationStateSummary.Suspended,
APP_SUSPENDED_MESSAGE));
+ }
+ Optional<Duration> keepWorkload = evicted ? keepKueueWorkload(context) :
Optional.empty();
+ Optional<ReconcileProgress> waiting = releaseResources(context,
keepWorkload.isEmpty());
if (waiting.isPresent()) {
return waiting.get();
}
+ if (keepWorkload.isPresent()) {
+ return completeAndRequeueAfter(keepWorkload.get());
+ }
if (suspend) {
return completeAndRequeueAfter(
Duration.ofSeconds(SUSPEND_HOLD_REQUEUE_INTERVAL_SECONDS.getValue()));
}
+ String resumedMessage = evicted ? APP_REQUEUED_MESSAGE :
APP_RESUMED_MESSAGE;
return statusRecorder.persistStatus(
context,
app.getStatus()
- .resume(APP_RESUMED_MESSAGE,
TRIM_ATTEMPT_STATE_TRANSITION_HISTORY.getValue()))
+ .resume(resumedMessage,
TRIM_ATTEMPT_STATE_TRANSITION_HISTORY.getValue()))
? completeAndImmediateRequeue()
: completeAndDefaultRequeue();
}
+ /**
+ * Returns the message of the Suspended state of an application whose Kueue
Workload is evicted,
+ * so that its driver and executors and then its Workload are released like
on spec.suspend. Like
+ * the built-in Kueue integrations, which stop the job on any eviction, a
`PodsReadyTimeout`
+ * suspends the application as well, even if its driver and executors are
ready by now, or if its
+ * applicationTolerations let it run with fewer executors than requested.
Kueue marks a
+ * deactivated Workload as evicted, too.
+ *
+ * @param context The SparkAppContext for the application.
+ * @return The message which gives the reason of the eviction, or empty if
the Workload is not
+ * evicted.
+ */
+ private static Optional<String> findKueueEviction(SparkAppContext context) {
+ Optional<Condition> eviction =
+
context.getCachedKueueWorkload().flatMap(KueueWorkloadUtils::findEviction);
+ if (eviction.isEmpty()) {
+ return Optional.empty();
+ }
+ String cause = eviction.get().getReason() + ": " +
eviction.get().getMessage();
+ log.info("Kueue evicted the Workload of the application ({}), suspending
it.", cause);
+ return Optional.of(APP_EVICTED_MESSAGE + " " + cause);
+ }
+
+ /**
+ * 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.
+ *
+ * @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);
+ }
+
+ /**
+ * Returns how long the Kueue Workload of an application which is suspended
without spec.suspend
+ * is kept after its driver and executors are released, or empty to release
it right away and
+ * queue the application again. A deactivated Workload is kept until it is
reactivated, which the
+ * Workload informer reconciles right away: Kueue no longer counts its
quota, and a new Workload
+ * of the application would come back active. An evicted Workload is kept
until the requeue
+ * backoff which Kueue records on it elapses, see {@link
+ * KueueWorkloadUtils#remainingRequeueBackoff}.
+ *
+ * @param context The SparkAppContext for the application.
+ * @return The requeue interval while the Workload is kept, or empty to
release it.
+ */
+ private static Optional<Duration> keepKueueWorkload(SparkAppContext context)
{
+ Optional<Workload> workload = context.getCachedKueueWorkload();
+ if (workload.isEmpty()) {
+ return Optional.empty();
+ }
+ if (KueueWorkloadUtils.isDeactivated(workload.get())) {
+ return
Optional.of(Duration.ofSeconds(SUSPEND_HOLD_REQUEUE_INTERVAL_SECONDS.getValue()));
+ }
+ Duration backoff =
KueueWorkloadUtils.remainingRequeueBackoff(workload.get());
+ return backoff.isZero() ? Optional.empty() : Optional.of(backoff);
+ }
+
/**
* 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
@@ -181,10 +271,12 @@ public final class AppSuspendStep extends
AppReconcileStep {
* 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.
+ * @param releaseWorkload Whether the Kueue Workload is released as well.
* @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) {
+ private Optional<ReconcileProgress> releaseResources(
+ SparkAppContext context, boolean releaseWorkload) {
SparkApplication app = context.getResource();
ApplicationTimeoutConfig timeoutConfig =
app.getSpec().getApplicationTolerations().getApplicationTimeoutConfig();
@@ -223,8 +315,10 @@ public final class AppSuspendStep extends AppReconcileStep
{
? completeAndRequeueAfter(remaining)
: defaultRequeue);
}
- // A Workload admitted before the queue label was removed is released as
well.
- KueueWorkloadUtils.deleteWorkloadOf(client, app);
+ if (releaseWorkload) {
+ // 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)) {
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 376fa3a3..bb0ecd39 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
@@ -98,12 +98,6 @@ public final class EventUtils {
*/
public static final String REASON_KUEUE_DISABLED = "KueueDisabled";
- /**
- * Reason for an event describing that Kueue evicted or deactivated the
Workload of a running
- * resource in a way which the operator does not act on, so the resource
keeps running.
- */
- public static final String REASON_KUEUE_EVICTION_IGNORED =
"KueueEvictionIgnored";
-
/** Maximum number of characters an event message may have, including the
ellipsis. */
static final int MAX_MESSAGE_LENGTH = 1024;
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 fc6ad9b5..59989468 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
@@ -88,7 +88,6 @@ 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.AppSuspendStep;
import org.apache.spark.k8s.operator.reconciler.reconcilesteps.AppValidateStep;
@@ -613,7 +612,8 @@ class SparkAppReconcilerTest {
} 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
+ // suspended, by spec.suspend or by a Kueue eviction, and run again
when the application is
+ // resumed. Before the driver is requested, AppInitStep handles both
itself.
assertEquals(
expected, steps.get(steps.size() - 1) instanceof AppSuspendStep,
summary.name());
assertEquals(
@@ -623,20 +623,4 @@ class SparkAppReconcilerTest {
}
}
}
-
- @Test
- void kueueEvictionIsReportedFromTheDriverRequestUntilTheAttemptStops() {
- for (ApplicationStateSummary summary : ApplicationStateSummary.values()) {
- app.setStatus(new ApplicationStatus().appendNewState(new
ApplicationState(summary, "")));
- List<AppReconcileStep> steps = reconciler.getReconcileSteps(app);
- 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());
- assertEquals(
- expected ? 1 : 0,
-
steps.stream().filter(AppKueueEvictionStep.class::isInstance).count(),
- summary.name());
- }
- }
}
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
deleted file mode 100644
index 810b656e..00000000
---
a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStepTest.java
+++ /dev/null
@@ -1,150 +0,0 @@
-/*
- * 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.Mockito.mock;
-import static org.mockito.Mockito.times;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.when;
-
-import java.util.List;
-import java.util.Optional;
-
-import io.fabric8.kubernetes.api.model.Condition;
-import io.fabric8.kubernetes.api.model.ConditionBuilder;
-import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
-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.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.ValueSource;
-import org.mockito.ArgumentCaptor;
-
-import org.apache.spark.k8s.operator.context.SparkAppContext;
-import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
-import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadSpec;
-import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadStatus;
-import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
-import org.apache.spark.k8s.operator.utils.EventUtils;
-import org.apache.spark.k8s.operator.utils.SparkAppStatusRecorder;
-
-class AppKueueEvictionStepTest {
- private final SparkAppContext context = mock(SparkAppContext.class);
- private final SparkAppStatusRecorder recorder =
mock(SparkAppStatusRecorder.class);
- private final ResourceEventRecorder eventRecorder =
mock(ResourceEventRecorder.class);
-
- @BeforeEach
- void setUp() {
- when(context.getEventRecorder()).thenReturn(eventRecorder);
- }
-
- @Test
- void workloadWhichIsMissingOrAdmittedIsNotReported() {
- AppKueueEvictionStep step = new AppKueueEvictionStep();
- when(context.getCachedKueueWorkload()).thenReturn(Optional.empty());
- Assertions.assertEquals(ReconcileProgress.proceed(),
step.reconcile(context, recorder));
-
-
when(context.getCachedKueueWorkload()).thenReturn(Optional.of(workload(true,
admitted())));
- Assertions.assertEquals(ReconcileProgress.proceed(),
step.reconcile(context, recorder));
-
- verifyNoInteractions(eventRecorder, recorder);
- }
-
- @ParameterizedTest
- @ValueSource(strings = {"Preempted", "PodsReadyTimeout",
"ClusterQueueStopped"})
- void evictionIsReportedAndIgnored(String reason) {
- when(context.getCachedKueueWorkload())
- .thenReturn(Optional.of(workload(true, admitted(), evicted(reason))));
-
- // The driver is still observed, e.g. until it completes
- Assertions.assertEquals(
- ReconcileProgress.proceed(), new
AppKueueEvictionStep().reconcile(context, recorder));
-
- String message = captureMessages(1).get(0);
- Assertions.assertTrue(
- message.contains("(" + reason + ": " + reason + " by the test)"),
message);
- // 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);
- }
-
- @Test
- void deactivationIsReportedAndIgnored() {
- when(context.getCachedKueueWorkload())
- // Before Kueue marks the deactivated Workload as evicted
- .thenReturn(Optional.of(workload(false, admitted())))
- .thenReturn(Optional.of(workload(false, admitted(),
evicted("Deactivated"))))
- // Reactivated, while Kueue keeps the Evicted condition
- .thenReturn(Optional.of(workload(true, admitted(),
evicted("Deactivated"))));
- AppKueueEvictionStep step = new AppKueueEvictionStep();
- for (int i = 0; i < 3; i++) {
- Assertions.assertEquals(ReconcileProgress.proceed(),
step.reconcile(context, recorder));
- }
-
- List<String> messages = captureMessages(3);
- // Kueue stops counting the quota of a deactivated Workload
- Assertions.assertTrue(messages.get(0).contains("is deactivated, "));
- Assertions.assertTrue(messages.get(0).contains("no longer counts"));
- Assertions.assertTrue(
- messages.get(1).contains("is deactivated (Deactivated: Deactivated by
the test)"));
- Assertions.assertTrue(messages.get(1).contains("no longer counts"));
- // And counts it again once it is reactivated
- Assertions.assertTrue(messages.get(2).contains("holding its quota"));
- verifyNoInteractions(recorder);
- }
-
- /** Returns the messages of the published events, which are all
KueueEvictionIgnored warnings. */
- private List<String> captureMessages(int count) {
- ArgumentCaptor<EventRecord> captor =
ArgumentCaptor.forClass(EventRecord.class);
- verify(eventRecorder, times(count)).record(captor.capture());
- for (EventRecord event : captor.getAllValues()) {
- Assertions.assertEquals(EventType.WARNING, event.type());
- Assertions.assertEquals(EventUtils.REASON_KUEUE_EVICTION_IGNORED,
event.reason());
- }
- return captor.getAllValues().stream().map(EventRecord::message).toList();
- }
-
- private static Workload workload(boolean active, Condition... conditions) {
- Workload workload = new Workload();
- workload.setMetadata(new
ObjectMetaBuilder().withName("sparkapplication-app1").build());
- workload.setSpec(WorkloadSpec.builder().active(active).build());
-
workload.setStatus(WorkloadStatus.builder().conditions(List.of(conditions)).build());
- return workload;
- }
-
- private static Condition admitted() {
- return new
ConditionBuilder().withType("Admitted").withStatus("True").build();
- }
-
- private static Condition evicted(String reason) {
- return new ConditionBuilder()
- .withType("Evicted")
- .withStatus("True")
- .withReason(reason)
- .withMessage(reason + " by the test")
- .build();
- }
-}
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 5d8cde58..bb1a7c3a 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
@@ -34,8 +34,10 @@ import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.concurrent.TimeUnit;
+import io.fabric8.kubernetes.api.model.ConditionBuilder;
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.api.model.PodBuilder;
@@ -52,6 +54,7 @@ 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.CsvSource;
import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
@@ -61,7 +64,9 @@ 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.RequeueState;
import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
+import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadStatus;
import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
import org.apache.spark.k8s.operator.spec.ResourceRetainPolicy;
import org.apache.spark.k8s.operator.status.ApplicationState;
@@ -593,6 +598,286 @@ class AppSuspendStepTest {
new AppSuspendStep().reconcile(mockContext, recorder));
}
+ @ParameterizedTest
+ @CsvSource({"Preempted, true", "Deactivated, false", "Deactivated, true"})
+ void evictedRunningAppEntersSuspendedBeforeReleasingResources(String reason,
boolean active) {
+ // A deactivated Workload no longer counts against the quota, and a
reactivated one is queued
+ // again, so both release the driver and executors like a preemption
+ SparkApplication app =
buildKueueApp(ApplicationStateSummary.RunningHealthy, false);
+ stubContext(app);
+ createDriver(app);
+ createWorkload(app, evictedStatus(reason), active);
+
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndImmediateRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+
+ ApplicationState state = captureAppendedState();
+ Assertions.assertEquals(ApplicationStateSummary.Suspended,
state.getCurrentStateSummary());
+ Assertions.assertEquals(
+ Constants.APP_EVICTED_MESSAGE + " " + reason + ": " + reason + " by
the test",
+ state.getMessage());
+ // Like spec.suspend, nothing is released until Suspended is persisted,
and nothing is reported
+ // but the Suspended state
+ Assertions.assertNotNull(getDriver());
+ Assertions.assertNotNull(getWorkload());
+ verifyNoInteractions(eventRecorder);
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = ApplicationStateSummary.class,
+ from = "DriverRequested",
+ to = "RunningWithBelowThresholdExecutors")
+ void podsReadyTimeoutSuspendsAppLikeAnyOtherEviction(ApplicationStateSummary
summary) {
+ // Including an application which runs with fewer executors than
spark.executor.instances, as
+ // its applicationTolerations allow, since its Workload requested the
quota for all of them
+ SparkApplication app = buildKueueApp(summary, false);
+ stubContext(app);
+ createDriver(app);
+ createWorkload(app, evictedStatus("PodsReadyTimeout"), true);
+
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndImmediateRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+
+ Assertions.assertEquals(
+ ApplicationStateSummary.Suspended,
captureAppendedState().getCurrentStateSummary());
+ }
+
+ @Test
+ void runningAppWithAdmittedWorkloadProceeds() {
+ SparkApplication app =
buildKueueApp(ApplicationStateSummary.RunningHealthy, false);
+ stubContext(app);
+ createDriver(app);
+ createWorkload(
+ app,
+ WorkloadStatus.builder()
+ .conditions(
+ List.of(new
ConditionBuilder().withType("Admitted").withStatus("True").build()))
+ .build(),
+ true);
+
+ Assertions.assertEquals(
+ ReconcileProgress.proceed(), new
AppSuspendStep().reconcile(mockContext, recorder));
+
+ verifyNoInteractions(recorder, eventRecorder);
+ Assertions.assertNotNull(getWorkload());
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"Succeeded", "Failed"})
+ void attemptWhoseDriverEndedMeanwhileEndsAsUsualOnEviction(String phase) {
+ SparkApplication app =
buildKueueApp(ApplicationStateSummary.RunningHealthy, false);
+ stubContext(app);
+ kubernetesClient
+ .resource(new
PodBuilder(driver(app)).withNewStatus().withPhase(phase).endStatus().build())
+ .create();
+ createWorkload(app, evictedStatus("Preempted"), true);
+
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndImmediateRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+
+ // The attempt ends rather than being queued again and run from scratch
+ Assertions.assertEquals(
+ ApplicationStateSummary.valueOf(phase),
captureAppendedState().getCurrentStateSummary());
+ Assertions.assertNotNull(getWorkload());
+ }
+
+ @Test
+ void suspendTakesPrecedenceOverEviction() {
+ SparkApplication app =
buildKueueApp(ApplicationStateSummary.RunningHealthy, true);
+ stubContext(app);
+ createDriver(app);
+ createWorkload(app, evictedStatus("Preempted"), true);
+
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndImmediateRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+
+ ApplicationState state = captureAppendedState();
+ Assertions.assertEquals(Constants.APP_SUSPENDED_MESSAGE,
state.getMessage());
+
+ // Once everything is released, the application is held rather than queued
again
+ app.setStatus(app.getStatus().appendNewState(state));
+ Assertions.assertEquals(
+ WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext,
recorder));
+ Assertions.assertEquals(
+ SUSPEND_HOLD_PROGRESS, new AppSuspendStep().reconcile(mockContext,
recorder));
+ Assertions.assertNull(getWorkload());
+ verify(recorder).appendNewStateAndPersist(any(), any());
+ verify(recorder, never()).persistStatus(any(), any());
+ }
+
+ @Test
+ void suspendingEvictedAppIsRecorded() {
+ SparkApplication app = buildEvictedApp(true);
+ stubContext(app);
+ createWorkload(app, evictedStatus("Preempted"), true);
+
+ // The application is no longer queued again once released, and resuming
it later says so
+ 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());
+
+ // Once that state is persisted, the application is held rather than
suspended again, and its
+ // Workload is released like on spec.suspend
+ app.setStatus(app.getStatus().appendNewState(state));
+ Assertions.assertEquals(
+ SUSPEND_HOLD_PROGRESS, new AppSuspendStep().reconcile(mockContext,
recorder));
+ verify(recorder).appendNewStateAndPersist(any(), any());
+ Assertions.assertNull(getWorkload());
+ }
+
+ @Test
+ void evictedThenSuspendedAppIsResumedRatherThanQueuedAgain() {
+ SparkApplication app = buildEvictedApp(true);
+ stubContext(app);
+ createWorkload(app, evictedStatus("Preempted"), true);
+ new AppSuspendStep().reconcile(mockContext, recorder);
+ app.setStatus(app.getStatus().appendNewState(captureAppendedState()));
+
+ // Held by spec.suspend since the eviction, so clearing it is a resume,
not a requeue
+ app.getSpec().setSuspend(false);
+ new AppSuspendStep().reconcile(mockContext, recorder);
+
+ Assertions.assertEquals(Constants.APP_RESUMED_MESSAGE,
capturePersistedState().getMessage());
+ }
+
+ @Test
+ void evictedAppIsQueuedAgainOnceEverythingIsReleased() {
+ SparkApplication app = buildEvictedApp(false);
+ stubContext(app);
+ createDriver(app);
+ createWorkload(app, evictedStatus("Preempted"), true);
+
+ // 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);
+
+ // Then it starts a new attempt like a resumed application, see
ApplicationStatus#resume
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndImmediateRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+ Assertions.assertEquals(Constants.APP_REQUEUED_MESSAGE,
capturePersistedState().getMessage());
+ // AppInitStep creates a new Workload of the same name and waits for its
admission
+ Assertions.assertNull(getWorkload());
+ }
+
+ @Test
+ void deactivatedWorkloadIsKeptUntilItIsReactivated() {
+ SparkApplication app = buildEvictedApp(false);
+ stubContext(app);
+ createDriver(app);
+ createWorkload(app, evictedStatus("Deactivated"), false);
+
+ // The driver and executors are released, since Kueue no longer counts
their quota
+ Assertions.assertEquals(
+ WAITING_FOR_PODS_PROGRESS, new AppSuspendStep().reconcile(mockContext,
recorder));
+ Assertions.assertNull(getDriver());
+
+ // While the Workload is kept, since a new Workload of the application
would come back active
+ Assertions.assertEquals(
+ SUSPEND_HOLD_PROGRESS, new AppSuspendStep().reconcile(mockContext,
recorder));
+ Assertions.assertNotNull(getWorkload());
+ verifyNoInteractions(recorder);
+
+ // Its reactivation, which the Workload informer reconciles, queues the
application again
+ Workload workload = getWorkload();
+ workload.getSpec().setActive(true);
+ kubernetesClient.resource(workload).update();
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndImmediateRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+
+ Assertions.assertEquals(Constants.APP_REQUEUED_MESSAGE,
capturePersistedState().getMessage());
+ Assertions.assertNull(getWorkload());
+ }
+
+ @Test
+ void evictedWorkloadIsKeptUntilTheRequeueBackoffElapses() {
+ SparkApplication app = buildEvictedApp(false);
+ stubContext(app);
+ WorkloadStatus status = evictedStatus("PodsReadyTimeout");
+ status.setRequeueState(
+ RequeueState.builder()
+ .count(1)
+ .requeueAt(Instant.now().plusSeconds(60).toString())
+ .build());
+ createWorkload(app, status, true);
+
+ ReconcileProgress progress = new AppSuspendStep().reconcile(mockContext,
recorder);
+
+ // Its deletion would drop the backoff, so it is released once the backoff
elapsed
+ Duration requeueAfter = progress.getRequeueAfterDuration();
+ Assertions.assertTrue(progress.isRequeue());
+ Assertions.assertTrue(
+ requeueAfter.compareTo(Duration.ofSeconds(50)) > 0
+ && requeueAfter.compareTo(Duration.ofSeconds(60)) <= 0,
+ requeueAfter::toString);
+ Assertions.assertNotNull(getWorkload());
+ verifyNoInteractions(recorder);
+ }
+
+ @Test
+ void failedReleaseIsRetriedWhileDeactivatedWorkloadIsKept() {
+ SparkApplication app = buildEvictedApp(false);
+ createWorkload(app, evictedStatus("Deactivated"), false);
+ KubernetesClient client = spy(kubernetesClient);
+ doThrow(new KubernetesClientException("Service Unavailable", 503,
null)).when(client).pods();
+ stubContext(app, client);
+
+ // Rather than after the hold interval of the deactivated Workload, which
would keep the driver
+ // and executors running for up to 30 minutes outside of the quota
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndDefaultRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+
+ Assertions.assertNotNull(getWorkload());
+ verifyNoInteractions(recorder, eventRecorder);
+ }
+
+ @Test
+ void resumedAppReleasesDeactivatedWorkload() {
+ SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended,
false);
+ stubContext(app);
+ // The Workload outlived the release on spec.suspend, e.g. after a failed
delete
+ createWorkload(app, evictedStatus("Deactivated"), false);
+
+ // Only an eviction keeps the Workload, so the resume does not wait for
its reactivation
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndImmediateRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+
+ Assertions.assertEquals(Constants.APP_RESUMED_MESSAGE,
capturePersistedState().getMessage());
+ Assertions.assertNull(getWorkload());
+ }
+
+ @Test
+ void evictedAppWhoseQueueLabelIsRemovedEntersSuspended() {
+ SparkApplication app =
buildKueueApp(ApplicationStateSummary.RunningHealthy, false);
+ stubContext(app);
+ createDriver(app);
+ createWorkload(app, evictedStatus("Preempted"), true);
+ // Like the release on spec.suspend, the Workload admitted before the
label was removed counts
+ app.getMetadata().setLabels(Map.of());
+
+ Assertions.assertEquals(
+ ReconcileProgress.completeAndImmediateRequeue(),
+ new AppSuspendStep().reconcile(mockContext, recorder));
+
+ Assertions.assertEquals(
+ ApplicationStateSummary.Suspended,
captureAppendedState().getCurrentStateSummary());
+ }
+
@ParameterizedTest
@EnumSource(
value = ApplicationStateSummary.class,
@@ -630,6 +915,8 @@ class AppSuspendStepTest {
when(mockContext.getResource()).thenReturn(app);
when(mockContext.getClient()).thenReturn(client);
when(mockContext.getEventRecorder()).thenReturn(eventRecorder);
+ // Like the Workload informer, the cache follows the Workload in the API
server
+ when(mockContext.getCachedKueueWorkload()).thenAnswer(i ->
Optional.ofNullable(getWorkload()));
}
private ApplicationState captureAppendedState() {
@@ -639,6 +926,14 @@ class AppSuspendStepTest {
return captor.getValue();
}
+ private ApplicationState capturePersistedState() {
+ 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());
+ return state;
+ }
+
private EventRecord captureEvent() {
ArgumentCaptor<EventRecord> event =
ArgumentCaptor.forClass(EventRecord.class);
verify(eventRecorder).record(event.capture());
@@ -686,6 +981,29 @@ class AppSuspendStepTest {
kubernetesClient.resource(KueueWorkloadFactory.buildWorkload(app)).create();
}
+ private void createWorkload(SparkApplication app, WorkloadStatus status,
boolean active) {
+ Workload desired = KueueWorkloadFactory.buildWorkload(app);
+ desired.getSpec().setActive(active);
+ kubernetesClient.resource(desired).create();
+ Workload workload = getWorkload();
+ workload.setStatus(status);
+ kubernetesClient.resource(workload).update();
+ }
+
+ private static WorkloadStatus evictedStatus(String reason) {
+ return WorkloadStatus.builder()
+ .conditions(
+ List.of(
+ new
ConditionBuilder().withType("Admitted").withStatus("True").build(),
+ new ConditionBuilder()
+ .withType("Evicted")
+ .withStatus("True")
+ .withReason(reason)
+ .withMessage(reason + " by the test")
+ .build()))
+ .build();
+ }
+
private static Pod driver(SparkApplication app) {
return pod(DRIVER, Utils.driverLabels(app));
}
@@ -726,4 +1044,13 @@ class AppSuspendStepTest {
app.getMetadata().setLabels(Map.of(Constants.LABEL_QUEUE_NAME,
"test-queue"));
return app;
}
+
+ /** Builds an application which entered Suspended on a Kueue eviction. */
+ private static SparkApplication buildEvictedApp(boolean suspend) {
+ SparkApplication app = buildKueueApp(ApplicationStateSummary.Suspended,
suspend);
+ app.getStatus()
+ .getCurrentState()
+ .setMessage(Constants.APP_EVICTED_MESSAGE + " Preempted: Preempted by
the test");
+ return app;
+ }
}
diff --git a/tests/e2e/kueue/chainsaw-test.yaml
b/tests/e2e/kueue/chainsaw-test.yaml
index 4435a009..ce84f314 100644
--- a/tests/e2e/kueue/chainsaw-test.yaml
+++ b/tests/e2e/kueue/chainsaw-test.yaml
@@ -804,6 +804,141 @@ spec:
kubectl delete clusterqueue "$CLUSTER_QUEUE" --ignore-not-found=true
--wait=false
kubectl delete resourceflavor "$FLAVOR" --ignore-not-found=true
--wait=false
kubectl delete workloadpriorityclass "$LOW_PRIORITY_CLASS"
"$PRIORITY_CLASS" --ignore-not-found=true --wait=false
+ - name: spark-application-is-preempted-by-kueue
+ # Each step uses its own queue objects, so that a step never waits for
Kueue to finish
+ # deleting the objects of another step.
+ bindings:
+ - name: FLAVOR
+ value: spark-e2e-app-preemption-flavor
+ - name: CLUSTER_QUEUE
+ value: spark-e2e-app-preemption-cluster-queue
+ - name: LOCAL_QUEUE
+ value: spark-e2e-app-preemption-queue
+ - name: LOW_PRIORITY_CLASS
+ value: spark-e2e-app-preemption-low-priority
+ - name: PRIORITY_CLASS
+ value: spark-e2e-app-preemption-high-priority
+ try:
+ - apply:
+ file: kueue-queues.yaml
+ # The quota fits only one application (1 CPU for the driver and 1 for the
executor), and a
+ # Workload of a higher priority may preempt one of a lower priority.
+ - script:
+ timeout: 30s
+ env:
+ - name: CLUSTER_QUEUE
+ value: ($CLUSTER_QUEUE)
+ content: |
+ kubectl patch clusterqueue "$CLUSTER_QUEUE" --type json -p \
+ '[{"op": "replace", "path":
"/spec/resourceGroups/0/flavors/0/resources/0/nominalQuota", "value": "2"},
+ {"op": "replace", "path":
"/spec/resourceGroups/0/flavors/0/resources/1/nominalQuota", "value": "8Gi"},
+ {"op": "add", "path": "/spec/preemption", "value":
{"withinClusterQueue": "LowerPriority"}}]'
+ - apply:
+ file: spark-example-preempted.yaml
+ # Its driver and executor are ready, so the eviction below is a
preemption, not a
+ # PodsReadyTimeout
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: Workload
+ metadata:
+ name: sparkapplication-spark-job-kueue-preempted-test
+ namespace: default
+ status:
+ (conditions[?type == 'PodsReady' && status == 'True'] |
length(@)): 1
+ - apply:
+ file: spark-example-prioritized.yaml
+ # Kueue evicts the Workload of the running application, and keeps its
quota until the operator
+ # releases the driver and executor first and then the Workload.
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: v1
+ kind: Event
+ metadata:
+ namespace: default
+ involvedObject:
+ kind: SparkApplication
+ name: spark-job-kueue-preempted-test
+ reason: Suspended
+ (contains(message || '', 'evicted by Kueue')): true
+ (contains(message || '', 'Preempted')): true
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: v1
+ kind: Event
+ metadata:
+ namespace: default
+ involvedObject:
+ kind: SparkApplication
+ name: spark-job-kueue-preempted-test
+ reason: Submitted
+ (contains(message || '', 'after its eviction')): true
+ # The application of the higher priority is admitted into the quota which
the preempted one
+ # gave back
+ - assert:
+ bindings:
+ - name: SPARK_APP_NAMESPACE
+ value: default
+ - name: SPARK_APPLICATION_NAME
+ value: spark-job-kueue-prioritized-test
+ timeout: 10m
+ file: "../assertions/spark-application/spark-state-transition.yaml"
+ # The preempted application is admitted again with a new Workload once the
other one releases
+ # the quota, and runs again with a new attempt, which does not count as a
restart, even with
+ # the default restartPolicy Never
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: spark.apache.org/v1
+ kind: SparkApplication
+ metadata:
+ name: spark-job-kueue-preempted-test
+ namespace: default
+ status:
+ currentState:
+ currentStateSummary: RunningHealthy
+ currentAttemptSummary:
+ attemptInfo:
+ id: 1
+ restartCounter: 0
+ catch:
+ - describe:
+ apiVersion: spark.apache.org/v1
+ kind: SparkApplication
+ namespace: default
+ - describe:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: Workload
+ namespace: default
+ - describe:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: ClusterQueue
+ - events:
+ namespace: default
+ finally:
+ - script:
+ timeout: 120s
+ env:
+ - name: FLAVOR
+ value: ($FLAVOR)
+ - name: CLUSTER_QUEUE
+ value: ($CLUSTER_QUEUE)
+ - name: LOCAL_QUEUE
+ value: ($LOCAL_QUEUE)
+ - name: LOW_PRIORITY_CLASS
+ value: ($LOW_PRIORITY_CLASS)
+ - name: PRIORITY_CLASS
+ value: ($PRIORITY_CLASS)
+ content: |
+ kubectl delete sparkapplication spark-job-kueue-prioritized-test
spark-job-kueue-preempted-test -n default --ignore-not-found=true
+ # Kueue finalizers may delay the deletion, which later steps do not
depend on.
+ kubectl delete localqueue "$LOCAL_QUEUE" -n default
--ignore-not-found=true --wait=false
+ kubectl delete clusterqueue "$CLUSTER_QUEUE" --ignore-not-found=true
--wait=false
+ kubectl delete resourceflavor "$FLAVOR" --ignore-not-found=true
--wait=false
+ kubectl delete workloadpriorityclass "$LOW_PRIORITY_CLASS"
"$PRIORITY_CLASS" --ignore-not-found=true --wait=false
- name: spark-cluster-whose-pods-are-not-ready-is-evicted-by-kueue
# Each step uses its own queue objects, so that a step never waits for
Kueue to finish
# deleting the objects of another step.
@@ -1027,7 +1162,9 @@ spec:
namespace: default
status:
(conditions[?type == 'Evicted' && status == 'True' && reason ==
'PodsReadyTimeout'] | length(@)): 1
- # A running application is not released on it yet
+ # Like a running SparkCluster, the operator releases the driver and
executor first and then the
+ # Workload once its requeue backoff, which the CI shortens to 10 seconds,
elapses, and the
+ # application is queued again with a new attempt.
- assert:
timeout: 60s
resource:
@@ -1038,70 +1175,24 @@ spec:
involvedObject:
kind: SparkApplication
name: spark-job-kueue-executors-not-ready-test
- type: Warning
- reason: KueueEvictionIgnored
+ reason: Suspended
+ (contains(message || '', 'evicted by Kueue')): true
(contains(message || '', 'PodsReadyTimeout')): true
- assert:
- timeout: 30s
- resource:
- apiVersion: spark.apache.org/v1
- kind: SparkApplication
- metadata:
- name: spark-job-kueue-executors-not-ready-test
- namespace: default
- 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
+ timeout: 5m
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
+ kind: Event
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}}'
+ involvedObject:
+ kind: SparkApplication
+ name: spark-job-kueue-executors-not-ready-test
+ reason: Submitted
+ (contains(message || '', 'after its eviction')): true
+ # The new attempt does not count as a restart, even with the default
restartPolicy Never, and
+ # runs again on the quota which Kueue admits its new Workload into. It is
evicted again once the
+ # waitForPodsReady timeout elapses, since its executor is never scheduled.
- assert:
timeout: 5m
resource:
@@ -1113,15 +1204,21 @@ spec:
status:
currentState:
currentStateSummary: RunningHealthy
+ currentAttemptSummary:
+ attemptInfo:
+ id: 1
+ restartCounter: 0
- assert:
timeout: 60s
resource:
apiVersion: kueue.x-k8s.io/v1beta2
- kind: ClusterQueue
+ kind: Workload
metadata:
- name: ($CLUSTER_QUEUE)
+ name: sparkapplication-spark-job-kueue-executors-not-ready-test
+ namespace: default
status:
- admittedWorkloads: 1
+ (conditions[?type == 'Admitted' && status == 'True'] | length(@)):
1
+ (conditions[?type == 'Evicted' && status == 'True'] | length(@)): 0
catch:
- describe:
apiVersion: spark.apache.org/v1
diff --git a/tests/e2e/kueue/spark-example-preempted.yaml
b/tests/e2e/kueue/spark-example-preempted.yaml
new file mode 100644
index 00000000..2f2eec03
--- /dev/null
+++ b/tests/e2e/kueue/spark-example-preempted.yaml
@@ -0,0 +1,42 @@
+#
+# 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: kueue.x-k8s.io/v1beta2
+kind: WorkloadPriorityClass
+metadata:
+ name: ($LOW_PRIORITY_CLASS)
+value: 100
+---
+apiVersion: spark.apache.org/v1
+kind: SparkApplication
+metadata:
+ name: spark-job-kueue-preempted-test
+ namespace: default
+ labels:
+ kueue.x-k8s.io/queue-name: ($LOCAL_QUEUE)
+ kueue.x-k8s.io/priority-class: ($LOW_PRIORITY_CLASS)
+spec:
+ # A streaming job which runs until it is deleted, so that it is still
running when it is
+ # preempted, and when its next attempt is checked
+ mainClass: "org.apache.spark.examples.streaming.CustomReceiver"
+ driverArgs: ["localhost", "9999"]
+ jars: "local:///opt/spark/examples/jars/spark-examples.jar"
+ sparkConf:
+ spark.executor.instances: "1"
+ 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]