This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/main by this push:
new 4872551328 [#14058] improvement(k8s-job-executor): Harden the k8s job
executor and quiet the job status poll (#14230)
4872551328 is described below
commit 48725513281f572a6647d4de44d1c626adf9f133
Author: Jerry Shao <[email protected]>
AuthorDate: Tue Oct 27 19:41:45 2026 +0800
[#14058] improvement(k8s-job-executor): Harden the k8s job executor and
quiet the job status poll (#14230)
### What changes were proposed in this pull request?
Follow-ups of the `k8s` job executor (#13613, #13616, #13617).
**Harden the k8s job executor**
- A driver pod whose phase is `Succeeded` or `Failed` has terminated,
whatever its container status says, for example after its node was lost.
Only otherwise the driver container decides, as before.
- A create that fails with a 5xx gets the same best-effort cleanup as
one without a response: the SparkApplication may have been created. If
the cleanup fails, a WARN tells how to delete it by hand. Gravitino
doesn't sweep the SparkApplications of jobs it doesn't know (see
#13554).
- The SparkApplications of a namespace are listed page by page (500 per
request). A listing that times out waiting for the response no longer
counts as an unreachable API server: the jobs are got one by one instead
of failing every status query for 10 s.
- Where a read decides the deletion (the no-status cleanup and the
failed-submit cleanup), the SparkApplication is deleted with a
`resourceVersion` precondition and the `Foreground` propagation, and
read again on a 409 (up to 3 retries). An application that got a status
meanwhile follows the normal status path. fabric8 7.2.0 types
`lockResourceVersion()` as `ReplaceDeletable`, without
`withPropagationPolicy`, so the resource is used as the
`ExtensibleResource` it is; both end up in the `DeleteOptions` of the
request.
**Job status poll**
- `JobManager` logged an ERROR with a full stack trace for every active
job on every poll while the job executor failed, for example while the
API server was unreachable. It now logs one WARN per poll and metalake,
with the number of failed jobs, the first five of them and the first
failure, and each failed job at DEBUG.
**Output size**
- `JobExecutor#getJobStdout`/`getJobStderr`,
`gravitino.job.outputMaxBytes` and the docs said `maxBytes` bounds both
the read cost and the response size. It bounds the response size; an
executor may not bound the bytes it reads, as the k8s job executor reads
the last lines of the driver log.
### Why are the changes needed?
They are the follow-ups of the k8s job executor collected in #13554: a
terminated driver pod could be deleted or kept by mistake, a
SparkApplication could run unknown after a create that failed with a
5xx, a namespace with many SparkApplications could stop all status
updates, a deletion could act on a SparkApplication that changed after
it was read, and the status poll flooded the log while the API server
was unreachable.
Fix: #14058
### Does this PR introduce _any_ user-facing change?
- The status poll failures are logged once per poll and metalake at WARN
instead of once per job at ERROR.
- The description of `gravitino.job.outputMaxBytes` changed. No new
configuration.
### How was this patch tested?
- Unit tests with the fabric8 mock server for the submit cleanup after a
5xx (also of a resource of another job, and after a conflict), the logs
of a deleted SparkApplication, the paged listing, a slow listing, the
conditional deletion of a job without status (request body, a status
written meanwhile, retries running out), `isDriverRunning`, the cache
eviction, and the summarized poll log in `TestJobManager`. The key tests
fail when their fix is reverted.
- Manually against a local Kubernetes cluster (OrbStack) with
spark-kubernetes-operator 0.9.0: Spark Pi succeeded with its driver
output, a running job was cancelled, a SparkApplication that never got a
status (in a namespace the operator doesn't watch) was deleted and its
job failed, and the API server rejected a `Foreground` deletion with a
stale `resourceVersion` precondition with a 409 and accepted it with the
current one.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../main/java/org/apache/gravitino/Configs.java | 12 +-
.../gravitino/connector/job/JobExecutor.java | 16 +-
.../java/org/apache/gravitino/job/JobManager.java | 76 +++--
.../gravitino/job/JobOperationDispatcher.java | 2 +-
.../org/apache/gravitino/job/TestJobManager.java | 91 ++++++
docs/gravitino-server-config.md | 2 +-
docs/manage-jobs-in-gravitino.md | 2 +-
docs/open-api/jobs.yaml | 2 +-
.../apache/gravitino/job/k8s/K8sJobExecutor.java | 150 +++++++--
.../gravitino/job/k8s/NamespacedResourceCache.java | 34 ++-
.../job/k8s/spark/SparkApplicationStatusUtils.java | 20 +-
.../gravitino/job/k8s/TestK8sJobExecutor.java | 340 ++++++++++++++++++++-
.../job/k8s/TestNamespacedResourceCache.java | 64 +++-
.../k8s/spark/TestSparkApplicationStatusUtils.java | 31 ++
14 files changed, 754 insertions(+), 88 deletions(-)
diff --git a/core/src/main/java/org/apache/gravitino/Configs.java
b/core/src/main/java/org/apache/gravitino/Configs.java
index f85ac413d2..fe19e24f3a 100644
--- a/core/src/main/java/org/apache/gravitino/Configs.java
+++ b/core/src/main/java/org/apache/gravitino/Configs.java
@@ -663,12 +663,12 @@ public class Configs {
public static final ConfigEntry<Integer> JOB_OUTPUT_MAX_BYTES =
new ConfigBuilder("gravitino.job.outputMaxBytes")
.doc(
- "The maximum number of bytes read from the tail of a job's
captured stdout/stderr "
- + "when retrieving its output. Bounds both the read cost and
the response size "
- + "regardless of how the content is shaped (e.g. a single
very long line). "
- + "This is resolved by JobManager and passed as an argument
to "
- + "JobExecutor#getJobStdout and JobExecutor#getJobStderr, so
all executors "
- + "honor the same cap.")
+ "The maximum number of bytes returned from the tail of a job's
captured "
+ + "stdout/stderr when retrieving its output. Bounds the
response size regardless "
+ + "of how the content is shaped (e.g. a single very long
line). Whether it also "
+ + "bounds the bytes read depends on the job executor. This
is resolved by "
+ + "JobManager and passed as an argument to
JobExecutor#getJobStdout and "
+ + "JobExecutor#getJobStderr, so all executors honor the same
cap.")
.version(ConfigConstants.VERSION_2_0_0)
.intConf()
.checkValue(value -> value > 0,
ConfigConstants.POSITIVE_NUMBER_ERROR_MSG)
diff --git
a/core/src/main/java/org/apache/gravitino/connector/job/JobExecutor.java
b/core/src/main/java/org/apache/gravitino/connector/job/JobExecutor.java
index 32fd9d6783..c15389e738 100644
--- a/core/src/main/java/org/apache/gravitino/connector/job/JobExecutor.java
+++ b/core/src/main/java/org/apache/gravitino/connector/job/JobExecutor.java
@@ -189,9 +189,11 @@ public interface JobExecutor extends Closeable {
* @param jobId The unique identifier of the job.
* @param maxLines The maximum number of (most recent) lines to return,
resolved by the caller
* from the {@code gravitino.job.outputMaxLines} configuration.
- * @param maxBytes The maximum number of (most recent) bytes to read from
the underlying output,
- * resolved by the caller from the {@code gravitino.job.outputMaxBytes}
configuration. Bounds
- * both the read cost and the response size regardless of how the
content is shaped.
+ * @param maxBytes The maximum number of (most recent) bytes to return,
resolved by the caller
+ * from the {@code gravitino.job.outputMaxBytes} configuration. Bounds
the response size
+ * regardless of how the content is shaped. An implementation should
also bound the bytes it
+ * reads from the underlying output when it can, but may not, for
example when the output can
+ * only be read by lines.
* @return the stdout lines of the job, or an empty list if not available.
*/
default List<String> getJobStdout(String jobId, int maxLines, int maxBytes) {
@@ -212,9 +214,11 @@ public interface JobExecutor extends Closeable {
* @param jobId The unique identifier of the job.
* @param maxLines The maximum number of (most recent) lines to return,
resolved by the caller
* from the {@code gravitino.job.outputMaxLines} configuration.
- * @param maxBytes The maximum number of (most recent) bytes to read from
the underlying output,
- * resolved by the caller from the {@code gravitino.job.outputMaxBytes}
configuration. Bounds
- * both the read cost and the response size regardless of how the
content is shaped.
+ * @param maxBytes The maximum number of (most recent) bytes to return,
resolved by the caller
+ * from the {@code gravitino.job.outputMaxBytes} configuration. Bounds
the response size
+ * regardless of how the content is shaped. An implementation should
also bound the bytes it
+ * reads from the underlying output when it can, but may not, for
example when the output can
+ * only be read by lines.
* @return the stderr lines of the job, or an empty list if not available.
*/
default List<String> getJobStderr(String jobId, int maxLines, int maxBytes) {
diff --git a/core/src/main/java/org/apache/gravitino/job/JobManager.java
b/core/src/main/java/org/apache/gravitino/job/JobManager.java
index 86887db750..2ad5a3b1a3 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -121,6 +121,9 @@ public class JobManager implements JobOperationDispatcher {
// stop.
private static final int CANCELLATION_TIMEOUT_IN_PULL_INTERVALS = 3;
+ // How many of the jobs whose status pull failed are named in the log of a
poll.
+ private static final int MAX_LOGGED_FAILED_JOBS = 5;
+
private final EntityStore entityStore;
private final File stagingDir;
@@ -488,7 +491,7 @@ public class JobManager implements JobOperationDispatcher {
maxBytes == null || maxBytes > 0, "maxBytes must be positive if
specified");
// A caller-specified maxLines/maxBytes can only narrow the globally
configured cap, never
- // widen it - the global configuration remains a hard upper bound on read
cost/response size.
+ // widen it - the global configuration remains a hard upper bound on the
response size.
int effectiveMaxLines = clampToGlobalMax(maxLines, jobOutputMaxLines);
int effectiveMaxBytes = clampToGlobalMax(maxBytes, jobOutputMaxBytes);
@@ -1046,30 +1049,47 @@ public class JobManager implements
JobOperationDispatcher {
|| job.status() == JobHandle.Status.CANCELLING)
.toList();
+ // A job whose status can't be pulled or updated is kept unchanged, and
retried in the next
+ // poll. While the job executor fails, for example while its backend is
unreachable, every job
+ // fails the same way on every poll, so the failures are logged once per
poll.
long now = System.currentTimeMillis();
- activeJobs.forEach(
- job -> {
- // A failure on one job must never stop the status pull of the
others.
- try {
- // Only the job executor instance owning the job can query its
status. The jobs
- // owned by other servers are skipped, except a node local job
whose cancellation its
- // owner has not answered in time: the owner has likely exited,
e.g. the server
- // restarted, so nobody else would ever settle it. The other jobs
left behind by a
- // server that has exited are settled by cleanUpStagingDirs() once
they expire.
- if (jobExecutor.ownsJob(job.jobExecutionId())) {
- pullAndUpdateOwnedJobStatus(metalake, job);
- } else if (jobExecutor.isJobStateNodeLocal() &&
isCancellationUnanswered(job, now)) {
- settleUnansweredCancellation(metalake, job, now);
- }
- } catch (Throwable t) {
- LOG.error(
- "Failed to update the status of job {} with execution id {}
under metalake {}",
- job.name(),
- job.jobExecutionId(),
- metalake,
- t);
- }
- });
+ List<String> failedJobs = new ArrayList<>();
+ Throwable firstFailure = null;
+ for (JobEntity job : activeJobs) {
+ // A failure on one job must never stop the status pull of the others.
+ try {
+ // Only the job executor instance owning the job can query its status.
The jobs owned by
+ // other servers are skipped, except a node local job whose
cancellation its owner has not
+ // answered in time: the owner has likely exited, e.g. the server
restarted, so nobody else
+ // would ever settle it. The other jobs left behind by a server that
has exited are settled
+ // by cleanUpStagingDirs() once they expire.
+ if (jobExecutor.ownsJob(job.jobExecutionId())) {
+ pullAndUpdateOwnedJobStatus(metalake, job);
+ } else if (jobExecutor.isJobStateNodeLocal() &&
isCancellationUnanswered(job, now)) {
+ settleUnansweredCancellation(metalake, job, now);
+ }
+ } catch (Throwable t) {
+ LOG.debug(
+ "Failed to pull or update the status of job {} with execution id
{} under metalake {}",
+ job.name(),
+ job.jobExecutionId(),
+ metalake,
+ t);
+ failedJobs.add(job.name());
+ firstFailure = firstFailure == null ? t : firstFailure;
+ }
+ }
+
+ if (firstFailure != null) {
+ LOG.warn(
+ "Failed to pull or update the status of {} of {} active jobs under
metalake {}, for"
+ + " example {}, retrying in the next poll. The first failure:",
+ failedJobs.size(),
+ activeJobs.size(),
+ metalake,
+ failedJobs.subList(0, Math.min(failedJobs.size(),
MAX_LOGGED_FAILED_JOBS)),
+ firstFailure);
+ }
}
private void pullAndUpdateOwnedJobStatus(String metalake, JobEntity job) {
@@ -1104,14 +1124,6 @@ public class JobManager implements
JobOperationDispatcher {
job.jobExecutionId(),
metalake,
observed.status());
- } catch (Exception e) {
- // Keep the job unchanged, and retry it in the next poll.
- LOG.error(
- "Failed to pull or cancel job {} by execution id {}",
- job.name(),
- job.jobExecutionId(),
- e);
- return;
}
// Besides the status, the job executor may report the timestamps of the
job later than its
diff --git
a/core/src/main/java/org/apache/gravitino/job/JobOperationDispatcher.java
b/core/src/main/java/org/apache/gravitino/job/JobOperationDispatcher.java
index 7c2c340fef..a12b8612da 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobOperationDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobOperationDispatcher.java
@@ -138,7 +138,7 @@ public interface JobOperationDispatcher extends Closeable {
* @param includeOutput whether to also fetch and attach the job's
stdout/stderr output
* @param maxLines the maximum number of (most recent) output lines to
return, or {@code null} to
* use the global default
- * @param maxBytes the maximum number of (most recent) output bytes to read,
or {@code null} to
+ * @param maxBytes the maximum number of (most recent) output bytes to
return, or {@code null} to
* use the global default
* @return the job entity associated with the specified ID
* @throws NoSuchJobException if no job with the specified ID exists
diff --git a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
index a2a05f8fbb..e908f9f2a2 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -41,12 +41,14 @@ import java.nio.file.Files;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
@@ -93,6 +95,12 @@ import org.apache.gravitino.storage.IdGenerator;
import org.apache.gravitino.storage.RandomIdGenerator;
import org.apache.gravitino.utils.NameIdentifierUtil;
import org.apache.gravitino.utils.NamespaceUtil;
+import org.apache.logging.log4j.Level;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.core.LogEvent;
+import org.apache.logging.log4j.core.LoggerContext;
+import org.apache.logging.log4j.core.appender.AbstractAppender;
+import org.apache.logging.log4j.core.layout.PatternLayout;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
@@ -104,6 +112,19 @@ import org.mockito.Mockito;
public class TestJobManager {
+ private static class LogCollector extends AbstractAppender {
+ private final List<LogEvent> events = new CopyOnWriteArrayList<>();
+
+ private LogCollector() {
+ super("jobManagerLogCollector", null,
PatternLayout.createDefaultLayout(), true, null);
+ }
+
+ @Override
+ public void append(LogEvent event) {
+ events.add(event.toImmutable());
+ }
+ }
+
private JobManager jobManager;
private EntityStore entityStore;
@@ -1966,6 +1987,54 @@ public class TestJobManager {
Assertions.assertTrue(cancelled.finishedAt() > 0);
}
+ @Test
+ public void testPullJobStatusLogsFailuresOncePerPoll() throws IOException {
+ List<JobEntity> jobs = new ArrayList<>();
+ for (int i = 0; i < 7; i++) {
+ jobs.add(newJobEntity("execution-" + i, JobHandle.Status.STARTED,
Instant.now(), null));
+ }
+ mockListActiveJobs(jobs.toArray(new JobEntity[0]));
+ // The backend of the job executor is unreachable, the job executor fails
every job but one.
+ RuntimeException unreachable = new RuntimeException("API server
unreachable");
+ when(jobExecutor.getJobExecutionInfo(any())).thenThrow(unreachable);
+ doReturn(JobExecutionInfo.of(JobHandle.Status.STARTED))
+ .when(jobExecutor)
+ .getJobExecutionInfo(jobs.get(6).jobExecutionId());
+ stubEntityStoreUpdateToApply(jobs.get(6));
+
+ List<LogEvent> events = captureLogs(() ->
jobManager.pullAndUpdateJobStatus());
+
+ // Each job is logged at DEBUG, and the poll at WARN once, with the first
failure.
+ Assertions.assertEquals(
+ 6,
+ events.stream()
+ .filter(
+ event ->
+ Level.DEBUG.equals(event.getLevel())
+ && event.getThrown() == unreachable
+ && event
+ .getMessage()
+ .getFormattedMessage()
+ .startsWith("Failed to pull or update the status
of job "))
+ .count());
+ List<LogEvent> warnings =
+ events.stream().filter(event ->
event.getLevel().isMoreSpecificThan(Level.WARN)).toList();
+ Assertions.assertEquals(1, warnings.size());
+ LogEvent warning = warnings.get(0);
+ Assertions.assertEquals(Level.WARN, warning.getLevel());
+ Assertions.assertSame(unreachable, warning.getThrown());
+ String message = warning.getMessage().getFormattedMessage();
+ Assertions.assertTrue(
+ message.contains("6 of 7 active jobs under metalake " + metalake),
message);
+ Assertions.assertTrue(
+ message.contains(
+ jobs.subList(0, 5).stream()
+ .map(JobEntity::name)
+ .collect(Collectors.toList())
+ .toString()),
+ message);
+ }
+
@Test
public void testPullJobStatusKeepsCancellingWhenOwnerFailsToCancel() throws
IOException {
JobEntity job =
@@ -3376,6 +3445,28 @@ public class TestJobManager {
.build();
}
+ private static List<LogEvent> captureLogs(Runnable action) {
+ LoggerContext context =
+ (LoggerContext)
LogManager.getContext(JobManager.class.getClassLoader(), false);
+ var logger = context.getLogger(JobManager.class.getName());
+ LogCollector collector = new LogCollector();
+ collector.start();
+ // Attach to this logger without replacing its configuration or its
existing appenders.
+ synchronized (logger) {
+ Level previous = logger.getLevel();
+ logger.addAppender(collector);
+ logger.setLevel(Level.DEBUG);
+ try {
+ action.run();
+ } finally {
+ logger.removeAppender(collector);
+ logger.setLevel(previous);
+ collector.stop();
+ }
+ }
+ return List.copyOf(collector.events);
+ }
+
private void mockListActiveJobs(JobEntity... jobs) {
mockedMetalake
.when(() -> MetalakeManager.listInUseMetalakes(entityStore))
diff --git a/docs/gravitino-server-config.md b/docs/gravitino-server-config.md
index ce60086496..54224446dd 100644
--- a/docs/gravitino-server-config.md
+++ b/docs/gravitino-server-config.md
@@ -657,7 +657,7 @@ server, are documented with those services. See
| `gravitino.job.stagingDirKeepTimeInMs` | How long in milliseconds a finished
job's staging files are kept. Use at least 10 minutes outside testing.
| `604800000` (7 days) |
| `gravitino.job.statusPullIntervalInMs` | Interval in milliseconds between
job status polls. Use at least 1 minute outside testing.
| `300000` (5 minutes) |
| `gravitino.job.outputMaxLines` | Maximum number of lines returned
when fetching a job's stdout/stderr output.
| `1000` |
-| `gravitino.job.outputMaxBytes` | Maximum number of bytes read from
the tail of a job's stdout/stderr when fetching its output.
| `262144` (256KB) |
+| `gravitino.job.outputMaxBytes` | Maximum number of bytes returned
from the tail of a job's stdout/stderr when fetching its output.
| `262144` (256KB) |
#### Table maintenance cooldowns
diff --git a/docs/manage-jobs-in-gravitino.md b/docs/manage-jobs-in-gravitino.md
index b09e5f23ca..5d2465a42c 100644
--- a/docs/manage-jobs-in-gravitino.md
+++ b/docs/manage-jobs-in-gravitino.md
@@ -403,7 +403,7 @@ default configurations:
| `gravitino.job.stagingDirKeepTimeInMs` | The time in milliseconds to keep
the staging directory after the job is completed
| `604800000` (7 days)
| No |
| `gravitino.job.statusPullIntervalInMs` | The interval in milliseconds to
pull the job status from the job executor
| `300000` (5 minutes)
| No |
| `gravitino.job.outputMaxLines` | The maximum number of lines
returned when fetching a job's stdout/stderr output
| `1000`
| No |
-| `gravitino.job.outputMaxBytes` | The maximum number of bytes read
from the tail of a job's stdout/stderr output
| `262144` (256KB)
| No |
+| `gravitino.job.outputMaxBytes` | The maximum number of bytes
returned from the tail of a job's stdout/stderr output
| `262144` (256KB)
| No |
#### Configurations for Local Job Executor
diff --git a/docs/open-api/jobs.yaml b/docs/open-api/jobs.yaml
index bd194d64c7..00ed2cd991 100644
--- a/docs/open-api/jobs.yaml
+++ b/docs/open-api/jobs.yaml
@@ -395,7 +395,7 @@ components:
name: outputMaxBytes
in: query
description: >
- The maximum number of (most recent) output bytes to read, when
includeOutput is true.
+ The maximum number of (most recent) output bytes to return, when
includeOutput is true.
Ignored otherwise. Can only narrow the globally configured
gravitino.job.outputMaxBytes
cap, never widen it - a value larger than the global cap is clamped
down to it. Omit to
use the global default.
diff --git
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutor.java
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutor.java
index 5e6583f32d..3911f94d6a 100644
---
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutor.java
+++
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutor.java
@@ -26,6 +26,7 @@ import io.fabric8.kubernetes.api.model.Container;
import io.fabric8.kubernetes.api.model.DeletionPropagation;
import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
import io.fabric8.kubernetes.api.model.GenericKubernetesResourceList;
+import io.fabric8.kubernetes.api.model.ListOptionsBuilder;
import io.fabric8.kubernetes.api.model.OwnerReference;
import io.fabric8.kubernetes.api.model.Pod;
import io.fabric8.kubernetes.client.KubernetesClient;
@@ -33,10 +34,14 @@ import
io.fabric8.kubernetes.client.KubernetesClientException;
import io.fabric8.kubernetes.client.dsl.NonNamespaceOperation;
import io.fabric8.kubernetes.client.dsl.PodResource;
import io.fabric8.kubernetes.client.dsl.Resource;
+import io.fabric8.kubernetes.client.extension.ExtensibleResource;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
+import java.net.http.HttpConnectTimeoutException;
+import java.net.http.HttpTimeoutException;
import java.time.Clock;
+import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
@@ -46,6 +51,7 @@ import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Predicate;
import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
import org.apache.gravitino.connector.job.JobContext;
import org.apache.gravitino.connector.job.JobExecutionInfo;
import org.apache.gravitino.connector.job.JobExecutor;
@@ -92,6 +98,10 @@ public class K8sJobExecutor implements JobExecutor {
// How many times a change of a SparkApplication is tried again after it
changed meanwhile.
private static final int UPDATE_CONFLICT_RETRIES = 3;
+ // How many SparkApplications a listing gets per request, so that a
namespace with many of them
+ // is listed in several shorter requests rather than one that may time out.
+ @VisibleForTesting static final long LIST_PAGE_SIZE = 500L;
+
private final Clock clock;
// The failure of the last status query that couldn't reach the API server,
and when it failed.
@@ -129,17 +139,13 @@ public class K8sJobExecutor implements JobExecutor {
this.sparkApplicationCache =
new NamespacedResourceCache<>(
configs.statusCacheTtlMs(),
- namespace ->
- getSparkApplicationClient(namespace)
- .withLabel(
- K8sJobResourceUtils.LABEL_MANAGED_BY,
- K8sJobResourceUtils.MANAGED_BY_GRAVITINO)
- .list()
- .getItems(),
- // Getting the SparkApplication itself couldn't reach the API
server either.
+ this::listSparkApplications,
+ // Getting the SparkApplication itself couldn't reach the API
server either. A listing
+ // that the API server is too slow to answer isn't, it gets the
jobs one by one instead.
e ->
e instanceof KubernetesClientException
- && isUnreachable((KubernetesClientException) e),
+ && isUnreachable((KubernetesClientException) e)
+ && !isResponseTimedOut((KubernetesClientException) e),
clock::millis);
LOG.info(
"Initialized the k8s job executor for cluster {} at {}, default
namespace {}, built-in"
@@ -170,9 +176,10 @@ public class K8sJobExecutor implements JobExecutor {
try {
getSparkApplicationClient(namespace).resource(app).create();
} catch (KubernetesClientException e) {
- if (isUnreachable(e)) {
- // The SparkApplication may have been created although no response
came back. Gravitino
- // records no job for a failed submission, so delete it, otherwise it
would run unknown.
+ if (isUnreachable(e) || e.getCode() >=
HttpURLConnection.HTTP_INTERNAL_ERROR) {
+ // The SparkApplication may have been created although no response or
an error came back.
+ // Gravitino records no job for a failed submission, so delete it,
otherwise it would run
+ // unknown.
deleteUnsubmittedSparkApplication(jobExecutionId, context.jobId());
throw e;
}
@@ -372,6 +379,23 @@ public class K8sJobExecutor implements JobExecutor {
return ioFailure;
}
+ /**
+ * Returns whether a request failed because the API server didn't answer it
in time, although it
+ * could be connected to.
+ *
+ * @param e the failure of the request
+ * @return true if the response timed out
+ */
+ @VisibleForTesting
+ static boolean isResponseTimedOut(KubernetesClientException e) {
+ for (Throwable cause = e.getCause(); cause != null; cause =
cause.getCause()) {
+ if (cause instanceof HttpTimeoutException) {
+ return !(cause instanceof HttpConnectTimeoutException);
+ }
+ }
+ return false;
+ }
+
/**
* Fails right away if the API server couldn't be reached a moment ago, so
that querying the
* status of many jobs doesn't wait for the connection to time out once per
job.
@@ -479,11 +503,15 @@ public class K8sJobExecutor implements JobExecutor {
private JobExecutionInfo getExecutionInfoWithoutStatus(
K8sJobExecutionId id, String jobId, JobExecutionInfo failed) {
// The SparkApplication may come from the cache: it may have got a status
since, or be being
- // deleted, for example by a cancellation on another server. Then it is
mapped as usual. If it
- // is gone meanwhile, the job is missing rather than failed, which settles
a cancelling job as
- // cancelled.
+ // deleted, for example by a cancellation on another server. Then it is
mapped as usual, also
+ // if it gets one between the read and the deletion. If it is gone
meanwhile, the job is
+ // missing rather than failed, which settles a cancelling job as cancelled.
GenericKubernetesResource latest =
- getSparkApplication(id)
+ deleteSparkApplicationIf(
+ id,
+ app ->
+ !SparkApplicationStatusUtils.hasStatus(app)
+ && app.getMetadata().getDeletionTimestamp() == null)
.orElseThrow(
() -> new NoSuchJobException("SparkApplication of job %s
doesn't exist", jobId));
if (SparkApplicationStatusUtils.hasStatus(latest)
@@ -491,7 +519,6 @@ public class K8sJobExecutor implements JobExecutor {
sparkApplicationCache.invalidate(id.namespace());
return getExecutionInfo(id, jobId, latest);
}
- deleteSparkApplication(id);
return failed;
}
@@ -502,16 +529,24 @@ public class K8sJobExecutor implements JobExecutor {
*/
private void deleteUnsubmittedSparkApplication(K8sJobExecutionId id, long
jobId) {
try {
- Optional<GenericKubernetesResource> app = getSparkApplication(id);
+ Optional<GenericKubernetesResource> app =
+ deleteSparkApplicationIf(id, current ->
K8sJobResourceUtils.isOfJob(current, jobId));
if (app.isPresent() && K8sJobResourceUtils.isOfJob(app.get(), jobId)) {
- deleteSparkApplication(id);
LOG.info("Deleted the SparkApplication of job {}, whose submission
failed", jobId);
}
} catch (RuntimeException e) {
+ // Gravitino doesn't sweep the resources of unknown jobs, so tell how to
delete it by hand.
LOG.warn(
- "Failed to clean up the SparkApplication {} of job {}, whose
submission failed",
- id,
+ "Failed to clean up the SparkApplication {}/{} of job {}, whose
submission failed. It may"
+ + " run although Gravitino has no record of it. If it exists
with the label {}={},"
+ + " delete it with: kubectl delete sparkapplication -n {} {}",
+ id.namespace(),
+ id.name(),
+ jobId,
+ K8sJobResourceUtils.LABEL_JOB_ID,
jobId,
+ id.namespace(),
+ id.name(),
e);
}
}
@@ -557,6 +592,59 @@ public class K8sJobExecutor implements JobExecutor {
}
}
+ /**
+ * Deletes the SparkApplication of a job if it should be, only if it hasn't
changed since it was
+ * read, and reads it again to decide again otherwise.
+ *
+ * @param id the job execution id
+ * @param shouldDelete tells whether to delete the SparkApplication
+ * @return the SparkApplication the deletion was decided on, or empty if it
doesn't exist, also if
+ * it was gone by the time it was deleted
+ */
+ private Optional<GenericKubernetesResource> deleteSparkApplicationIf(
+ K8sJobExecutionId id, Predicate<GenericKubernetesResource> shouldDelete)
{
+ for (int attempt = 0; ; attempt++) {
+ Optional<GenericKubernetesResource> app = getSparkApplication(id);
+ if (!app.isPresent() || !shouldDelete.test(app.get())) {
+ return app;
+ }
+ try {
+ // fabric8 reports that nothing was deleted, rather than failing, for
a SparkApplication
+ // that is gone, for example deleted by a cancellation on another
server meanwhile.
+ boolean deleted = !lockSparkApplication(app.get()).delete().isEmpty();
+ sparkApplicationCache.invalidate(id.namespace());
+ return deleted ? app : Optional.empty();
+ } catch (KubernetesClientException e) {
+ if (e.getCode() != HttpURLConnection.HTTP_CONFLICT || attempt >=
UPDATE_CONFLICT_RETRIES) {
+ throw e;
+ }
+ }
+ }
+ }
+
+ /**
+ * Returns a SparkApplication as a resource locked to the version it was
read at. Deleting it
+ * fails with the code 409 if it has changed since, and deletes its pods
first, as {@link
+ * #deleteSparkApplication(K8sJobExecutionId)} does.
+ *
+ * @param app the SparkApplication as it was read
+ * @return the locked resource
+ */
+ @SuppressWarnings("unchecked")
+ private ExtensibleResource<GenericKubernetesResource> lockSparkApplication(
+ GenericKubernetesResource app) {
+ // fabric8 types a resource locked to a version as one that can't set the
propagation policy,
+ // but its implementation can: it sends both the precondition and the
policy in the options of
+ // the deletion.
+ ExtensibleResource<GenericKubernetesResource> resource =
+ (ExtensibleResource<GenericKubernetesResource>)
+ getSparkApplicationClient(app.getMetadata().getNamespace())
+ .withName(app.getMetadata().getName());
+ return resource
+ .lockResourceVersion(app.getMetadata().getResourceVersion())
+ .withPropagationPolicy(DeletionPropagation.FOREGROUND);
+ }
+
/**
* Returns the newest driver pod of a SparkApplication. The operator names
the driver pod after
* the Spark app id, which it may hash-truncate, so it is looked up by the
operator's labels, and
@@ -608,6 +696,26 @@ public class K8sJobExecutor implements JobExecutor {
sparkApplicationCache.invalidate(id.namespace());
}
+ /** Lists the SparkApplications managed by Gravitino in a namespace, page by
page. */
+ private List<GenericKubernetesResource> listSparkApplications(String
namespace) {
+ List<GenericKubernetesResource> apps = new ArrayList<>();
+ String continueToken = null;
+ do {
+ GenericKubernetesResourceList page =
+ getSparkApplicationClient(namespace)
+ .withLabel(
+ K8sJobResourceUtils.LABEL_MANAGED_BY,
K8sJobResourceUtils.MANAGED_BY_GRAVITINO)
+ .list(
+ new ListOptionsBuilder()
+ .withLimit(LIST_PAGE_SIZE)
+ .withContinue(continueToken)
+ .build());
+ apps.addAll(page.getItems());
+ continueToken = page.getMetadata() == null ? null :
page.getMetadata().getContinue();
+ } while (StringUtils.isNotBlank(continueToken));
+ return apps;
+ }
+
private NonNamespaceOperation<
GenericKubernetesResource,
GenericKubernetesResourceList,
diff --git
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/NamespacedResourceCache.java
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/NamespacedResourceCache.java
index 59001e9638..db0356f770 100644
---
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/NamespacedResourceCache.java
+++
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/NamespacedResourceCache.java
@@ -18,11 +18,14 @@
*/
package org.apache.gravitino.job.k8s;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
import io.fabric8.kubernetes.api.model.HasMetadata;
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentHashMap;
@@ -41,24 +44,31 @@ import org.slf4j.LoggerFactory;
* namespace was listed, or the namespace can't be listed at all. The caller
must fall back to a GET
* call on a cache miss.
*
+ * <p>A namespace that can't be listed isn't listed again for {@link
#FAILED_LIST_TTL_MS}, longer
+ * than a listing is cached: a failed listing may have taken long, for example
one that timed out
+ * after its retries, and getting the resources one by one meanwhile gives the
same results.
+ *
* @param <T> the resource type
*/
final class NamespacedResourceCache<T extends HasMetadata> {
private static final class Snapshot<T> {
- private final long listedAtMs;
+ private final long expiresAtMs;
private final Map<String, T> resources;
- private Snapshot(long listedAtMs, Map<String, T> resources) {
- this.listedAtMs = listedAtMs;
+ private Snapshot(long expiresAtMs, Map<String, T> resources) {
+ this.expiresAtMs = expiresAtMs;
this.resources = resources;
}
}
private static final Logger LOG =
LoggerFactory.getLogger(NamespacedResourceCache.class);
+ /** How long a namespace that can't be listed isn't listed again, at least
the TTL. */
+ @VisibleForTesting static final long FAILED_LIST_TTL_MS = 10 * 60 * 1000L;
+
private final long ttlMs;
private final Function<String, List<T>> lister;
@@ -149,6 +159,12 @@ final class NamespacedResourceCache<T extends HasMetadata>
{
snapshots.remove(namespace);
}
+ /** Returns the namespaces whose listing is cached or being made. */
+ @VisibleForTesting
+ Set<String> getCachedNamespaces() {
+ return ImmutableSet.copyOf(snapshots.keySet());
+ }
+
/** Whether a listing must be made again: there is none, it failed, or it is
too old. */
private boolean isExpired(@Nullable CompletableFuture<Snapshot<T>> snapshot)
{
if (snapshot == null || snapshot.isCompletedExceptionally()) {
@@ -156,7 +172,7 @@ final class NamespacedResourceCache<T extends HasMetadata> {
}
// A listing being made is waited for.
Snapshot<T> made = snapshot.getNow(null);
- return made != null && clock.getAsLong() - made.listedAtMs >= ttlMs;
+ return made != null && clock.getAsLong() >= made.expiresAtMs;
}
private Snapshot<T> snapshotOf(String namespace) {
@@ -164,20 +180,22 @@ final class NamespacedResourceCache<T extends
HasMetadata> {
try {
ImmutableMap.Builder<String, T> resources = ImmutableMap.builder();
lister.apply(namespace).forEach(r ->
resources.put(r.getMetadata().getName(), r));
- return new Snapshot<>(listedAtMs, resources.buildKeepingLast());
+ return new Snapshot<>(listedAtMs + ttlMs, resources.buildKeepingLast());
} catch (RuntimeException e) {
if (isFatal.test(e)) {
throw e;
}
// Cache the failure as an empty listing, so that the callers get the
resources one by one
- // instead of each of them failing to list the namespace again.
+ // instead of each of them failing to list the namespace again. It is
cached from when the
+ // listing failed: a listing that timed out may have taken longer than
the TTL.
+ long failedTtlMs = Math.max(ttlMs, FAILED_LIST_TTL_MS);
LOG.warn(
"Failed to list the resources in namespace {}, getting them one by
one for the next {}"
+ " ms: {}",
namespace,
- ttlMs,
+ failedTtlMs,
e.toString());
- return new Snapshot<>(listedAtMs, ImmutableMap.of());
+ return new Snapshot<>(clock.getAsLong() + failedTtlMs,
ImmutableMap.of());
}
}
}
diff --git
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationStatusUtils.java
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationStatusUtils.java
index f78a498137..8bf81e2409 100644
---
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationStatusUtils.java
+++
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationStatusUtils.java
@@ -108,6 +108,9 @@ public final class SparkApplicationStatusUtils {
private static final Set<String> STOPPED_STATES =
ImmutableSet.of("ResourceReleased", "TerminatedWithoutReleaseResources");
+ // The terminal phases of a pod. fabric8 has no public constants for the pod
phases.
+ private static final Set<String> POD_TERMINATED_PHASES =
ImmutableSet.of("Succeeded", "Failed");
+
private SparkApplicationStatusUtils() {}
/**
@@ -251,21 +254,22 @@ public final class SparkApplicationStatusUtils {
}
/**
- * Returns whether the driver of a SparkApplication is still pending or
running. It is decided by
- * the driver container, as a sidecar can keep the pod running after the
driver exited, and by the
- * pod only if the driver container can't be told.
+ * Returns whether the driver of a SparkApplication is still pending or
running. A pod that has
+ * terminated runs nothing, even if its container status still says running,
for example after its
+ * node was lost. Otherwise it is decided by the driver container, as a
sidecar can keep the pod
+ * running after the driver exited, and the driver runs if its container
can't be told.
*
* @param app the SparkApplication
* @param driver the driver pod
* @return true if the driver hasn't terminated
*/
public static boolean isDriverRunning(GenericKubernetesResource app, Pod
driver) {
- ContainerState state = driverContainerState(driver,
getDriverContainerName(app));
- if (state != null) {
- return state.getTerminated() == null;
- }
String phase = driver.getStatus() == null ? null :
driver.getStatus().getPhase();
- return !"Succeeded".equals(phase) && !"Failed".equals(phase);
+ if (POD_TERMINATED_PHASES.contains(phase)) {
+ return false;
+ }
+ ContainerState state = driverContainerState(driver,
getDriverContainerName(app));
+ return state == null || state.getTerminated() == null;
}
/**
diff --git
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutor.java
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutor.java
index a5fdc9c92b..01f479941c 100644
---
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutor.java
+++
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutor.java
@@ -23,6 +23,8 @@ import com.fasterxml.jackson.core.JsonParser;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
+import io.fabric8.kubernetes.api.model.GenericKubernetesResourceList;
+import io.fabric8.kubernetes.api.model.ListMetaBuilder;
import io.fabric8.kubernetes.api.model.OwnerReference;
import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder;
import io.fabric8.kubernetes.api.model.PodBuilder;
@@ -43,6 +45,8 @@ import io.fabric8.mockwebserver.http.RecordedRequest;
import java.io.File;
import java.io.IOException;
import java.net.ConnectException;
+import java.net.http.HttpConnectTimeoutException;
+import java.net.http.HttpTimeoutException;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
@@ -285,6 +289,87 @@ public class TestK8sJobExecutor {
Assertions.assertNull(sparkApplication("gravitino-job-1"));
}
+ @Test
+ public void testJobWithoutStatusDeletedIfUnchanged() throws
InterruptedException {
+ String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+ String resourceVersion =
sparkApplication("gravitino-job-1").getMetadata().getResourceVersion();
+ now.set(now.get().plusSeconds(601));
+
+ Assertions.assertEquals(
+ JobHandle.Status.FAILED,
executor.getJobExecutionInfo(jobExecutionId).status());
+
+ String delete = lastRequest("DELETE").getBody().readUtf8();
+ Assertions.assertTrue(
+ delete.contains("\"resourceVersion\":\"" + resourceVersion + "\""),
delete);
+ Assertions.assertTrue(delete.contains("\"Foreground\""), delete);
+ }
+
+ @Test
+ public void testJobWithoutStatusGetsStatusBeforeDeletion() {
+ String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+ GenericKubernetesResource withoutStatus =
sparkApplication("gravitino-job-1");
+ now.set(now.get().plusSeconds(601));
+ // The operator writes the status after the SparkApplication was read
without one, and before
+ // it is deleted, which then conflicts. Read again, it is mapped as usual.
+ setStates("gravitino-job-1", "Submitted", "DriverReady");
+ server
+ .expect()
+ .get()
+ .withPath(APPS_PATH + "/gravitino-job-1")
+ .andReturn(200, withoutStatus)
+ .times(2);
+ server
+ .expect()
+ .delete()
+ .withPath(APPS_PATH + "/gravitino-job-1")
+ .andReturn(409, new StatusBuilder().withCode(409).build())
+ .once();
+
+ Assertions.assertEquals(
+ JobHandle.Status.STARTED,
executor.getJobExecutionInfo(jobExecutionId).status());
+ Assertions.assertNotNull(sparkApplication("gravitino-job-1"));
+ }
+
+ @Test
+ public void testJobWithoutStatusGoneBeforeDeletion() {
+ String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+ now.set(now.get().plusSeconds(601));
+ // Deleted by a cancellation on another server between the read and the
deletion.
+ server
+ .expect()
+ .delete()
+ .withPath(APPS_PATH + "/gravitino-job-1")
+ .andReturn(404, new StatusBuilder().withCode(404).build())
+ .once();
+
+ // Missing rather than failed, which settles a cancelling job as cancelled.
+ Assertions.assertThrows(
+ NoSuchJobException.class, () ->
executor.getJobExecutionInfo(jobExecutionId));
+ }
+
+ @Test
+ public void testJobWithoutStatusConflictRetriesRunOut() {
+ String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+ now.set(now.get().plusSeconds(601));
+ server
+ .expect()
+ .delete()
+ .withPath(APPS_PATH + "/gravitino-job-1")
+ .andReturn(409, new StatusBuilder().withCode(409).build())
+ .times(4);
+
+ // The job isn't failed while it can't be deleted.
+ KubernetesClientException e =
+ Assertions.assertThrows(
+ KubernetesClientException.class, () ->
executor.getJobExecutionInfo(jobExecutionId));
+ Assertions.assertEquals(409, e.getCode());
+ Assertions.assertNotNull(sparkApplication("gravitino-job-1"));
+
+ Assertions.assertEquals(
+ JobHandle.Status.FAILED,
executor.getJobExecutionInfo(jobExecutionId).status());
+ Assertions.assertNull(sparkApplication("gravitino-job-1"));
+ }
+
@Test
public void testDeleteAliveDriverOfFailedJob() {
String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
@@ -595,6 +680,242 @@ public class TestK8sJobExecutor {
}
}
+ @Test
+ public void testSubmitWithServerError() throws InterruptedException {
+ // The SparkApplication was created, but an error came back instead of the
response.
+ GenericKubernetesResource created =
+ SparkApplicationUtils.buildSparkApplication(
+ context(1L), sparkTemplate(),
TestSparkApplicationUtils.newConfigs(ImmutableMap.of()));
+ client
+ .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+ .inNamespace("default")
+ .resource(created)
+ .create();
+ String resourceVersion =
sparkApplication("gravitino-job-1").getMetadata().getResourceVersion();
+ server
+ .expect()
+ .post()
+ .withPath(APPS_PATH)
+ .andReturn(500, new StatusBuilder().withCode(500).withMessage("etcd
timeout").build())
+ .always();
+
+ try (KubernetesClient noRetries =
clientWithoutRetries(client.getConfiguration())) {
+
executor.initialize(TestSparkApplicationUtils.newConfigs(ImmutableMap.of()),
noRetries);
+ KubernetesClientException e =
+ Assertions.assertThrows(
+ KubernetesClientException.class,
+ () -> executor.submitJob(context(1L), sparkTemplate()));
+ Assertions.assertEquals(500, e.getCode());
+ }
+
+ // Gravitino records no job, so the SparkApplication is deleted, if it is
still the one read.
+ Assertions.assertNull(sparkApplication("gravitino-job-1"));
+ String delete = lastRequest("DELETE").getBody().readUtf8();
+ Assertions.assertTrue(
+ delete.contains("\"resourceVersion\":\"" + resourceVersion + "\""),
delete);
+ Assertions.assertTrue(delete.contains("\"Foreground\""), delete);
+ }
+
+ @Test
+ public void testSubmitWithServerErrorAndFailedCleanup() {
+ server
+ .expect()
+ .post()
+ .withPath(APPS_PATH)
+ .andReturn(500, new StatusBuilder().withCode(500).withMessage("etcd
timeout").build())
+ .always();
+ server
+ .expect()
+ .get()
+ .withPath(APPS_PATH + "/gravitino-job-1")
+ .andReturn(403, new StatusBuilder().withCode(403).build())
+ .always();
+
+ try (KubernetesClient noRetries =
clientWithoutRetries(client.getConfiguration())) {
+
executor.initialize(TestSparkApplicationUtils.newConfigs(ImmutableMap.of()),
noRetries);
+ // The failure of the cleanup doesn't hide the failure of the submission.
+ KubernetesClientException e =
+ Assertions.assertThrows(
+ KubernetesClientException.class,
+ () -> executor.submitJob(context(1L), sparkTemplate()));
+ Assertions.assertEquals(500, e.getCode());
+ }
+ }
+
+ @Test
+ public void testSubmitCleanupKeepsResourceOfAnotherJob() {
+ // A SparkApplication with the name of the job, but of another one.
+ GenericKubernetesResource other =
+ SparkApplicationUtils.buildSparkApplication(
+ context(2L), sparkTemplate(),
TestSparkApplicationUtils.newConfigs(ImmutableMap.of()));
+ other.getMetadata().setName("gravitino-job-1");
+ client
+ .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+ .inNamespace("default")
+ .resource(other)
+ .create();
+ server
+ .expect()
+ .post()
+ .withPath(APPS_PATH)
+ .andReturn(503, new StatusBuilder().withCode(503).build())
+ .always();
+
+ try (KubernetesClient noRetries =
clientWithoutRetries(client.getConfiguration())) {
+
executor.initialize(TestSparkApplicationUtils.newConfigs(ImmutableMap.of()),
noRetries);
+ Assertions.assertThrows(
+ KubernetesClientException.class, () ->
executor.submitJob(context(1L), sparkTemplate()));
+ }
+ Assertions.assertNotNull(sparkApplication("gravitino-job-1"));
+ }
+
+ @Test
+ public void testSubmitCleanupRereadsChangedResource() {
+ GenericKubernetesResource created =
+ SparkApplicationUtils.buildSparkApplication(
+ context(1L), sparkTemplate(),
TestSparkApplicationUtils.newConfigs(ImmutableMap.of()));
+ client
+ .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+ .inNamespace("default")
+ .resource(created)
+ .create();
+ server
+ .expect()
+ .post()
+ .withPath(APPS_PATH)
+ .andReturn(500, new StatusBuilder().withCode(500).build())
+ .always();
+ // It changes between the read and the deletion, so it is read again and
deleted then.
+ server
+ .expect()
+ .delete()
+ .withPath(APPS_PATH + "/gravitino-job-1")
+ .andReturn(409, new StatusBuilder().withCode(409).build())
+ .once();
+
+ try (KubernetesClient noRetries =
clientWithoutRetries(client.getConfiguration())) {
+
executor.initialize(TestSparkApplicationUtils.newConfigs(ImmutableMap.of()),
noRetries);
+ Assertions.assertThrows(
+ KubernetesClientException.class, () ->
executor.submitJob(context(1L), sparkTemplate()));
+ }
+ Assertions.assertNull(sparkApplication("gravitino-job-1"));
+ }
+
+ @Test
+ public void testStdoutOfDeletedSparkApplication() throws
InterruptedException {
+ String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+ createDriverPod("driver", "spark-kubernetes-driver", "sidecar");
+ // The SparkApplication is gone, its driver pod is retained longer.
+ client
+ .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+ .inNamespace("default")
+ .withName("gravitino-job-1")
+ .delete();
+ server
+ .expect()
+ .get()
+ .withPath(
+ "/api/v1/namespaces/default/pods/driver/log?pretty=false"
+ + "&container=spark-kubernetes-driver&tailLines=10")
+ .andReturn(200, "line 1\nline 2\n")
+ .always();
+
+ // The driver container has the default name, as the SparkApplication
can't tell another one.
+ Assertions.assertEquals(
+ ImmutableList.of("line 1", "line 2"),
executor.getJobStdout(jobExecutionId, 10, 1024));
+ }
+
+ @Test
+ public void testListPageByPage() {
+ executor.initialize(
+ TestSparkApplicationUtils.newConfigs(
+ ImmutableMap.of(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS,
"60000")),
+ client);
+ String job1 = executor.submitJob(context(1L), sparkTemplate());
+ String job2 = executor.submitJob(context(2L), sparkTemplate());
+ GenericKubernetesResource app1 = sparkApplication("gravitino-job-1");
+ GenericKubernetesResource app2 = sparkApplication("gravitino-job-2");
+ setStates("gravitino-job-1", "Submitted", "DriverReady");
+ setStates("gravitino-job-2", "Submitted", "DriverReady");
+ String list = APPS_PATH +
"?labelSelector=app.kubernetes.io%2Fmanaged-by%3Dgravitino";
+ server
+ .expect()
+ .get()
+ .withPath(list + "&limit=" + K8sJobExecutor.LIST_PAGE_SIZE)
+ .andReturn(200, listOf("next", app1))
+ .once();
+ server
+ .expect()
+ .get()
+ .withPath(
+ APPS_PATH
+ +
"?continue=next&labelSelector=app.kubernetes.io%2Fmanaged-by%3Dgravitino&limit="
+ + K8sJobExecutor.LIST_PAGE_SIZE)
+ .andReturn(200, listOf(null, app2))
+ .once();
+
+ // Both come from the listing, which predates their new state.
+ Assertions.assertEquals(JobHandle.Status.QUEUED,
executor.getJobExecutionInfo(job1).status());
+ Assertions.assertEquals(JobHandle.Status.QUEUED,
executor.getJobExecutionInfo(job2).status());
+ }
+
+ @Test
+ public void testSlowListingFallsBackToGet() {
+ String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+ server
+ .expect()
+ .get()
+ .delay(2, TimeUnit.SECONDS)
+ .withPath(
+ APPS_PATH
+ +
"?labelSelector=app.kubernetes.io%2Fmanaged-by%3Dgravitino&limit="
+ + K8sJobExecutor.LIST_PAGE_SIZE)
+ .andReturn(200, listOf(null))
+ .always();
+
+ try (KubernetesClient slow =
+ // Retried like the client of the executor, after which the timeout is
still the cause.
+ new KubernetesClientBuilder()
+ .withConfig(
+ new ConfigBuilder(client.getConfiguration())
+ .withRequestTimeout(200)
+ .withRequestRetryBackoffLimit(2)
+ .withRequestRetryBackoffInterval(10)
+ .build())
+ .build()) {
+ executor.initialize(
+ TestSparkApplicationUtils.newConfigs(
+ ImmutableMap.of(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS,
"60000")),
+ slow);
+
+ // The API server answers, only too slowly to list the namespace: the
job is got by itself,
+ // and the next queries aren't failed fast.
+ Assertions.assertEquals(
+ JobHandle.Status.QUEUED,
executor.getJobExecutionInfo(jobExecutionId).status());
+ setStates("gravitino-job-1", "Submitted", "DriverReady");
+ Assertions.assertEquals(
+ JobHandle.Status.STARTED,
executor.getJobExecutionInfo(jobExecutionId).status());
+ }
+ }
+
+ @Test
+ public void testIsResponseTimedOut() {
+ Assertions.assertTrue(
+ K8sJobExecutor.isResponseTimedOut(
+ new KubernetesClientException(
+ "failed", new IOException(new HttpTimeoutException("request
timed out")))));
+ Assertions.assertFalse(
+ K8sJobExecutor.isResponseTimedOut(
+ new KubernetesClientException(
+ "failed", new HttpConnectTimeoutException("connect timed
out"))));
+ Assertions.assertFalse(
+ K8sJobExecutor.isResponseTimedOut(
+ new KubernetesClientException("failed", new IOException(new
ConnectException()))));
+ Assertions.assertFalse(
+ K8sJobExecutor.isResponseTimedOut(
+ new KubernetesClientException(new
StatusBuilder().withCode(504).build())));
+ }
+
@Test
public void testStdoutOfRenamedDriverContainer() throws InterruptedException
{
String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
@@ -642,7 +963,10 @@ public class TestK8sJobExecutor {
server
.expect()
.get()
- .withPath(APPS_PATH +
"?labelSelector=app.kubernetes.io%2Fmanaged-by%3Dgravitino")
+ .withPath(
+ APPS_PATH
+ +
"?labelSelector=app.kubernetes.io%2Fmanaged-by%3Dgravitino&limit="
+ + K8sJobExecutor.LIST_PAGE_SIZE)
.andReturn(403, new StatusBuilder().withCode(403).build())
.always();
@@ -1190,6 +1514,20 @@ public class TestK8sJobExecutor {
return last;
}
+ private static GenericKubernetesResourceList listOf(
+ String continueToken, GenericKubernetesResource... items) {
+ GenericKubernetesResourceList list = new GenericKubernetesResourceList();
+ list.setMetadata(new
ListMetaBuilder().withContinue(continueToken).build());
+ list.setItems(ImmutableList.copyOf(items));
+ return list;
+ }
+
+ private static KubernetesClient clientWithoutRetries(Config config) {
+ return new KubernetesClientBuilder()
+ .withConfig(new
ConfigBuilder(config).withRequestRetryBackoffLimit(0).build())
+ .build();
+ }
+
private static JobContext context(long jobId) {
return new JobContext(jobId, "ml", new File("/tmp/job-" + jobId));
}
diff --git
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestNamespacedResourceCache.java
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestNamespacedResourceCache.java
index aa358c0ee4..8587524210 100644
---
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestNamespacedResourceCache.java
+++
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestNamespacedResourceCache.java
@@ -19,6 +19,7 @@
package org.apache.gravitino.job.k8s;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import io.fabric8.kubernetes.api.model.ConfigMap;
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import java.time.Duration;
@@ -95,11 +96,39 @@ public class TestNamespacedResourceCache {
now::get);
Assertions.assertFalse(cache.find("ns1", "a").isPresent());
- // The namespace isn't listed again until the failure expires.
+ // The namespace isn't listed again until the failure expires, which takes
longer than the TTL
+ // of a listing.
+ Assertions.assertFalse(cache.find("ns1", "b").isPresent());
+ now.addAndGet(100L);
Assertions.assertFalse(cache.find("ns1", "b").isPresent());
Assertions.assertEquals(ImmutableList.of("ns1"), listed);
- now.addAndGet(100L);
+ now.addAndGet(NamespacedResourceCache.FAILED_LIST_TTL_MS - 100L);
+ Assertions.assertFalse(cache.find("ns1", "a").isPresent());
+ Assertions.assertEquals(ImmutableList.of("ns1", "ns1"), listed);
+ }
+
+ @Test
+ public void testSlowListFailureIsCachedFromWhenItFailed() {
+ NamespacedResourceCache<ConfigMap> cache =
+ new NamespacedResourceCache<>(
+ 100L,
+ namespace -> {
+ listed.add(namespace);
+ // The listing times out after longer than a failure is cached.
+ now.addAndGet(NamespacedResourceCache.FAILED_LIST_TTL_MS + 50L);
+ throw new IllegalStateException("timed out");
+ },
+ e -> false,
+ now::get);
+
+ Assertions.assertFalse(cache.find("ns1", "a").isPresent());
+ // The failure isn't expired as soon as it is cached, so the next query
doesn't wait for
+ // another listing.
+ Assertions.assertFalse(cache.find("ns1", "b").isPresent());
+ Assertions.assertEquals(ImmutableList.of("ns1"), listed);
+
+ now.addAndGet(NamespacedResourceCache.FAILED_LIST_TTL_MS);
Assertions.assertFalse(cache.find("ns1", "a").isPresent());
Assertions.assertEquals(ImmutableList.of("ns1", "ns1"), listed);
}
@@ -143,6 +172,37 @@ public class TestNamespacedResourceCache {
Assertions.assertEquals(ImmutableList.of("ns1", "ns1"), listed);
}
+ @Test
+ public void testExpiredListingsAreEvicted() {
+ NamespacedResourceCache<ConfigMap> cache =
+ new NamespacedResourceCache<>(
+ 100L,
+ namespace -> {
+ listed.add(namespace);
+ return ImmutableList.of(resource(namespace, "a"));
+ },
+ e -> false,
+ now::get);
+ Assertions.assertTrue(cache.find("ns1", "a").isPresent());
+ now.addAndGet(50L);
+ Assertions.assertTrue(cache.find("ns2", "a").isPresent());
+ Assertions.assertEquals(ImmutableSet.of("ns1", "ns2"),
cache.getCachedNamespaces());
+
+ // Listing another namespace drops the expired listings, also of the
namespaces no longer
+ // queried, and keeps the others.
+ now.addAndGet(50L);
+ Assertions.assertTrue(cache.find("ns3", "a").isPresent());
+ Assertions.assertEquals(ImmutableSet.of("ns2", "ns3"),
cache.getCachedNamespaces());
+ // A cached listing is found without listing again, so nothing is evicted.
+ Assertions.assertTrue(cache.find("ns2", "a").isPresent());
+ Assertions.assertEquals(ImmutableSet.of("ns2", "ns3"),
cache.getCachedNamespaces());
+
+ now.addAndGet(100L);
+ Assertions.assertTrue(cache.find("ns2", "a").isPresent());
+ Assertions.assertEquals(ImmutableSet.of("ns2"),
cache.getCachedNamespaces());
+ Assertions.assertEquals(ImmutableList.of("ns1", "ns2", "ns3", "ns2"),
listed);
+ }
+
@Test
public void testInvalidationOfAnotherNamespace() {
AtomicReference<NamespacedResourceCache<ConfigMap>> cache = new
AtomicReference<>();
diff --git
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationStatusUtils.java
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationStatusUtils.java
index 499a94ed5d..5404cc33eb 100644
---
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationStatusUtils.java
+++
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationStatusUtils.java
@@ -651,6 +651,33 @@ public class TestSparkApplicationStatusUtils {
.finishedAt());
}
+ @Test
+ public void testIsDriverRunning() {
+ GenericKubernetesResource app = app("Submitted", "DriverRequested",
"DriverStartTimedOut");
+ Pod running = driverPod("spark-kubernetes-driver", "2026-09-30T00:00:10Z",
null);
+ Pod terminated =
+ driverPod("spark-kubernetes-driver", "2026-09-30T00:00:10Z",
"2026-09-30T00:00:20Z");
+
+ Assertions.assertTrue(SparkApplicationStatusUtils.isDriverRunning(app,
running));
+ Assertions.assertFalse(SparkApplicationStatusUtils.isDriverRunning(app,
terminated));
+ // Nothing tells the driver has terminated: a pending pod, or one without
a status.
+ Assertions.assertTrue(SparkApplicationStatusUtils.isDriverRunning(app,
podInPhase("Pending")));
+ Assertions.assertTrue(SparkApplicationStatusUtils.isDriverRunning(app, new
Pod()));
+
+ // A terminated pod runs nothing, even if its container status still says
running, for example
+ // after its node was lost.
+ for (String phase : new String[] {"Succeeded", "Failed"}) {
+ running.getStatus().setPhase(phase);
+ Assertions.assertFalse(SparkApplicationStatusUtils.isDriverRunning(app,
running), phase);
+ Assertions.assertFalse(
+ SparkApplicationStatusUtils.isDriverRunning(app, podInPhase(phase)),
phase);
+ }
+
+ // A sidecar keeps the pod running after the driver exited.
+ terminated.getStatus().setPhase("Running");
+ Assertions.assertFalse(SparkApplicationStatusUtils.isDriverRunning(app,
terminated));
+ }
+
@Test
public void testHasState() {
Assertions.assertFalse(SparkApplicationStatusUtils.hasState(app()));
@@ -843,6 +870,10 @@ public class TestSparkApplicationStatusUtils {
.build();
}
+ private static Pod podInPhase(String phase) {
+ return new
PodBuilder().withNewStatus().withPhase(phase).endStatus().build();
+ }
+
private static void cancel(GenericKubernetesResource app) {
app.getMetadata()
.setAnnotations(ImmutableMap.of(K8sJobResourceUtils.ANNOTATION_CANCEL_REQUESTED,
"true"));