Skip to content

Commit c1c2f60

Browse files
committed
[SPARK-59655] Fill priorityClassRef and priority of Kueue Workload
### What changes were proposed in this pull request? This PR aims to fill `spec.priorityClassRef` and `spec.priority` of Kueue `Workload`s. The priority is decided like Kueue's built-in integrations ([Kueue v0.19.4](https://github.com/kubernetes-sigs/kueue/releases/tag/v0.19.4) (2026-09-10)), in this order: 1. The `WorkloadPriorityClass` named by the `kueue.x-k8s.io/priority-class` label. 2. The `PriorityClass` of the first pod set that has one: `driver` or `master` first. 3. The `globalDefault` `PriorityClass`. If the named class does not exist, the admission is retried. If the operator cannot read the classes (`403 Forbidden`), it creates the `Workload` without priority. If the label changes while the `Workload` is pending, the `Workload` is updated in place. ### Why are the changes needed? Kueue orders the queue and picks preemption targets by `spec.priority`, and the integration has to fill it. Currently, all Spark `Workload`s have the same priority. ### Does this PR introduce _any_ user-facing change? No, `Kueue` feature is not released yet. ### How was this patch tested? Pass the CIs with the newly added unit tests and the new Chainsaw step in `tests/e2e/kueue`. ### Was this patch authored or co-authored using generative AI tooling? Generated-by: Claude Opus 5 Closes #851 from dongjoon-hyun/SPARK-59655. Authored-by: Dongjoon Hyun <dongjoon@apache.org> Signed-off-by: Dongjoon Hyun <dongjoon@apache.org>
1 parent eb533c4 commit c1c2f60

13 files changed

Lines changed: 804 additions & 4 deletions

File tree

‎docs/operations.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ under the License.
4747
`spark.kubernetes.operator.kueue.workloadInformer.enabled`, so that the operator watches
4848
`Workload` resources and an admission starts the queued resource right away. Without the watch,
4949
an admission is noticed on the next periodic reconcile. The watch requires Kueue to be installed
50-
before the operator starts; otherwise the operator reports itself unhealthy. See
50+
before the operator starts; otherwise the operator reports itself unhealthy. The priority of a
51+
`Workload` comes from the cluster-scoped `workloadpriorityclasses` and `priorityclasses`, which
52+
are granted only by `clusterRole.create`. Without the permission, the operator logs a warning and
53+
creates the `Workload` without priority. See
5154
[Kueue](spark_custom_resources.md#kueue) for how resources opt into queueing.
5255

5356
## Spark Application Namespaces

‎docs/spark_custom_resources.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,15 @@ spec:
586586
See [Optional Prerequisites](operations.md#optional-prerequisites).
587587
* The `Workload` is named `<lower-cased kind>-<resource name>` and is owned by the Spark resource,
588588
so it is garbage collected along with it.
589+
* Like Kueue built-in integrations, the `Workload` gets the priority of the `WorkloadPriorityClass`
590+
named by the `kueue.x-k8s.io/priority-class` label. Without the label, the `priorityClassName` of
591+
the driver (or master) pod template is used, then the one of the executor (or worker) pod
592+
template, and then the `globalDefault` `PriorityClass`. The resource keeps waiting without a
593+
`Workload` while the named priority class does not exist. Changing the label before the
594+
`Workload` reserves quota updates its priority in place. After that Kueue freezes the presence,
595+
the group and the kind of the priority class, so only the name of a `WorkloadPriorityClass` still
596+
changes. Like Kueue, a `Workload` which took its priority from a `PriorityClass` never follows
597+
the label, and a changed value of the same class does not affect the existing `Workload`.
589598
* While the `Workload` waits for quota, the resource stays in its initializing state (`Submitted`,
590599
or `ScheduledToRestart` for a restarted attempt) and no driver (or master / worker) is created.
591600
Like `spec.suspend`, the initial `Submitted` status of the first attempt is not persisted to the

‎spark-operator-api/src/main/java/org/apache/spark/k8s/operator/Constants.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ public class Constants {
4747
/** The label for the Kueue queue name. */
4848
public static final String LABEL_QUEUE_NAME = "kueue.x-k8s.io/queue-name";
4949

50+
/** The label for the Kueue WorkloadPriorityClass name. */
51+
public static final String LABEL_WORKLOAD_PRIORITY_CLASS = "kueue.x-k8s.io/priority-class";
52+
5053
/** The API group for Kueue resources. */
5154
public static final String KUEUE_API_GROUP = "kueue.x-k8s.io";
5255

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package org.apache.spark.k8s.operator.kueue;
21+
22+
import static java.net.HttpURLConnection.HTTP_FORBIDDEN;
23+
24+
import java.util.Comparator;
25+
import java.util.Map;
26+
27+
import io.fabric8.kubernetes.api.model.PodTemplateSpec;
28+
import io.fabric8.kubernetes.api.model.scheduling.v1.PriorityClass;
29+
import io.fabric8.kubernetes.client.KubernetesClient;
30+
import io.fabric8.kubernetes.client.KubernetesClientException;
31+
import lombok.extern.slf4j.Slf4j;
32+
33+
import org.apache.spark.k8s.operator.Constants;
34+
import org.apache.spark.k8s.operator.kueue.v1beta2.PodSet;
35+
import org.apache.spark.k8s.operator.kueue.v1beta2.PriorityClassRef;
36+
import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
37+
import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadPriorityClass;
38+
import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadSpec;
39+
import org.apache.spark.k8s.operator.utils.StringUtils;
40+
41+
/** Utilities to resolve the priority of a Kueue Workload. */
42+
@Slf4j
43+
public final class KueueWorkloadPriority {
44+
45+
/** Like Kueue, the priority of a Workload without any priority class. */
46+
private static final int DEFAULT_PRIORITY = 0;
47+
48+
/** The API group of the Kubernetes PriorityClass, which a pod template refers to. */
49+
private static final String SCHEDULING_API_GROUP = "scheduling.k8s.io";
50+
51+
private KueueWorkloadPriority() {}
52+
53+
/**
54+
* Checks whether the priority class of the given Workload is followed and whether Kueue accepts
55+
* the change. Like Kueue's `classifyWorkloadsForPriorityUpdate`, only a Workload without a
56+
* priority class or with a WorkloadPriorityClass follows the owner, so the priority of a Workload
57+
* backed by a Kubernetes PriorityClass is never rewritten, e.g. when the global default changes.
58+
* Once the quota is reserved, the Workload CEL rules freeze the presence, the group and the kind
59+
* of the priorityClassRef. The name of a WorkloadPriorityClass stays mutable, which is what Kueue
60+
* relies on to raise the priority of a Workload waiting for its admission checks.
61+
*/
62+
static boolean isPriorityClassChangeAllowed(
63+
final Workload workload, final PriorityClassRef desired) {
64+
PriorityClassRef current = workload.getSpec().getPriorityClassRef();
65+
if (current != null && !Constants.KUEUE_API_GROUP.equals(current.getGroup())) {
66+
return false;
67+
}
68+
if (workload.getStatus() == null || !workload.getStatus().isQuotaReserved()) {
69+
return true;
70+
}
71+
return current != null
72+
&& desired != null
73+
&& Constants.KUEUE_API_GROUP.equals(desired.getGroup());
74+
}
75+
76+
/**
77+
* Sets the priority of the desired Workload in the same way as Kueue built-in integrations. The
78+
* WorkloadPriorityClass of the `kueue.x-k8s.io/priority-class` label takes precedence over the
79+
* PriorityClass of the first pod set which has one, and the global default PriorityClass is used
80+
* without both. Without any of them, the priority is 0 without a priority class. The priority is
81+
* left unset if the operator is not allowed to read the cluster-scoped priority classes.
82+
*
83+
* @param client The KubernetesClient.
84+
* @param workload The Workload whose priority is set in place.
85+
* @throws IllegalStateException if the priority class does not exist.
86+
*/
87+
static void setPriority(final KubernetesClient client, final Workload workload) {
88+
WorkloadSpec spec = workload.getSpec();
89+
try {
90+
Map<String, String> labels = workload.getMetadata().getLabels();
91+
String workloadPriorityClassName =
92+
labels == null ? null : labels.get(Constants.LABEL_WORKLOAD_PRIORITY_CLASS);
93+
if (StringUtils.isNotEmpty(workloadPriorityClassName)) {
94+
WorkloadPriorityClass workloadPriorityClass =
95+
client.resources(WorkloadPriorityClass.class).withName(workloadPriorityClassName).get();
96+
if (workloadPriorityClass == null) {
97+
throw new IllegalStateException(
98+
"Kueue WorkloadPriorityClass " + workloadPriorityClassName + " is not found.");
99+
}
100+
spec.setPriorityClassRef(
101+
new PriorityClassRef(
102+
Constants.KUEUE_API_GROUP, "WorkloadPriorityClass", workloadPriorityClassName));
103+
spec.setPriority(workloadPriorityClass.getValue());
104+
return;
105+
}
106+
PriorityClass priorityClass = getPodPriorityClass(client, spec);
107+
if (priorityClass == null) {
108+
spec.setPriorityClassRef(null);
109+
spec.setPriority(DEFAULT_PRIORITY);
110+
} else {
111+
spec.setPriorityClassRef(
112+
new PriorityClassRef(
113+
SCHEDULING_API_GROUP, "PriorityClass", priorityClass.getMetadata().getName()));
114+
spec.setPriority(priorityClass.getValue());
115+
}
116+
} catch (KubernetesClientException e) {
117+
if (e.getCode() != HTTP_FORBIDDEN) {
118+
throw e;
119+
}
120+
log.warn(
121+
"Requesting the Kueue Workload {} without priority because the operator is not allowed "
122+
+ "to read the priority classes.",
123+
workload.getMetadata().getName());
124+
}
125+
}
126+
127+
/**
128+
* Returns the PriorityClass of the first pod set which has one, or the global default
129+
* PriorityClass. Like Kueue, the lowest one wins if there are more than one global default.
130+
*/
131+
private static PriorityClass getPodPriorityClass(
132+
final KubernetesClient client, final WorkloadSpec spec) {
133+
for (PodSet podSet : spec.getPodSets()) {
134+
PodTemplateSpec template = podSet.getTemplate();
135+
String name =
136+
template == null || template.getSpec() == null
137+
? null
138+
: template.getSpec().getPriorityClassName();
139+
if (StringUtils.isNotEmpty(name)) {
140+
PriorityClass priorityClass =
141+
client.scheduling().v1().priorityClasses().withName(name).get();
142+
if (priorityClass == null) {
143+
throw new IllegalStateException("PriorityClass " + name + " is not found.");
144+
}
145+
return priorityClass;
146+
}
147+
}
148+
return client.scheduling().v1().priorityClasses().list().getItems().stream()
149+
.filter(priorityClass -> Boolean.TRUE.equals(priorityClass.getGlobalDefault()))
150+
.min(Comparator.comparing(PriorityClass::getValue))
151+
.orElse(null);
152+
}
153+
}

‎spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/KueueWorkloadUtils.java‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,10 @@
4848
import org.apache.spark.k8s.operator.context.BaseContext;
4949
import org.apache.spark.k8s.operator.kueue.v1beta2.PodSet;
5050
import org.apache.spark.k8s.operator.kueue.v1beta2.PodSetAssignment;
51+
import org.apache.spark.k8s.operator.kueue.v1beta2.PriorityClassRef;
5152
import org.apache.spark.k8s.operator.kueue.v1beta2.ResourceFlavor;
5253
import org.apache.spark.k8s.operator.kueue.v1beta2.Workload;
54+
import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadSpec;
5355
import org.apache.spark.k8s.operator.kueue.v1beta2.WorkloadStatus;
5456
import org.apache.spark.k8s.operator.reconciler.ReconcileProgress;
5557
import org.apache.spark.k8s.operator.utils.EventUtils;
@@ -90,16 +92,18 @@ public enum AdmissionResult {
9092
* Creates the given Workload if it does not exist yet and reports whether Kueue admitted it.
9193
*
9294
* @param client The KubernetesClient.
93-
* @param desired The Workload built for the resource to be admitted. The pod sets hash annotation
94-
* is added to it in place.
95+
* @param desired The Workload built for the resource to be admitted. The priority and the pod
96+
* sets hash annotation are added to it in place.
9597
* @return The AdmissionResult for the Workload.
96-
* @throws IllegalStateException if the Workload can neither be read nor created.
98+
* @throws IllegalStateException if the Workload can neither be read nor created, or if its
99+
* priority class does not exist.
97100
* @throws KubernetesClientException if the Workload cannot be created, or a stale Workload cannot
98101
* be deleted. Unlike {@link #releaseWorkload}, this is not swallowed so that the resource is
99102
* not created until the stale Workload is gone.
100103
*/
101104
public static AdmissionResult requestAdmission(
102105
final KubernetesClient client, final Workload desired) {
106+
KueueWorkloadPriority.setPriority(client, desired);
103107
String podSetsHash = hashPodSets(desired);
104108
Map<String, String> desiredAnnotations = new HashMap<>();
105109
if (desired.getMetadata().getAnnotations() != null) {
@@ -138,6 +142,21 @@ public static AdmissionResult requestAdmission(
138142
deleteWorkload(client, workload);
139143
return AdmissionResult.STALE;
140144
}
145+
WorkloadSpec spec = workload.getSpec();
146+
PriorityClassRef desiredPriorityClassRef = desired.getSpec().getPriorityClassRef();
147+
if (desired.getSpec().getPriority() != null
148+
&& !Objects.equals(spec.getPriorityClassRef(), desiredPriorityClassRef)
149+
&& KueueWorkloadPriority.isPriorityClassChangeAllowed(
150+
workload, desiredPriorityClassRef)) {
151+
// Like Kueue, a changed priority class is applied in place so that the Workload keeps its
152+
// position in the queue.
153+
log.info(
154+
"Updating the priority class of the pending Kueue Workload {}.",
155+
workload.getMetadata().getName());
156+
spec.setPriorityClassRef(desiredPriorityClassRef);
157+
spec.setPriority(desired.getSpec().getPriority());
158+
client.resource(workload).update();
159+
}
141160
return AdmissionResult.PENDING;
142161
}
143162

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.spark.k8s.operator.kueue.v1beta2;
20+
21+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
22+
import com.fasterxml.jackson.annotation.JsonInclude;
23+
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
24+
import io.fabric8.kubernetes.client.CustomResource;
25+
import io.fabric8.kubernetes.model.annotation.Group;
26+
import io.fabric8.kubernetes.model.annotation.Version;
27+
import lombok.EqualsAndHashCode;
28+
import lombok.Getter;
29+
import lombok.Setter;
30+
import lombok.ToString;
31+
32+
import org.apache.spark.k8s.operator.Constants;
33+
34+
/**
35+
* Fabric8 CustomResource representation of a cluster-scoped Kueue WorkloadPriorityClass
36+
* (kueue.x-k8s.io/v1beta2), which has neither spec nor status.
37+
*/
38+
@JsonInclude(JsonInclude.Include.NON_NULL)
39+
@JsonDeserialize
40+
@Group(Constants.KUEUE_API_GROUP)
41+
@Version(Constants.KUEUE_API_VERSION)
42+
@JsonIgnoreProperties(ignoreUnknown = true)
43+
@EqualsAndHashCode(callSuper = true)
44+
@ToString(callSuper = true)
45+
@Getter
46+
@Setter
47+
public class WorkloadPriorityClass extends CustomResource<Void, Void> {
48+
private Integer value;
49+
}

‎spark-operator/src/main/java/org/apache/spark/k8s/operator/kueue/v1beta2/WorkloadStatus.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ public boolean isAdmitted() {
5858
&& "True".equalsIgnoreCase(c.getStatus()));
5959
}
6060

61+
/**
62+
* Checks if Kueue reserved the quota for the workload, which precedes the admission.
63+
*
64+
* @return true if a "QuotaReserved" condition exists with status "True", false otherwise.
65+
*/
66+
public boolean isQuotaReserved() {
67+
return conditions != null
68+
&& conditions.stream()
69+
.anyMatch(
70+
c ->
71+
"QuotaReserved".equalsIgnoreCase(c.getType())
72+
&& "True".equalsIgnoreCase(c.getStatus()));
73+
}
74+
6175
/**
6276
* Checks if the workload has finished.
6377
*

0 commit comments

Comments
 (0)