What happened:
Two independent bugs in the v0.18 SparkApplication integration framework (pkg/controller/jobs/sparkapplication/) which together break Kueue's requeue cycle for Spark workloads.
Bug 1 — PodsReady returns true while executors are still pending
Current implementation:
// pkg/controller/jobs/sparkapplication/sparkapplication_controller.go
func (j *SparkApplication) PodsReady(ctx context.Context, _ client.Client) bool {
return j.Status.AppState.State == sparkv1beta2.ApplicationStateRunning
}
AppState.State == Running transitions to Running as soon as the driver is up, regardless of executor state. When executors are unschedulable (resource shortage, missing node label, etc.), PodsReady is permanently true, so waitForPodsReady.timeout never fires. The Workload sits forever with the driver running and 0 executors ready.
Bug 2 — Stop only sets spec.suspend=true, leaving orphan executor pods across requeue cycles
The framework relies on the default stopJob flow which only patches spec.suspend=true on the SparkApplication and assumes Spark Operator cleans up. In practice Spark Operator's suspend handling only kills the driver; executor pods are owned by the SparkApplication CR via a non-controller OwnerReference and do not get GC'd when the driver dies.
Result — observed across two requeue cycles:
$ kubectl get pods -n default
NAME READY STATUS RESTARTS AGE
pythonpi-7f25689e671b40fd-exec-1 0/1 Pending 0 4m58s <- cycle 1
pythonpi-7f25689e671b40fd-exec-2 0/1 Pending 0 4m58s <- cycle 1
pythonpi-db71229e671def82-exec-1 0/1 Pending 0 2m3s <- cycle 2
pythonpi-db71229e671def82-exec-2 0/1 Pending 0 2m3s <- cycle 2
Each evict -> re-admit cycle leaks N executor pods. With waitForPodsReady.requeuingStrategy.backoffLimitCount: 5 and executor.instances: 2, the cluster accumulates 10 zombie executor pods before the Workload is finally deactivated.
What you expected to happen:
PodsReady returns true only when the driver and all requested executors are running (or completed), so waitForPodsReady.timeout actually fires when executors cannot be scheduled.
- After eviction,
Stop tears down driver and all executor pods so the next requeue cycle starts clean. After 5 cycles, 0 pods remain (not 10).
How to reproduce it (as minimally and precisely as possible):
- Cluster with Kueue v0.18 + Spark Operator >= v2.4.0, feature gate
SparkApplicationIntegration=true.
- ClusterQueue + LocalQueue using
default-flavor, with quota sufficient for the driver. Bug 2 manifests on any eviction; Bug 1 needs the executor to be unschedulable so the Workload waits for the timeout that never fires.
- Configure Kueue with
waitForPodsReady.timeout: 2m and requeuingStrategy.backoffLimitCount: 5.
- Apply the following SparkApplication. The fake
nodeSelector deliberately makes executors unschedulable so Bug 1 is reachable:
apiVersion: sparkoperator.k8s.io/v1beta2
kind: SparkApplication
metadata:
name: spark-bug-repro
namespace: default
labels:
kueue.x-k8s.io/queue-name: spark-test
spec:
type: Python
mode: cluster
image: "docker.io/apache/spark:3.5.3-python3"
imagePullPolicy: IfNotPresent
mainApplicationFile: "local:///opt/spark/examples/src/main/python/pi.py"
sparkVersion: "3.5.3"
driver:
cores: 1
memory: "512m"
executor:
cores: 1
instances: 2
memory: "512m"
nodeSelector:
non-existent-node-label: "true" # forces executors to stay Pending
restartPolicy:
type: Never
Observed (Bug 1): The Workload never gets a PodsReadyTimeout eviction. Driver stays Running, executors stay Pending, indefinitely. With Bug 1 patched out (PodsReady also checks executor state), Evicted=True / reason=PodsReadyTimeout fires at the configured 2-minute mark.
Observed (Bug 2) — using a local patch to make Bug 1 trigger evictions, or by manually requeuing the workload: each cycle accumulates 2 zombie executor pods. After 5 cycles -> 10 zombie pods; cycle 1's executors are still Pending when cycle 5 starts.
Anything else we need to know?:
Why these two bugs travel together
Bug 1 prevents the requeue cycle from ever firing (timeout never triggers), so Bug 2 normally does not manifest in production. They were both discovered while testing the requeue cycle end-to-end with a local workaround for Bug 1 in place.
Stop ordering matters — two "obvious" implementations are incorrect
| Attempt |
Why it fails |
spec.suspend=true then immediately Delete executors |
Driver's Spark scheduler can issue create executor API calls right up until its container terminates -> a trailing executor (owner=SparkApplication, no driver pod ref) is created after our Delete and lingers as an orphan. |
Delete driver first (force) then suspend then Delete executors |
Force-deleting the driver makes Spark Operator transition AppState to Failed, which the framework's Finished() interprets as Workload.Finished=True — the requeue cycle is prematurely terminated. |
Correct ordering: (1) spec.suspend=true (Spark Operator gracefully tears down the driver, AppState stays transitionable to Running on resume); (2) bounded poll (<= 5s) until driver pod is gone; (3) delete all executor pods. This needs to live in a framework custom Stop since JobWithCustomStop bypasses the default Suspend() + RestorePodSetsInfo() flow in pkg/controller/jobframework/reconciler.go.
Environment:
- Kubernetes version: 1.32.0
- Kueue version: v0.18
- Spark Operator version: 2.5.0
What happened:
Two independent bugs in the v0.18 SparkApplication integration framework (
pkg/controller/jobs/sparkapplication/) which together break Kueue's requeue cycle for Spark workloads.Bug 1 —
PodsReadyreturns true while executors are still pendingCurrent implementation:
AppState.State == Runningtransitions toRunningas soon as the driver is up, regardless of executor state. When executors are unschedulable (resource shortage, missing node label, etc.),PodsReadyis permanentlytrue, sowaitForPodsReady.timeoutnever fires. The Workload sits forever with the driver running and 0 executors ready.Bug 2 —
Stoponly setsspec.suspend=true, leaving orphan executor pods across requeue cyclesThe framework relies on the default
stopJobflow which only patchesspec.suspend=trueon the SparkApplication and assumes Spark Operator cleans up. In practice Spark Operator's suspend handling only kills the driver; executor pods are owned by the SparkApplication CR via a non-controllerOwnerReferenceand do not get GC'd when the driver dies.Result — observed across two requeue cycles:
Each evict -> re-admit cycle leaks N executor pods. With
waitForPodsReady.requeuingStrategy.backoffLimitCount: 5andexecutor.instances: 2, the cluster accumulates 10 zombie executor pods before the Workload is finally deactivated.What you expected to happen:
PodsReadyreturnstrueonly when the driver and all requested executors are running (or completed), sowaitForPodsReady.timeoutactually fires when executors cannot be scheduled.Stoptears down driver and all executor pods so the next requeue cycle starts clean. After 5 cycles, 0 pods remain (not 10).How to reproduce it (as minimally and precisely as possible):
SparkApplicationIntegration=true.default-flavor, with quota sufficient for the driver. Bug 2 manifests on any eviction; Bug 1 needs the executor to be unschedulable so the Workload waits for the timeout that never fires.waitForPodsReady.timeout: 2mandrequeuingStrategy.backoffLimitCount: 5.nodeSelectordeliberately makes executors unschedulable so Bug 1 is reachable:Observed (Bug 1): The Workload never gets a
PodsReadyTimeouteviction. Driver staysRunning, executors stayPending, indefinitely. With Bug 1 patched out (PodsReadyalso checks executor state),Evicted=True / reason=PodsReadyTimeoutfires at the configured 2-minute mark.Observed (Bug 2) — using a local patch to make Bug 1 trigger evictions, or by manually requeuing the workload: each cycle accumulates 2 zombie executor pods. After 5 cycles -> 10 zombie pods; cycle 1's executors are still
Pendingwhen cycle 5 starts.Anything else we need to know?:
Why these two bugs travel together
Bug 1 prevents the requeue cycle from ever firing (timeout never triggers), so Bug 2 normally does not manifest in production. They were both discovered while testing the requeue cycle end-to-end with a local workaround for Bug 1 in place.
Stopordering matters — two "obvious" implementations are incorrectspec.suspend=truethen immediatelyDeleteexecutorscreate executorAPI calls right up until its container terminates -> a trailing executor (owner=SparkApplication, no driver pod ref) is created after our Delete and lingers as an orphan.Deletedriver first (force) thensuspendthenDeleteexecutorsAppStatetoFailed, which the framework'sFinished()interprets asWorkload.Finished=True— the requeue cycle is prematurely terminated.Correct ordering: (1)
spec.suspend=true(Spark Operator gracefully tears down the driver,AppStatestays transitionable toRunningon resume); (2) bounded poll (<= 5s) until driver pod is gone; (3) delete all executor pods. This needs to live in a framework customStopsinceJobWithCustomStopbypasses the defaultSuspend()+RestorePodSetsInfo()flow inpkg/controller/jobframework/reconciler.go.Environment: