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 a556c358 [SPARK-59866] Support K8s events for Kueue eviction of
running `SparkApplication`
a556c358 is described below
commit a556c358af01c9b610d7371c679c05b63e8ec8a4
Author: Dongjoon Hyun <[email protected]>
AuthorDate: Tue Sep 29 08:45:31 2026 -0700
[SPARK-59866] Support K8s events for Kueue eviction of running
`SparkApplication`
### What changes were proposed in this pull request?
This PR aims to publish the existing `KueueEvictionIgnored` warning event
when Kueue evicts or deactivates the `Workload` of a `SparkApplication` whose
driver is requested, like a running `SparkCluster` does for a
`PodsReadyTimeout`.
- A new `AppKueueEvictionStep` reports it from `DriverRequested` to
`RunningWithBelowThresholdExecutors`. It does not stop or requeue the
application.
- The message tells whether the `Workload` still holds its quota (evicted)
or not (deactivated).
- Like `KueueAdmissionPending`, the event is republished while it lasts,
subject to `minIntervalSeconds`.
### Why are the changes needed?
Currently, an eviction or a deactivation after the driver is requested is
neither handled nor reported, while the driver and executors keep running.
Kueue's own events are on the `Workload`, so `kubectl describe
sparkapplication` shows nothing, although a preempting workload may keep
waiting for 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 #906 from dongjoon-hyun/SPARK-59866.
Authored-by: Dongjoon Hyun <[email protected]>
Signed-off-by: Dongjoon Hyun <[email protected]>
---
docs/configuration.md | 2 +-
docs/spark_custom_resources.md | 14 +-
.../operator/reconciler/SparkAppReconciler.java | 3 +
.../reconcilesteps/AppKueueEvictionStep.java | 84 ++++++++++++
.../reconcilesteps/AppReconcileStep.java | 4 +-
.../spark/k8s/operator/utils/EventUtils.java | 4 +-
.../reconciler/SparkAppReconcilerTest.java | 22 +++
.../reconcilesteps/AppKueueEvictionStepTest.java | 149 +++++++++++++++++++++
tests/e2e/kueue/chainsaw-test.yaml | 96 +++++++++++++
.../kueue/spark-example-executors-not-ready.yaml | 37 +++++
10 files changed, 405 insertions(+), 10 deletions(-)
diff --git a/docs/configuration.md b/docs/configuration.md
index 31731afb..40795590 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -96,7 +96,7 @@ In addition, the operator publishes the following `Warning`
events.
| `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`. Transport-level errors and conflicts are
skipped. A `SparkCluster` retries them every 5 seconds and a persistent failure
with the default interval, while a `SparkApplication` retries any failure with
its next reconciliation, so [...]
| `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 the `Workload` of a running
`SparkCluster` by a `PodsReadyTimeout`, which the operator does not act on, so
the master and workers keep running and holding its quota. It is republished on
every reconciliation while the eviction lasts, subject to
[`minIntervalSeconds`](#event-frequency). See
[Kueue](spark_custom_resources.md#kueue). |
+| `KueueEvictionIgnored` | Kueue evicted or deactivated the `Workload` of a
`SparkApplication` whose driver is requested and whose attempt has not stopped,
or evicted the `Workload` of a running `SparkCluster` by a `PodsReadyTimeout`.
The operator does not act on it, so the driver and executors (or master and
workers) 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, [...]
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 7a34661e..fe296f4b 100644
--- a/docs/spark_custom_resources.md
+++ b/docs/spark_custom_resources.md
@@ -770,11 +770,15 @@ spec:
workers are ready, so that `blockAdmission` admits the next workload.
* 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`. The operator checks the admission only before it
creates the
- resources, so after a later eviction the driver and executors keep running.
An evicted
- `Workload` keeps its Kueue quota meanwhile, while a deactivated one no
longer counts against it.
- 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.
+ `spark.executor.instances`, or a deactivation. The operator checks the
admission only before it
+ creates the resources, so after a later eviction the driver and executors
keep running, and
+ until the attempt stops, the `KueueEvictionIgnored`
[event](configuration.md#kubernetes-events)
+ is published instead when enabled. An evicted `Workload` keeps its Kueue
quota until the driver
+ and executors are released, so a workload which preempts the application
waits until then,
+ while a deactivated one no longer counts against it. Since `spec.suspend`
does not stop a
+ running attempt, delete the application to release its quota earlier. With
`blockAdmission`, an
+ application whose pods are not all ready holds back every other workload
until they are ready
+ or its quota is released as described above.
## Spark Cluster
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 4a9ef3bf..8cf56fcd 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
@@ -56,6 +56,7 @@ 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,6 +208,7 @@ 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())));
@@ -218,6 +220,7 @@ 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
new file mode 100644
index 00000000..cbeb760e
--- /dev/null
+++
b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStep.java
@@ -0,0 +1,84 @@
+/*
+ * 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. 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 531800d6..cb1d9db2 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, AppResourceObserveStep,
AppRunningStep,
- AppUnknownStateStep, AppValidateStep {
+ permits AppCleanUpStep, AppInitStep, AppKueueEvictionStep,
AppResourceObserveStep,
+ AppRunningStep, AppUnknownStateStep, AppValidateStep {
/**
* Reconciles a specific step for a Spark application.
*
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 1a5e3288..6dc72a8f 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,8 +98,8 @@ public final class EventUtils {
public static final String REASON_KUEUE_DISABLED = "KueueDisabled";
/**
- * Reason for an event describing that Kueue evicted the Workload of a
running resource in a way
- * which the operator does not act on, so the resource keeps running.
+ * 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";
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 10a13177..d86f7b1e 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
@@ -44,6 +44,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.ArrayList;
+import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -85,6 +86,7 @@ import org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils;
import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadStatus;
import org.apache.spark.k8s.operator.metrics.healthcheck.SentinelManager;
+import
org.apache.spark.k8s.operator.reconciler.reconcilesteps.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.spec.ApplicationSpec;
@@ -584,4 +586,24 @@ class SparkAppReconcilerTest {
summary.name());
}
}
+
+ @Test
+ void kueueEvictionIsReportedFromTheDriverRequestUntilTheAttemptStops() {
+ Set<ApplicationStateSummary> driverRequested =
+ EnumSet.range(
+ ApplicationStateSummary.DriverRequested,
+ ApplicationStateSummary.RunningWithBelowThresholdExecutors);
+ for (ApplicationStateSummary summary : ApplicationStateSummary.values()) {
+ app.setStatus(new ApplicationStatus().appendNewState(new
ApplicationState(summary, "")));
+ List<AppReconcileStep> steps = reconciler.getReconcileSteps(app);
+ boolean expected = driverRequested.contains(summary);
+ // 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
new file mode 100644
index 00000000..fac5276b
--- /dev/null
+++
b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppKueueEvictionStepTest.java
@@ -0,0 +1,149 @@
+/*
+ * 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);
+ 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/tests/e2e/kueue/chainsaw-test.yaml
b/tests/e2e/kueue/chainsaw-test.yaml
index 76caae8b..b18e2b1d 100644
--- a/tests/e2e/kueue/chainsaw-test.yaml
+++ b/tests/e2e/kueue/chainsaw-test.yaml
@@ -954,3 +954,99 @@ spec:
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
+ - name: spark-application-whose-executors-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.
+ bindings:
+ - name: FLAVOR
+ value: spark-e2e-app-pods-ready-flavor
+ - name: CLUSTER_QUEUE
+ value: spark-e2e-app-pods-ready-cluster-queue
+ - name: LOCAL_QUEUE
+ value: spark-e2e-app-pods-ready-queue
+ try:
+ # The eviction below needs the waitForPodsReady timeout which the CI
shortens to 2 minutes,
+ # rather than the default of 30 minutes.
+ - script:
+ timeout: 30s
+ content: |
+ kubectl get configmap kueue-manager-config -n kueue-system -o json \
+ | jq -r '.data["controller_manager_config.yaml"] | if
contains("\nwaitForPodsReady:\n timeout: 2m\n") then "SHORTENED" else
"DEFAULT" end'
+ check:
+ (contains($stdout, 'SHORTENED')): true
+ - apply:
+ file: kueue-queues.yaml
+ # The quota fits the application (1 CPU for the driver and 1 for the
executor)
+ - 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"}]'
+ - apply:
+ file: spark-example-executors-not-ready.yaml
+ - assert:
+ timeout: 5m
+ resource:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: Workload
+ metadata:
+ name: sparkapplication-spark-job-kueue-executors-not-ready-test
+ namespace: default
+ status:
+ (conditions[?type == 'Evicted' && status == 'True' && reason ==
'PodsReadyTimeout'] | length(@)): 1
+ # A running application is not released on it yet
+ - assert:
+ timeout: 60s
+ resource:
+ apiVersion: v1
+ kind: Event
+ metadata:
+ namespace: default
+ involvedObject:
+ kind: SparkApplication
+ name: spark-job-kueue-executors-not-ready-test
+ type: Warning
+ reason: KueueEvictionIgnored
+ (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
+ catch:
+ - describe:
+ apiVersion: spark.apache.org/v1
+ kind: SparkApplication
+ namespace: default
+ - describe:
+ apiVersion: kueue.x-k8s.io/v1beta2
+ kind: Workload
+ namespace: default
+ - events:
+ namespace: default
+ finally:
+ - script:
+ timeout: 120s
+ env:
+ - name: FLAVOR
+ value: ($FLAVOR)
+ - name: CLUSTER_QUEUE
+ value: ($CLUSTER_QUEUE)
+ - name: LOCAL_QUEUE
+ value: ($LOCAL_QUEUE)
+ content: |
+ kubectl delete sparkapplication
spark-job-kueue-executors-not-ready-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
diff --git a/tests/e2e/kueue/spark-example-executors-not-ready.yaml
b/tests/e2e/kueue/spark-example-executors-not-ready.yaml
new file mode 100644
index 00000000..2aa215b1
--- /dev/null
+++ b/tests/e2e/kueue/spark-example-executors-not-ready.yaml
@@ -0,0 +1,37 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+apiVersion: spark.apache.org/v1
+kind: SparkApplication
+metadata:
+ name: spark-job-kueue-executors-not-ready-test
+ namespace: default
+ labels:
+ kueue.x-k8s.io/queue-name: ($LOCAL_QUEUE)
+spec:
+ mainClass: "org.apache.spark.examples.SparkPi"
+ jars: "local:///opt/spark/examples/jars/spark-examples.jar"
+ sparkConf:
+ spark.executor.instances: "1"
+ # No node has this label, so the executor is never scheduled and the job
waits for it, while
+ # Kueue still admits the Workload since the label is not one of the node
labels of its
+ # ResourceFlavor. Without executor thresholds, the application is
RunningHealthy meanwhile.
+
spark.kubernetes.executor.node.selector.spark.apache.org/kueue-e2e-unschedulable:
"true"
+ spark.kubernetes.container.image: "apache/spark:{{SPARK_VERSION}}-scala"
+ spark.kubernetes.authenticate.driver.serviceAccountName: "spark"
+ runtimeVersions:
+ sparkVersion: "4.2.0"
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]