Skip to content

Commit 361b1e6

Browse files
jerryshaoclaude
andauthored
[#13613] feat(k8s-job-executor): Add the k8s job executor to run Spark jobs on Kubernetes (part 3/3) (#13700)
### What changes were proposed in this pull request? This is part 3 of 3 of #13613. Part 1 (#13724) and part 2 (#13725) are merged. It adds `K8sJobExecutor`, a job executor named `k8s` that runs SPARK job templates as `SparkApplication` resources of [apache/spark-kubernetes-operator](https://github.com/apache/spark-kubernetes-operator) 0.9.x. It rejects SHELL templates for now. All the job state lives in Kubernetes, so any Gravitino server can query and cancel any job. - **Submit**: creates the SparkApplication. - On a conflict, an existing SparkApplication is adopted only if it belongs to the same job. - A job that the cluster rejects, for example because its namespace doesn't exist, is rejected with the reason. So is a job that sets `spark.submit.deployMode=client`, as the operator only runs cluster mode. - If the API server doesn't answer, the executor tries, best effort, to delete the SparkApplication in case it was created, as Gravitino records no job for a failed submission. If that cleanup can't reach the API server either, the SparkApplication may be left running (a sweep of such orphans is a follow-up in #13554). - **Status**: - The SparkApplications of a namespace are listed once and cached for `statusCacheTtlInMs` (`NamespacedResourceCache`). A miss falls back to a GET. - A SparkApplication with no `status` field after `noStatusTimeoutInMs` is deleted before the job fails, so the operator can't run it later. One whose status has no state this version can read stays unfinished. - A failed job whose driver container hasn't terminated gets its driver pod deleted, for the same reason. The job isn't failed while its driver pod can't be looked up. A driver that exited is kept with its log, even if a sidecar keeps the pod running. - The started and finished times of a finished job, or of one whose SparkApplication is being deleted such as a cancelled one, come from its driver pod when it is still there. The driver pod is found by the operator's labels and must be owned by the SparkApplication. - While the API server can't be reached, the status queries fail right away for 10 seconds after the first failure, so a round over many jobs doesn't wait for the connection to time out once per job. - **Cancel**: the SparkApplication gets a cancel-requested annotation, written with a resource-version checked update so a job that stops meanwhile isn't marked, then is deleted with `Foreground` propagation. The job is `CANCELLED` once it is being deleted or has stopped. - **Ownership**: the status, cancel and output paths only use a SparkApplication that is managed by Gravitino and whose job id label matches its name. Anything else with the name is treated as missing. - **Cluster**: a job whose execution id names another `cluster` fails its queries with `IllegalStateException` rather than `NoSuchJobException`, so it isn't ended while it may still run. `cluster` must be the same on every server and must not be renamed while jobs run. - **Output**: stdout is the tail of the driver pod log (`PodLogUtils`). Kubernetes merges stderr into it, so stderr is empty. The executor isn't registered in `JobExecutorFactory` or shipped in the distribution yet. That is #13616. ### Why are the changes needed? The only built-in job executor, `local`, runs jobs as child processes of the Gravitino server, which isn't suitable for production. This is the first step of #13554. Fix: #13613 ### Does this PR introduce _any_ user-facing change? Yes, the configurations under `gravitino.jobExecutor.k8s.` added in part 1 take effect once the executor is registered in #13616. - Connection: `cluster`, `kubeconfig`, `context`, `masterUrl`, `caCertFile`, `tokenFile`, `connectionTimeoutInMs`, `requestTimeoutInMs`, `requestRetries` - Jobs: `namespace` (required), `namePrefix`, `statusCacheTtlInMs`, `noStatusTimeoutInMs` - Spark: `spark.image` and `spark.sparkVersion` (both required), `spark.serviceAccount`, `spark.resourceRetainDurationInMs`, `spark.ttlAfterStopInMs`, `spark.driverStartTimeoutInMs`, `spark.driverReadyTimeoutInMs`, `spark.conf.*` ### How was this patch tested? - Unit tests. The executor tests run against the fabric8 mock server. - Manually on a local Kubernetes cluster (OrbStack) with operator 0.9.0, driving the executor directly: a successful job, a failing job, a cancelled job, a job whose driver can't be scheduled, and reading the driver output. Re-run after the second review (Kubernetes 1.35, operator 0.9.0): a successful job, a failing job and a cancelled job, with their times and output, and a cancel of a finished job, which keeps its outcome. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent dc4b20e commit 361b1e6

13 files changed

Lines changed: 2749 additions & 25 deletions

‎plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutor.java‎

Lines changed: 618 additions & 0 deletions
Large diffs are not rendered by default.

‎plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutorConfigs.java‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ public class K8sJobExecutorConfigs {
3535
/** The name of the job executor. */
3636
public static final String K8S_JOB_EXECUTOR_NAME = "k8s";
3737

38-
/** The name of the Kubernetes cluster the jobs run in, which is part of the job execution id. */
38+
/**
39+
* The name of the Kubernetes cluster the jobs run in, which is part of the job execution id. It
40+
* must be the same on every Gravitino server, and must not be changed while jobs run: the jobs
41+
* submitted under another name can't be queried nor cancelled, and stay unfinished until it is
42+
* changed back.
43+
*/
3944
public static final String CLUSTER = "cluster";
4045

4146
/** The default cluster name. */
@@ -105,7 +110,11 @@ public class K8sJobExecutorConfigs {
105110
/** The default status cache TTL, 5 seconds. */
106111
public static final long DEFAULT_STATUS_CACHE_TTL_MS = 5_000L;
107112

108-
/** A job whose resource still has no status after this time is considered failed. */
113+
/**
114+
* A job whose resource still has no status after this time is considered failed, and its resource
115+
* is deleted. A job whose resource has a status without any state this version can read, for
116+
* example of a newer operator, stays unfinished instead, until the operator deletes the resource.
117+
*/
109118
public static final String NO_STATUS_TIMEOUT_MS = "noStatusTimeoutInMs";
110119

111120
/** The default no status timeout, 10 minutes. */

‎plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobResourceUtils.java‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020

2121
import com.google.common.collect.ImmutableMap;
2222
import io.fabric8.kubernetes.api.model.HasMetadata;
23+
import io.fabric8.kubernetes.api.model.ObjectMeta;
2324
import java.util.Map;
2425
import java.util.regex.Pattern;
26+
import org.apache.commons.lang3.StringUtils;
2527

2628
/** The naming and labeling of the Kubernetes resources created for the jobs. */
2729
public final class K8sJobResourceUtils {
@@ -92,6 +94,27 @@ public static boolean isOfJob(HasMetadata resource, long jobId) {
9294
return labels != null && String.valueOf(jobId).equals(labels.get(LABEL_JOB_ID));
9395
}
9496

97+
/**
98+
* Returns whether the Kubernetes resource is the one Gravitino created for the job its name was
99+
* made for: it is managed by Gravitino, and its job id label is the job id its name ends with.
100+
* The name alone may be used by another system, or by another Gravitino deployment with the same
101+
* name prefix in the namespace, for another of its jobs.
102+
*
103+
* @param resource the resource
104+
* @return true if the resource is a job resource of Gravitino matching its name
105+
*/
106+
public static boolean isJobResource(HasMetadata resource) {
107+
ObjectMeta metadata = resource.getMetadata();
108+
Map<String, String> labels = metadata == null ? null : metadata.getLabels();
109+
if (labels == null || !MANAGED_BY_GRAVITINO.equals(labels.get(LABEL_MANAGED_BY))) {
110+
return false;
111+
}
112+
String jobId = labels.get(LABEL_JOB_ID);
113+
return StringUtils.isNotBlank(jobId)
114+
&& metadata.getName() != null
115+
&& metadata.getName().endsWith("-" + jobId);
116+
}
117+
95118
/**
96119
* Returns whether Gravitino requested to cancel the job of the Kubernetes resource.
97120
*
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
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.gravitino.job.k8s;
20+
21+
import com.google.common.collect.ImmutableMap;
22+
import io.fabric8.kubernetes.api.model.HasMetadata;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.Optional;
26+
import java.util.concurrent.CompletableFuture;
27+
import java.util.concurrent.CompletionException;
28+
import java.util.concurrent.ConcurrentHashMap;
29+
import java.util.function.Function;
30+
import java.util.function.LongSupplier;
31+
import java.util.function.Predicate;
32+
import javax.annotation.Nullable;
33+
import org.slf4j.Logger;
34+
import org.slf4j.LoggerFactory;
35+
36+
/**
37+
* Caches the resources listed in a namespace for a short time, so that querying the status of many
38+
* jobs in the same namespace costs one LIST call instead of one GET call per job.
39+
*
40+
* <p>A resource missing from the cache isn't necessarily gone: it may have been created after the
41+
* namespace was listed, or the namespace can't be listed at all. The caller must fall back to a GET
42+
* call on a cache miss.
43+
*
44+
* @param <T> the resource type
45+
*/
46+
final class NamespacedResourceCache<T extends HasMetadata> {
47+
48+
private static final class Snapshot<T> {
49+
50+
private final long listedAtMs;
51+
52+
private final Map<String, T> resources;
53+
54+
private Snapshot(long listedAtMs, Map<String, T> resources) {
55+
this.listedAtMs = listedAtMs;
56+
this.resources = resources;
57+
}
58+
}
59+
60+
private static final Logger LOG = LoggerFactory.getLogger(NamespacedResourceCache.class);
61+
62+
private final long ttlMs;
63+
64+
private final Function<String, List<T>> lister;
65+
66+
private final Predicate<RuntimeException> isFatal;
67+
68+
private final LongSupplier clock;
69+
70+
// The listing of each namespace, being made or made. Only one thread lists a namespace at a
71+
// time, the others wait for its listing. A listing is a network call, so it is made outside of
72+
// the map, which only holds the namespaces listed within the TTL or being listed.
73+
private final Map<String, CompletableFuture<Snapshot<T>>> snapshots = new ConcurrentHashMap<>();
74+
75+
/**
76+
* Creates a cache.
77+
*
78+
* @param ttlMs how long a namespace listing is cached, caching is disabled if not positive
79+
* @param lister lists the resources of a namespace
80+
* @param isFatal tells the failures of the lister that getting a resource by itself would run
81+
* into as well, for example an API server that can't be reached. They are thrown to the
82+
* caller of {@link #find(String, String)} instead of being cached as an empty listing.
83+
* @param clock the current time in milliseconds
84+
*/
85+
NamespacedResourceCache(
86+
long ttlMs,
87+
Function<String, List<T>> lister,
88+
Predicate<RuntimeException> isFatal,
89+
LongSupplier clock) {
90+
this.ttlMs = ttlMs;
91+
this.lister = lister;
92+
this.isFatal = isFatal;
93+
this.clock = clock;
94+
}
95+
96+
/**
97+
* Finds a resource in the cached listing of its namespace, and lists the namespace again if the
98+
* cached listing has expired. A namespace that can't be listed, for example because listing it is
99+
* forbidden, isn't listed again until the cached failure expires.
100+
*
101+
* @param namespace the namespace of the resource
102+
* @param name the name of the resource
103+
* @return the resource, or empty if it isn't in the listing or the namespace can't be listed
104+
* @throws RuntimeException the failure to list the namespace, if it is a fatal one
105+
*/
106+
Optional<T> find(String namespace, String name) {
107+
if (ttlMs <= 0) {
108+
return Optional.empty();
109+
}
110+
111+
CompletableFuture<Snapshot<T>> snapshot = snapshots.get(namespace);
112+
if (isExpired(snapshot)) {
113+
// Drop the expired listings, also of the namespaces whose jobs are all done.
114+
snapshots.values().removeIf(this::isExpired);
115+
CompletableFuture<Snapshot<T>> listing = new CompletableFuture<>();
116+
snapshot =
117+
snapshots.compute(namespace, (ns, current) -> isExpired(current) ? listing : current);
118+
if (snapshot == listing) {
119+
try {
120+
listing.complete(snapshotOf(namespace));
121+
} catch (Throwable t) {
122+
// Whatever the failure, even an error, the listing must be completed and isn't cached,
123+
// otherwise the next queries of the namespace would wait for it forever. The waiting
124+
// callers get the failure too.
125+
snapshots.remove(namespace, listing);
126+
listing.completeExceptionally(t);
127+
throw t;
128+
}
129+
}
130+
}
131+
132+
try {
133+
return Optional.ofNullable(snapshot.join().resources.get(name));
134+
} catch (CompletionException e) {
135+
if (e.getCause() instanceof Error) {
136+
throw (Error) e.getCause();
137+
}
138+
throw e.getCause() instanceof RuntimeException ? (RuntimeException) e.getCause() : e;
139+
}
140+
}
141+
142+
/**
143+
* Drops the cached listing of a namespace, after a resource in it is changed. A listing of it
144+
* being made isn't cached either, as it may predate the change.
145+
*
146+
* @param namespace the namespace
147+
*/
148+
void invalidate(String namespace) {
149+
snapshots.remove(namespace);
150+
}
151+
152+
/** Whether a listing must be made again: there is none, it failed, or it is too old. */
153+
private boolean isExpired(@Nullable CompletableFuture<Snapshot<T>> snapshot) {
154+
if (snapshot == null || snapshot.isCompletedExceptionally()) {
155+
return true;
156+
}
157+
// A listing being made is waited for.
158+
Snapshot<T> made = snapshot.getNow(null);
159+
return made != null && clock.getAsLong() - made.listedAtMs >= ttlMs;
160+
}
161+
162+
private Snapshot<T> snapshotOf(String namespace) {
163+
long listedAtMs = clock.getAsLong();
164+
try {
165+
ImmutableMap.Builder<String, T> resources = ImmutableMap.builder();
166+
lister.apply(namespace).forEach(r -> resources.put(r.getMetadata().getName(), r));
167+
return new Snapshot<>(listedAtMs, resources.buildKeepingLast());
168+
} catch (RuntimeException e) {
169+
if (isFatal.test(e)) {
170+
throw e;
171+
}
172+
// Cache the failure as an empty listing, so that the callers get the resources one by one
173+
// instead of each of them failing to list the namespace again.
174+
LOG.warn(
175+
"Failed to list the resources in namespace {}, getting them one by one for the next {}"
176+
+ " ms: {}",
177+
namespace,
178+
ttlMs,
179+
e.toString());
180+
return new Snapshot<>(listedAtMs, ImmutableMap.of());
181+
}
182+
}
183+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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.gravitino.job.k8s;
20+
21+
import com.google.common.base.Preconditions;
22+
import com.google.common.collect.ImmutableList;
23+
import java.io.IOException;
24+
import java.io.InputStream;
25+
import java.nio.charset.StandardCharsets;
26+
import java.util.Arrays;
27+
import java.util.List;
28+
29+
/** Reads the output of a job from the log of its pod. */
30+
public final class PodLogUtils {
31+
32+
private static final int READ_BUFFER_SIZE = 8192;
33+
34+
// The largest array size that is safe to allocate on every JVM.
35+
private static final int MAX_BUFFER_SIZE = Integer.MAX_VALUE - 8;
36+
37+
private PodLogUtils() {}
38+
39+
/**
40+
* Reads the last lines of a log stream, within its last bytes. Only the last bytes are kept in
41+
* memory while reading. If the kept bytes start in the middle of a line, that partial line is
42+
* dropped, unless it is the only line.
43+
*
44+
* <p>The whole stream is read: {@code maxBytes} bounds the memory and the lines returned, not the
45+
* bytes read. The caller bounds those, for example by asking Kubernetes for the last lines of a
46+
* log only.
47+
*
48+
* @param log the log stream, which is read to its end
49+
* @param maxLines the maximum number of lines to return
50+
* @param maxBytes the maximum number of bytes to read from the tail of the log
51+
* @return the last lines of the log
52+
* @throws IOException if the log can't be read
53+
*/
54+
public static List<String> readLastLines(InputStream log, int maxLines, int maxBytes)
55+
throws IOException {
56+
Preconditions.checkArgument(maxLines > 0, "maxLines must be positive");
57+
Preconditions.checkArgument(maxBytes > 0, "maxBytes must be positive");
58+
59+
// Keep one more byte than the window, to tell whether the window starts at a line boundary.
60+
// The window is capped so that the buffer, of twice its size, fits in an array.
61+
int keep = (int) Math.min(maxBytes + 1L, MAX_BUFFER_SIZE / 2);
62+
int window = keep - 1;
63+
int maxCapacity = Math.max(2 * keep, READ_BUFFER_SIZE);
64+
// The buffer grows with the log, as most logs are much shorter than the window.
65+
byte[] tail = new byte[READ_BUFFER_SIZE];
66+
int length = 0;
67+
byte[] chunk = new byte[READ_BUFFER_SIZE];
68+
int read;
69+
while ((read = log.read(chunk)) >= 0) {
70+
if (length + read > tail.length && tail.length < maxCapacity) {
71+
long capacity = Math.max(2L * tail.length, (long) length + read);
72+
tail = Arrays.copyOf(tail, (int) Math.min(capacity, maxCapacity));
73+
}
74+
if (length + read > tail.length) {
75+
// Drop the head, keeping the last bytes of the buffer and the chunk.
76+
int kept = Math.max(keep - read, 0);
77+
System.arraycopy(tail, length - kept, tail, 0, kept);
78+
length = kept;
79+
if (read > keep) {
80+
System.arraycopy(chunk, read - keep, tail, 0, keep);
81+
length = keep;
82+
continue;
83+
}
84+
}
85+
System.arraycopy(chunk, 0, tail, length, read);
86+
length += read;
87+
}
88+
89+
int start = 0;
90+
boolean startsAtLineBoundary = true;
91+
if (length > window) {
92+
start = length - window;
93+
startsAtLineBoundary = tail[start - 1] == '\n';
94+
}
95+
// Never start in the middle of a UTF-8 character. A '\n' is never part of one.
96+
while (start < length && (tail[start] & 0xC0) == 0x80) {
97+
start++;
98+
}
99+
String content = new String(tail, start, length - start, StandardCharsets.UTF_8);
100+
if (!startsAtLineBoundary) {
101+
int firstNewline = content.indexOf('\n');
102+
if (firstNewline >= 0 && firstNewline < content.length() - 1) {
103+
content = content.substring(firstNewline + 1);
104+
}
105+
}
106+
if (content.isEmpty()) {
107+
return ImmutableList.of();
108+
}
109+
110+
List<String> lines = Arrays.asList(content.split("\\r?\\n", -1));
111+
// A trailing line terminator doesn't start another line.
112+
if (lines.get(lines.size() - 1).isEmpty()) {
113+
lines = lines.subList(0, lines.size() - 1);
114+
}
115+
// The window only holds the line terminator of a line that starts before it.
116+
if (!startsAtLineBoundary && lines.size() == 1 && lines.get(0).isEmpty()) {
117+
return ImmutableList.of();
118+
}
119+
return ImmutableList.copyOf(lines.subList(Math.max(0, lines.size() - maxLines), lines.size()));
120+
}
121+
}

0 commit comments

Comments
 (0)