You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@spark.apache.org by do...@apache.org on 2026/09/17 10:15:45 UTC
(spark-kubernetes-operator) branch main updated: [SPARK-59594] Release Kueue `Workload` when `AppCleanUpStep` releases `SparkApplication` resources
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 6920cc52 [SPARK-59594] Release Kueue `Workload` when `AppCleanUpStep` releases `SparkApplication` resources
6920cc52 is described below
commit 6920cc528ecd30dfbe60f8b76b4757ed5bfba611
Author: Dongjoon Hyun <do...@apache.org>
AuthorDate: Thu Sep 17 03:15:37 2026 -0700
[SPARK-59594] Release Kueue `Workload` when `AppCleanUpStep` releases `SparkApplication` resources
### What changes were proposed in this pull request?
This PR aims to delete the Kueue `Workload` when `AppCleanUpStep` releases the resources of a `SparkApplication` with the `kueue.x-k8s.io/queue-name` label.
The `Workload` is deleted before the attempt's resources, so it is released on every path that reaches the resource release:
- On-demand clean-up
- Stopping apps, including before a restart
- Terminated apps whose retention period has expired
- `SchedulingFailure` attempts whose spec cannot be built again
Apps that keep their resources via `resourceRetainPolicy` keep their quota until the resources are released.
### Why are the changes needed?
This is part of the Kueue runtime integration. Once labeled apps wait for admission, a `Workload` left `Admitted` after the app finishes holds the `ClusterQueue` quota indefinitely by default (`ttlAfterStopMillis=-1`). This blocks other jobs in the same `LocalQueue`.
- Deleting the `Workload` returns the quota and needs no `workloads/status` permission. A restarted attempt is queued again with a new `Workload`, so the quota is not held during the restart backoff.
- Failures are only logged as warnings, and a missing `Workload` or Kueue CRD is a no-op. So clean-up is never blocked, and `ownerReference` GC still removes the `Workload` eventually.
- This should land before the admission PR so that `main` never leaks quota.
### Does this PR introduce _any_ user-facing change?
No, the operator does not create Kueue Workloads yet.
### How was this patch tested?
Pass the CIs with the newly added test cases and the updated existing test cases in `AppCleanUpStepTest`. The new test cases cover apps with and without the queue label, the `SchedulingFailure` spec-build failure path, and the `resourceRetainPolicy` path.
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Opus 5
Closes #837 from dongjoon-hyun/SPARK-59594.
Authored-by: Dongjoon Hyun <do...@apache.org>
Signed-off-by: Dongjoon Hyun <do...@apache.org>
---
.../reconciler/reconcilesteps/AppCleanUpStep.java | 6 ++
.../reconcilesteps/AppCleanUpStepTest.java | 112 +++++++++++++++++++++
2 files changed, 118 insertions(+)
diff --git a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppCleanUpStep.java b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppCleanUpStep.java
index e0dc065a..5d536ac0 100644
--- a/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppCleanUpStep.java
+++ b/spark-operator/src/main/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppCleanUpStep.java
@@ -37,6 +37,8 @@ import lombok.extern.slf4j.Slf4j;
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.KueueWorkloadUtils;
import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
import org.apache.spark.k8s.operator.spec.ApplicationTolerations;
import org.apache.spark.k8s.operator.spec.ResourceRetainPolicy;
@@ -133,6 +135,10 @@ public final class AppCleanUpStep extends AppReconcileStep {
return ReconcileProgress.proceed();
}
+ if (KueueWorkloadFactory.hasQueueName(application)) {
+ // Release the quota. A restarted attempt is queued again with a new Workload.
+ KueueWorkloadUtils.releaseWorkload(context.getClient(), application);
+ }
List<HasMetadata> resourcesToRemove = new ArrayList<>();
if (isReleasingResourcesForSchedulingFailureAttempt(currentStatus)) {
// if app failed at scheduling, re-compute all spec and delete as they may not be fully
diff --git a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppCleanUpStepTest.java b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppCleanUpStepTest.java
index 86f30867..a911428f 100644
--- a/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppCleanUpStepTest.java
+++ b/spark-operator/src/test/java/org/apache/spark/k8s/operator/reconciler/reconcilesteps/AppCleanUpStepTest.java
@@ -32,9 +32,11 @@ import static org.mockito.Mockito.when;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
+import java.util.Map;
import java.util.Optional;
import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.client.KubernetesClient;
import org.junit.jupiter.api.Assertions;
@@ -46,6 +48,7 @@ import org.mockito.Mockito;
import org.apache.spark.k8s.operator.Constants;
import org.apache.spark.k8s.operator.SparkApplication;
import org.apache.spark.k8s.operator.context.SparkAppContext;
+import org.apache.spark.k8s.operator.kueue.KueueWorkloadUtils;
import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
import org.apache.spark.k8s.operator.spec.ApplicationSpec;
import org.apache.spark.k8s.operator.spec.ApplicationTolerations;
@@ -115,6 +118,108 @@ class AppCleanUpStepTest {
notExceedRetainDuration,
notExceedTtl);
+ @Test
+ void cleanupReleasesKueueWorkload() {
+ SparkAppStatusRecorder mockRecorder = mock(SparkAppStatusRecorder.class);
+ AppCleanUpStep cleanUpWithReason = new AppCleanUpStep(SparkAppStatusUtils::appCancelled);
+ SparkApplication app = new SparkApplication();
+ app.setMetadata(
+ new ObjectMetaBuilder()
+ .withName("app1")
+ .withNamespace("default")
+ .withLabels(Map.of(Constants.LABEL_QUEUE_NAME, "test-queue"))
+ .build());
+ app.setStatus(prepareApplicationStatus(ApplicationStateSummary.RunningHealthy));
+ SparkAppContext mockAppContext = mock(SparkAppContext.class);
+ when(mockAppContext.getResource()).thenReturn(app);
+ KubernetesClient mockClient = mock(KubernetesClient.class);
+ when(mockAppContext.getClient()).thenReturn(mockClient);
+ when(mockAppContext.getDriverPod()).thenReturn(Optional.empty());
+ when(mockRecorder.appendNewStateAndPersist(eq(mockAppContext), any())).thenReturn(true);
+
+ try (MockedStatic<KueueWorkloadUtils> kueue = Mockito.mockStatic(KueueWorkloadUtils.class)) {
+ cleanUpWithReason.reconcile(mockAppContext, mockRecorder);
+ kueue.verify(() -> KueueWorkloadUtils.releaseWorkload(mockClient, app));
+ }
+ }
+
+ @Test
+ void cleanupWithoutQueueNameDoesNotReleaseKueueWorkload() {
+ SparkAppStatusRecorder mockRecorder = mock(SparkAppStatusRecorder.class);
+ AppCleanUpStep cleanUpWithReason = new AppCleanUpStep(SparkAppStatusUtils::appCancelled);
+ SparkApplication app = new SparkApplication();
+ app.setMetadata(new ObjectMetaBuilder().withName("app1").withNamespace("default").build());
+ app.setStatus(prepareApplicationStatus(ApplicationStateSummary.RunningHealthy));
+ SparkAppContext mockAppContext = mock(SparkAppContext.class);
+ when(mockAppContext.getResource()).thenReturn(app);
+ when(mockAppContext.getClient()).thenReturn(mock(KubernetesClient.class));
+ when(mockAppContext.getDriverPod()).thenReturn(Optional.empty());
+ when(mockRecorder.appendNewStateAndPersist(eq(mockAppContext), any())).thenReturn(true);
+
+ try (MockedStatic<KueueWorkloadUtils> kueue = Mockito.mockStatic(KueueWorkloadUtils.class)) {
+ cleanUpWithReason.reconcile(mockAppContext, mockRecorder);
+ kueue.verifyNoInteractions();
+ }
+ }
+
+ @Test
+ void cleanupReleasesKueueWorkloadWhenSpecBuildFails() {
+ SparkAppStatusRecorder mockRecorder = mock(SparkAppStatusRecorder.class);
+ AppCleanUpStep routineCheck = new AppCleanUpStep();
+ SparkApplication app = new SparkApplication();
+ app.setMetadata(
+ new ObjectMetaBuilder()
+ .withName("app1")
+ .withNamespace("default")
+ .withLabels(Map.of(Constants.LABEL_QUEUE_NAME, "test-queue"))
+ .build());
+ app.setStatus(prepareApplicationStatus(ApplicationStateSummary.SchedulingFailure));
+ SparkAppContext mockAppContext = mock(SparkAppContext.class);
+ when(mockAppContext.getResource()).thenReturn(app);
+ KubernetesClient mockClient = mock(KubernetesClient.class);
+ when(mockAppContext.getClient()).thenReturn(mockClient);
+ when(mockAppContext.getDriverPreResourcesSpec()).thenThrow(new IllegalStateException("foo"));
+ when(mockRecorder.appendNewStateAndPersist(eq(mockAppContext), any())).thenReturn(true);
+
+ try (MockedStatic<KueueWorkloadUtils> kueue = Mockito.mockStatic(KueueWorkloadUtils.class)) {
+ routineCheck.reconcile(mockAppContext, mockRecorder);
+ kueue.verify(() -> KueueWorkloadUtils.releaseWorkload(mockClient, app));
+ }
+ ArgumentCaptor<ApplicationState> captor = ArgumentCaptor.forClass(ApplicationState.class);
+ verify(mockRecorder).appendNewStateAndPersist(eq(mockAppContext), captor.capture());
+ Assertions.assertEquals(
+ ApplicationStateSummary.ResourceReleased, captor.getValue().getCurrentStateSummary());
+ }
+
+ @Test
+ void cleanupWithRetainPolicyKeepsKueueWorkload() {
+ SparkAppStatusRecorder mockRecorder = mock(SparkAppStatusRecorder.class);
+ AppCleanUpStep routineCheck = new AppCleanUpStep();
+ SparkApplication app = new SparkApplication();
+ app.setMetadata(
+ new ObjectMetaBuilder()
+ .withName("app1")
+ .withNamespace("default")
+ .withLabels(Map.of(Constants.LABEL_QUEUE_NAME, "test-queue"))
+ .build());
+ app.setSpec(alwaysRetain);
+ app.setStatus(prepareApplicationStatus(ApplicationStateSummary.Failed));
+ SparkAppContext mockAppContext = mock(SparkAppContext.class);
+ when(mockAppContext.getResource()).thenReturn(app);
+ when(mockAppContext.getClient()).thenReturn(mock(KubernetesClient.class));
+ when(mockRecorder.appendNewStateAndPersist(eq(mockAppContext), any())).thenReturn(true);
+
+ try (MockedStatic<KueueWorkloadUtils> kueue = Mockito.mockStatic(KueueWorkloadUtils.class)) {
+ routineCheck.reconcile(mockAppContext, mockRecorder);
+ kueue.verifyNoInteractions();
+ }
+ ArgumentCaptor<ApplicationState> captor = ArgumentCaptor.forClass(ApplicationState.class);
+ verify(mockRecorder).appendNewStateAndPersist(eq(mockAppContext), captor.capture());
+ Assertions.assertEquals(
+ ApplicationStateSummary.TerminatedWithoutReleaseResources,
+ captor.getValue().getCurrentStateSummary());
+ }
+
@Test
void enableForceDelete() {
AppCleanUpStep appCleanUpStep = new AppCleanUpStep();
@@ -192,6 +297,8 @@ class AppCleanUpStepTest {
Assertions.assertEquals(
ApplicationStateSummary.ResourceReleased, appState.getCurrentStateSummary());
Assertions.assertEquals(Constants.APP_CANCELLED_MESSAGE, appState.getMessage());
+ // The Kueue queue label is looked up before releasing the resources
+ verify(mockApp).getMetadata();
verifyNoMoreInteractions(mockAppContext, mockRecorder, mockApp, mockClient, driverPod);
}
}
@@ -282,6 +389,8 @@ class AppCleanUpStepTest {
Assertions.assertEquals(
ApplicationStateSummary.ResourceReleased, appState.getCurrentStateSummary());
Assertions.assertEquals(Constants.APP_CANCELLED_MESSAGE, appState.getMessage());
+ // The Kueue queue label is looked up before releasing the resources
+ verify(mockApp).getMetadata();
verifyNoMoreInteractions(mockAppContext, mockRecorder, mockApp, mockClient, driverPod);
}
@@ -360,6 +469,9 @@ class AppCleanUpStepTest {
Assertions.assertEquals(
ApplicationStateSummary.ResourceReleased, appState2.getCurrentStateSummary());
Assertions.assertEquals(Constants.APP_CANCELLED_MESSAGE, appState2.getMessage());
+ // The Kueue queue label is looked up before releasing the resources
+ verify(mockApp1).getMetadata();
+ verify(mockApp2).getMetadata();
verifyNoMoreInteractions(
mockAppContext1, mockAppContext2, mockRecorder, mockApp1, mockApp2, mockClient, driverPod);
}
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@spark.apache.org
For additional commands, e-mail: commits-help@spark.apache.org