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 361b1e6565 [#13613] feat(k8s-job-executor): Add the k8s job executor 
to run Spark jobs on Kubernetes (part 3/3) (#13700)
361b1e6565 is described below

commit 361b1e6565407b1f3b3202e8ba5dfe04f0a5e568
Author: Jerry Shao <[email protected]>
AuthorDate: Fri Oct 16 14:31:08 2026 +0800

    [#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 <[email protected]>
---
 .../apache/gravitino/job/k8s/K8sJobExecutor.java   |  618 ++++++++++
 .../gravitino/job/k8s/K8sJobExecutorConfigs.java   |   13 +-
 .../gravitino/job/k8s/K8sJobResourceUtils.java     |   23 +
 .../gravitino/job/k8s/NamespacedResourceCache.java |  183 +++
 .../org/apache/gravitino/job/k8s/PodLogUtils.java  |  121 ++
 .../job/k8s/spark/SparkApplicationStatusUtils.java |  105 +-
 .../job/k8s/spark/SparkApplicationUtils.java       |   11 +
 .../gravitino/job/k8s/TestK8sJobExecutor.java      | 1195 ++++++++++++++++++++
 .../gravitino/job/k8s/TestK8sJobResourceUtils.java |   30 +
 .../job/k8s/TestNamespacedResourceCache.java       |  248 ++++
 .../apache/gravitino/job/k8s/TestPodLogUtils.java  |  101 ++
 .../k8s/spark/TestSparkApplicationStatusUtils.java |   95 +-
 .../job/k8s/spark/TestSparkApplicationUtils.java   |   31 +-
 13 files changed, 2749 insertions(+), 25 deletions(-)

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
new file mode 100644
index 0000000000..1433f860bb
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutor.java
@@ -0,0 +1,618 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.job.k8s;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+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.OwnerReference;
+import io.fabric8.kubernetes.api.model.Pod;
+import io.fabric8.kubernetes.client.KubernetesClient;
+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 java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.time.Clock;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Predicate;
+import javax.annotation.Nullable;
+import org.apache.gravitino.connector.job.JobContext;
+import org.apache.gravitino.connector.job.JobExecutionInfo;
+import org.apache.gravitino.connector.job.JobExecutor;
+import org.apache.gravitino.exceptions.NoSuchJobException;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.job.JobTemplate;
+import org.apache.gravitino.job.SparkJobTemplate;
+import org.apache.gravitino.job.k8s.spark.SparkApplicationStatusUtils;
+import org.apache.gravitino.job.k8s.spark.SparkApplicationUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A job executor that runs jobs on Kubernetes. Spark jobs run as 
SparkApplication resources of <a
+ * 
href="https://github.com/apache/spark-kubernetes-operator";>apache/spark-kubernetes-operator</a>
+ * 0.9.x, shell jobs aren't supported yet.
+ *
+ * <p>All the job state lives in Kubernetes, so every Gravitino server can 
query and cancel any job.
+ * The job execution id is {@code <cluster>/<kind>/<namespace>/<name>}, see 
{@link
+ * K8sJobExecutionId}.
+ */
+public class K8sJobExecutor implements JobExecutor {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(K8sJobExecutor.class);
+
+  // Not defined by HttpURLConnection.
+  private static final int HTTP_UNPROCESSABLE_ENTITY = 422;
+
+  // The responses of the API server that reject a resource as it is, so 
creating it again can't
+  // succeed: bad request, forbidden, not found (the namespace or the 
SparkApplication CRD), and
+  // unprocessable entity.
+  private static final Set<Integer> REJECTED_REQUEST_CODES =
+      ImmutableSet.of(
+          HttpURLConnection.HTTP_BAD_REQUEST,
+          HttpURLConnection.HTTP_FORBIDDEN,
+          HttpURLConnection.HTTP_NOT_FOUND,
+          HTTP_UNPROCESSABLE_ENTITY);
+
+  // How long the status queries fail right away after the API server couldn't 
be reached.
+  // Gravitino queries the jobs one after another, and each query would 
otherwise wait for the
+  // connection to time out again.
+  @VisibleForTesting static final long UNREACHABLE_BACKOFF_MS = 10_000L;
+
+  // How many times a change of a SparkApplication is tried again after it 
changed meanwhile.
+  private static final int UPDATE_CONFLICT_RETRIES = 3;
+
+  private final Clock clock;
+
+  // The failure of the last status query that couldn't reach the API server, 
and when it failed.
+  // Reset once the backoff is over.
+  @Nullable private volatile KubernetesClientException unreachableFailure;
+
+  private volatile long unreachableAtMs;
+
+  private K8sJobExecutorConfigs configs;
+
+  private KubernetesClient client;
+
+  private NamespacedResourceCache<GenericKubernetesResource> 
sparkApplicationCache;
+
+  /** Creates the job executor, which Gravitino initializes with {@link 
#initialize(Map)}. */
+  public K8sJobExecutor() {
+    this(Clock.systemUTC());
+  }
+
+  @VisibleForTesting
+  K8sJobExecutor(Clock clock) {
+    this.clock = clock;
+  }
+
+  @Override
+  public void initialize(Map<String, String> configs) {
+    K8sJobExecutorConfigs parsed = new K8sJobExecutorConfigs(configs);
+    initialize(parsed, K8sClientUtils.createClient(parsed));
+  }
+
+  @VisibleForTesting
+  void initialize(K8sJobExecutorConfigs configs, KubernetesClient client) {
+    this.configs = configs;
+    this.client = client;
+    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.
+            e ->
+                e instanceof KubernetesClientException
+                    && isUnreachable((KubernetesClientException) e),
+            clock::millis);
+    LOG.info(
+        "Initialized the k8s job executor for cluster {} at {}, default 
namespace {}",
+        configs.cluster(),
+        client.getMasterUrl(),
+        configs.namespace());
+  }
+
+  @Override
+  public String submitJob(JobContext context, JobTemplate jobTemplate) {
+    if (!(jobTemplate instanceof SparkJobTemplate)) {
+      throw new IllegalArgumentException(
+          String.format(
+              "The k8s job executor only supports Spark jobs, but job template 
%s is a %s job",
+              jobTemplate.name(), jobTemplate.jobType()));
+    }
+
+    GenericKubernetesResource app =
+        SparkApplicationUtils.buildSparkApplication(
+            context, (SparkJobTemplate) jobTemplate, configs);
+    String namespace = app.getMetadata().getNamespace();
+    String name = app.getMetadata().getName();
+    K8sJobExecutionId jobExecutionId =
+        K8sJobExecutionId.of(
+            configs.cluster(), K8sJobExecutionId.Kind.SPARK_APPLICATION, 
namespace, name);
+    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.
+        deleteUnsubmittedSparkApplication(jobExecutionId, context.jobId());
+        throw e;
+      }
+      if (REJECTED_REQUEST_CODES.contains(e.getCode())) {
+        // The cluster rejects the job as it is, for example its namespace 
doesn't exist or isn't
+        // allowed, so the caller gets the reason rather than an internal 
error.
+        throw new IllegalArgumentException(
+            String.format(
+                "Kubernetes rejected the SparkApplication %s/%s of job 
template %s: %s",
+                namespace, name, jobTemplate.name(), e.getMessage()),
+            e);
+      }
+      if (e.getCode() != HttpURLConnection.HTTP_CONFLICT) {
+        throw e;
+      }
+      // Created by an earlier attempt to submit the same job. Never adopt a 
resource of another
+      // job, for example one left by another Gravitino deployment sharing the 
namespace, nor one
+      // that is being deleted.
+      GenericKubernetesResource existing =
+          getSparkApplicationClient(namespace).withName(name).get();
+      if (existing == null
+          || existing.getMetadata().getDeletionTimestamp() != null
+          || !K8sJobResourceUtils.isOfJob(existing, context.jobId())) {
+        throw new IllegalStateException(
+            String.format(
+                "SparkApplication %s/%s already exists and can't be used for 
job %s. If it belongs"
+                    + " to another Gravitino deployment sharing the namespace, 
use a different %s"
+                    + " for each of them",
+                namespace, name, context.jobId(), 
K8sJobExecutorConfigs.NAME_PREFIX),
+            e);
+      }
+    }
+
+    LOG.info("Submitted job {} as {}", context.jobId(), jobExecutionId);
+    return jobExecutionId.toString();
+  }
+
+  @Override
+  public JobExecutionInfo getJobExecutionInfo(String jobId) throws 
NoSuchJobException {
+    K8sJobExecutionId id = parseJobExecutionId(jobId);
+    checkApiServerReachable();
+    try {
+      return getSparkApplicationExecutionInfo(id, jobId);
+    } catch (KubernetesClientException e) {
+      if (isUnreachable(e)) {
+        unreachableAtMs = clock.millis();
+        unreachableFailure = e;
+      }
+      throw e;
+    }
+  }
+
+  @Override
+  public void cancelJob(String jobId) throws NoSuchJobException {
+    K8sJobExecutionId id = parseJobExecutionId(jobId);
+    // Mark the cancellation before deleting, so that a status query on any 
server tells a
+    // cancelled job from one deleted by someone else. A job that has stopped 
isn't marked, so
+    // that a finished job isn't reported as cancelled. The mark is only 
written if the job hasn't
+    // changed since it was checked, so a job that stops meanwhile is checked 
again.
+    GenericKubernetesResource app =
+        updateSparkApplication(
+                id,
+                current -> {
+                  if (SparkApplicationStatusUtils.isStopped(current)) {
+                    return false;
+                  }
+                  setCancelRequested(current, true);
+                  return true;
+                })
+            .orElseThrow(
+                () -> new NoSuchJobException("SparkApplication of job %s 
doesn't exist", jobId));
+    if (SparkApplicationStatusUtils.isStopped(app)) {
+      return;
+    }
+
+    try {
+      deleteSparkApplication(id);
+    } catch (KubernetesClientException e) {
+      // The deletion may have taken effect although its response was lost. 
Then the job is being
+      // cancelled, and keeps its mark.
+      AtomicBoolean deleting = new AtomicBoolean();
+      try {
+        Optional<GenericKubernetesResource> latest =
+            updateSparkApplication(
+                id,
+                current -> {
+                  deleting.set(current.getMetadata().getDeletionTimestamp() != 
null);
+                  if (deleting.get()) {
+                    return false;
+                  }
+                  // The job keeps running and the caller is told the 
cancellation failed, so
+                  // remove the mark, otherwise the job would be reported as 
cancelled if someone
+                  // else deleted it while it runs. A mark that can't be 
removed is harmless
+                  // otherwise: without a deletion, the job keeps the outcome 
it reaches.
+                  setCancelRequested(current, false);
+                  return true;
+                });
+        deleting.compareAndSet(false, !latest.isPresent());
+      } catch (RuntimeException removal) {
+        e.addSuppressed(removal);
+        throw e;
+      }
+      if (!deleting.get()) {
+        throw e;
+      }
+      LOG.warn("The deletion of the SparkApplication of job {} took effect 
anyway", jobId, e);
+    }
+    LOG.info("Requested to cancel job {}", jobId);
+  }
+
+  @Override
+  public List<String> getJobStdout(String jobId, int maxLines, int maxBytes) {
+    try {
+      K8sJobExecutionId id = parseJobExecutionId(jobId);
+      // The SparkApplication tells the driver pod apart from other pods with 
the same labels, and
+      // names the driver container. It may already be gone, the driver pod is 
retained longer.
+      GenericKubernetesResource app =
+          getSparkApplicationClient(id.namespace()).withName(id.name()).get();
+      if (app != null && !K8sJobResourceUtils.isJobResource(app)) {
+        // The name is used by a resource that isn't the job's, whose driver 
log isn't the job's.
+        return ImmutableList.of();
+      }
+      Optional<Pod> driver = getDriverPod(id, app);
+      if (!driver.isPresent()) {
+        return ImmutableList.of();
+      }
+
+      // Kubernetes can only limit the bytes from the head of the log, so read 
the last lines and
+      // keep their tail. So the lines bound the bytes read, and maxBytes only 
the bytes kept.
+      PodResource pod =
+          
client.pods().inNamespace(id.namespace()).withName(driver.get().getMetadata().getName());
+      // The container must be named if the driver pod has others, for example 
a sidecar.
+      List<Container> containers =
+          driver.get().getSpec() == null ? null : 
driver.get().getSpec().getContainers();
+      String driverContainer =
+          app == null
+              ? SparkApplicationUtils.DRIVER_CONTAINER_NAME
+              : SparkApplicationStatusUtils.getDriverContainerName(app);
+      boolean nameContainer = containers != null && containers.size() > 1;
+      try (InputStream log =
+          (nameContainer ? pod.inContainer(driverContainer) : pod)
+              .tailingLines(maxLines)
+              .getLogInputStream()) {
+        return PodLogUtils.readLastLines(log, maxLines, maxBytes);
+      }
+    } catch (KubernetesClientException e) {
+      if (e.getCode() == HttpURLConnection.HTTP_BAD_REQUEST) {
+        // For example, the driver container hasn't started yet.
+        LOG.debug("No driver log of job {} to read yet: {}", jobId, 
e.getMessage());
+      } else {
+        LOG.warn("Failed to read the driver log of job {}", jobId, e);
+      }
+      return ImmutableList.of();
+    } catch (RuntimeException | IOException e) {
+      // The output is best effort and must never fail the caller.
+      LOG.warn("Failed to read the driver log of job {}", jobId, e);
+      return ImmutableList.of();
+    }
+  }
+
+  /**
+   * Returns an empty list: Kubernetes merges the standard error into the 
standard output of a pod,
+   * so it is returned by {@link #getJobStdout(String, int, int)}.
+   */
+  @Override
+  public List<String> getJobStderr(String jobId, int maxLines, int maxBytes) {
+    return ImmutableList.of();
+  }
+
+  @Override
+  public void close() {
+    if (client != null) {
+      client.close();
+    }
+  }
+
+  /**
+   * Returns whether a request failed because the API server couldn't be 
reached or didn't answer,
+   * rather than because the API server refused or failed the request.
+   *
+   * @param e the failure of the request
+   * @return true if the API server gave no response
+   */
+  @VisibleForTesting
+  static boolean isUnreachable(KubernetesClientException e) {
+    if (e.getStatus() != null) {
+      return false;
+    }
+    boolean ioFailure = false;
+    for (Throwable cause = e.getCause(); cause != null; cause = 
cause.getCause()) {
+      // A response that isn't JSON, for example the login page of a proxy, 
did come back.
+      if (cause instanceof JsonProcessingException) {
+        return false;
+      }
+      ioFailure |= cause instanceof IOException;
+    }
+    return ioFailure;
+  }
+
+  /**
+   * 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.
+   */
+  private void checkApiServerReachable() {
+    KubernetesClientException failure = unreachableFailure;
+    if (failure == null) {
+      return;
+    }
+    long remainingMs = unreachableAtMs + UNREACHABLE_BACKOFF_MS - 
clock.millis();
+    if (remainingMs <= 0) {
+      unreachableFailure = null;
+      return;
+    }
+    throw new KubernetesClientException(
+        String.format(
+            "The API server of cluster %s couldn't be reached a moment ago, 
the status of its jobs"
+                + " isn't queried again for %s ms",
+            configs.cluster(), remainingMs),
+        failure);
+  }
+
+  private JobExecutionInfo getSparkApplicationExecutionInfo(K8sJobExecutionId 
id, String jobId) {
+    Optional<GenericKubernetesResource> cached =
+        sparkApplicationCache.find(id.namespace(), id.name());
+    GenericKubernetesResource app =
+        (cached.isPresent() ? cached : getSparkApplication(id))
+            .filter(K8sJobResourceUtils::isJobResource)
+            .orElseThrow(
+                () -> new NoSuchJobException("SparkApplication of job %s 
doesn't exist", jobId));
+    return getExecutionInfo(id, jobId, app);
+  }
+
+  private JobExecutionInfo getExecutionInfo(
+      K8sJobExecutionId id, String jobId, GenericKubernetesResource app) {
+    // The operator observes the driver finishing a while after it does, so a 
finished job takes
+    // its times from the driver pod, which is retained after the job 
finishes. So does a job whose
+    // SparkApplication is being deleted, for example a cancelled one, whose 
driver may have
+    // terminated before the operator recorded any outcome.
+    Pod driver = null;
+    KubernetesClientException driverFailure = null;
+    if (SparkApplicationStatusUtils.isStopped(app)
+        || app.getMetadata().getDeletionTimestamp() != null) {
+      try {
+        driver = getDriverPod(id, app).orElse(null);
+      } catch (KubernetesClientException e) {
+        driverFailure = e;
+      }
+    }
+    JobExecutionInfo info =
+        SparkApplicationStatusUtils.executionInfoOf(
+            app, driver, clock.instant(), configs.noStatusTimeoutMs());
+    if (info.status() == JobHandle.Status.FAILED
+        && !SparkApplicationStatusUtils.hasStatus(app)
+        && app.getMetadata().getDeletionTimestamp() == null) {
+      return getExecutionInfoWithoutStatus(id, jobId, info);
+    }
+    if (info.status() == JobHandle.Status.FAILED && driverFailure != null) {
+      // Whether the driver still runs can't be told, so the job isn't failed 
yet: a later query
+      // checks its driver again.
+      throw driverFailure;
+    }
+    if (driverFailure != null) {
+      LOG.warn("Failed to get the driver pod of job {}", jobId, driverFailure);
+    }
+    if (info.status() == JobHandle.Status.FAILED
+        && driver != null
+        && SparkApplicationStatusUtils.isDriverRunning(app, driver)) {
+      // The operator retains the driver pod of a stopped application, even 
one that is still
+      // pending or running after the application timed out starting. Delete 
it, otherwise it
+      // could still run the job after it failed. A failure to delete it fails 
this status query,
+      // so that the job only fails once the pod is deleted, by a later query.
+      LOG.warn(
+          "Deleting the driver pod {} of failed job {}, as it hasn't 
terminated",
+          driver.getMetadata().getName(),
+          jobId);
+      
client.pods().inNamespace(id.namespace()).withName(driver.getMetadata().getName()).delete();
+    }
+    return info;
+  }
+
+  /**
+   * Parses the job execution id of a job of the connected cluster. A job of 
another cluster isn't
+   * reported as missing, which would end it while it may still run, for 
example after the cluster
+   * is renamed: the query fails, and the job is left as it is.
+   */
+  private K8sJobExecutionId parseJobExecutionId(String jobId) throws 
NoSuchJobException {
+    K8sJobExecutionId id = K8sJobExecutionId.parse(jobId);
+    if (!configs.cluster().equals(id.cluster())) {
+      throw new IllegalStateException(
+          String.format(
+              "Job %s runs in cluster %s, but the k8s job executor is 
connected to cluster %s",
+              jobId, id.cluster(), configs.cluster()));
+    }
+    return id;
+  }
+
+  /**
+   * Returns the execution info of a job whose SparkApplication got no status 
from the operator in
+   * time, which is failed. The SparkApplication is deleted first, otherwise 
the operator would
+   * still run it once it comes back, with the job already failed and out of 
Gravitino's reach. A
+   * failure to delete it fails the status query, so that the job only fails 
once it is deleted, by
+   * a later query.
+   */
+  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.
+    GenericKubernetesResource latest =
+        getSparkApplication(id)
+            .orElseThrow(
+                () -> new NoSuchJobException("SparkApplication of job %s 
doesn't exist", jobId));
+    if (SparkApplicationStatusUtils.hasStatus(latest)
+        || latest.getMetadata().getDeletionTimestamp() != null) {
+      sparkApplicationCache.invalidate(id.namespace());
+      return getExecutionInfo(id, jobId, latest);
+    }
+    deleteSparkApplication(id);
+    return failed;
+  }
+
+  /**
+   * Deletes the SparkApplication of a job whose submission failed without 
telling whether it was
+   * created, if it was. Only a SparkApplication of this job is deleted, and 
any failure is ignored:
+   * the submission fails anyway.
+   */
+  private void deleteUnsubmittedSparkApplication(K8sJobExecutionId id, long 
jobId) {
+    try {
+      Optional<GenericKubernetesResource> app = getSparkApplication(id);
+      if (app.isPresent() && K8sJobResourceUtils.isOfJob(app.get(), jobId)) {
+        deleteSparkApplication(id);
+        LOG.info("Deleted the SparkApplication of job {}, whose submission 
failed", jobId);
+      }
+    } catch (RuntimeException e) {
+      LOG.warn(
+          "Failed to clean up the SparkApplication {} of job {}, whose 
submission failed",
+          id,
+          jobId,
+          e);
+    }
+  }
+
+  /**
+   * Returns the SparkApplication of a job, if it exists. A resource with its 
name that isn't a job
+   * resource of Gravitino, for example one created by another system, isn't.
+   */
+  private Optional<GenericKubernetesResource> 
getSparkApplication(K8sJobExecutionId id) {
+    return 
Optional.ofNullable(getSparkApplicationClient(id.namespace()).withName(id.name()).get())
+        .filter(K8sJobResourceUtils::isJobResource);
+  }
+
+  /**
+   * Changes the SparkApplication of a job, only if it hasn't changed since it 
was read, and reads
+   * it again to decide the change again otherwise.
+   *
+   * @param id the job execution id
+   * @param change changes the SparkApplication in place, and returns whether 
it changed it
+   * @return the SparkApplication the change was decided on, or empty if it 
doesn't exist
+   */
+  private Optional<GenericKubernetesResource> updateSparkApplication(
+      K8sJobExecutionId id, Predicate<GenericKubernetesResource> change) {
+    for (int attempt = 0; ; attempt++) {
+      Optional<GenericKubernetesResource> app = getSparkApplication(id);
+      if (!app.isPresent() || !change.test(app.get())) {
+        return app;
+      }
+      try {
+        // An update carries the resource version that was read, so it fails 
if the
+        // SparkApplication has changed since.
+        getSparkApplicationClient(id.namespace()).resource(app.get()).update();
+        sparkApplicationCache.invalidate(id.namespace());
+        return app;
+      } catch (KubernetesClientException e) {
+        if (e.getCode() == HttpURLConnection.HTTP_NOT_FOUND) {
+          return Optional.empty();
+        }
+        if (e.getCode() != HttpURLConnection.HTTP_CONFLICT || attempt >= 
UPDATE_CONFLICT_RETRIES) {
+          throw e;
+        }
+      }
+    }
+  }
+
+  /**
+   * 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
+   * also by its owner, the SparkApplication, when that is known.
+   */
+  private Optional<Pod> getDriverPod(
+      K8sJobExecutionId id, @Nullable GenericKubernetesResource app) {
+    String appUid = app == null ? null : app.getMetadata().getUid();
+    return client
+        .pods()
+        .inNamespace(id.namespace())
+        .withLabel(SparkApplicationUtils.LABEL_SPARK_APP_NAME, id.name())
+        .withLabel(SparkApplicationUtils.LABEL_SPARK_ROLE, 
SparkApplicationUtils.SPARK_ROLE_DRIVER)
+        .list()
+        .getItems()
+        .stream()
+        .filter(pod -> appUid == null || isOwnedBy(pod, appUid))
+        .max(
+            Comparator.comparing(
+                pod -> pod.getMetadata().getCreationTimestamp(),
+                Comparator.nullsFirst(Comparator.naturalOrder())));
+  }
+
+  private static boolean isOwnedBy(Pod pod, String uid) {
+    List<OwnerReference> owners = pod.getMetadata().getOwnerReferences();
+    return owners != null && owners.stream().anyMatch(owner -> 
uid.equals(owner.getUid()));
+  }
+
+  private static void setCancelRequested(GenericKubernetesResource app, 
boolean cancelRequested) {
+    Map<String, String> annotations = new HashMap<>();
+    if (app.getMetadata().getAnnotations() != null) {
+      annotations.putAll(app.getMetadata().getAnnotations());
+    }
+    if (cancelRequested) {
+      annotations.put(K8sJobResourceUtils.ANNOTATION_CANCEL_REQUESTED, "true");
+    } else {
+      annotations.remove(K8sJobResourceUtils.ANNOTATION_CANCEL_REQUESTED);
+    }
+    app.getMetadata().setAnnotations(annotations);
+  }
+
+  private void deleteSparkApplication(K8sJobExecutionId id) {
+    // The SparkApplication stays until its pods are deleted, so that a status 
query sees it being
+    // deleted rather than gone.
+    getSparkApplicationClient(id.namespace())
+        .withName(id.name())
+        .withPropagationPolicy(DeletionPropagation.FOREGROUND)
+        .delete();
+    sparkApplicationCache.invalidate(id.namespace());
+  }
+
+  private NonNamespaceOperation<
+          GenericKubernetesResource,
+          GenericKubernetesResourceList,
+          Resource<GenericKubernetesResource>>
+      getSparkApplicationClient(String namespace) {
+    return client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace(namespace);
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutorConfigs.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutorConfigs.java
index 306322a771..b4b8eea85d 100644
--- 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutorConfigs.java
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobExecutorConfigs.java
@@ -35,7 +35,12 @@ public class K8sJobExecutorConfigs {
   /** The name of the job executor. */
   public static final String K8S_JOB_EXECUTOR_NAME = "k8s";
 
-  /** The name of the Kubernetes cluster the jobs run in, which is part of the 
job execution id. */
+  /**
+   * The name of the Kubernetes cluster the jobs run in, which is part of the 
job execution id. It
+   * must be the same on every Gravitino server, and must not be changed while 
jobs run: the jobs
+   * submitted under another name can't be queried nor cancelled, and stay 
unfinished until it is
+   * changed back.
+   */
   public static final String CLUSTER = "cluster";
 
   /** The default cluster name. */
@@ -105,7 +110,11 @@ public class K8sJobExecutorConfigs {
   /** The default status cache TTL, 5 seconds. */
   public static final long DEFAULT_STATUS_CACHE_TTL_MS = 5_000L;
 
-  /** A job whose resource still has no status after this time is considered 
failed. */
+  /**
+   * A job whose resource still has no status after this time is considered 
failed, and its resource
+   * is deleted. A job whose resource has a status without any state this 
version can read, for
+   * example of a newer operator, stays unfinished instead, until the operator 
deletes the resource.
+   */
   public static final String NO_STATUS_TIMEOUT_MS = "noStatusTimeoutInMs";
 
   /** The default no status timeout, 10 minutes. */
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobResourceUtils.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobResourceUtils.java
index a0ffb490e8..b9a3a3b4eb 100644
--- 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobResourceUtils.java
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/K8sJobResourceUtils.java
@@ -20,8 +20,10 @@ package org.apache.gravitino.job.k8s;
 
 import com.google.common.collect.ImmutableMap;
 import io.fabric8.kubernetes.api.model.HasMetadata;
+import io.fabric8.kubernetes.api.model.ObjectMeta;
 import java.util.Map;
 import java.util.regex.Pattern;
+import org.apache.commons.lang3.StringUtils;
 
 /** The naming and labeling of the Kubernetes resources created for the jobs. 
*/
 public final class K8sJobResourceUtils {
@@ -92,6 +94,27 @@ public final class K8sJobResourceUtils {
     return labels != null && 
String.valueOf(jobId).equals(labels.get(LABEL_JOB_ID));
   }
 
+  /**
+   * Returns whether the Kubernetes resource is the one Gravitino created for 
the job its name was
+   * made for: it is managed by Gravitino, and its job id label is the job id 
its name ends with.
+   * The name alone may be used by another system, or by another Gravitino 
deployment with the same
+   * name prefix in the namespace, for another of its jobs.
+   *
+   * @param resource the resource
+   * @return true if the resource is a job resource of Gravitino matching its 
name
+   */
+  public static boolean isJobResource(HasMetadata resource) {
+    ObjectMeta metadata = resource.getMetadata();
+    Map<String, String> labels = metadata == null ? null : 
metadata.getLabels();
+    if (labels == null || 
!MANAGED_BY_GRAVITINO.equals(labels.get(LABEL_MANAGED_BY))) {
+      return false;
+    }
+    String jobId = labels.get(LABEL_JOB_ID);
+    return StringUtils.isNotBlank(jobId)
+        && metadata.getName() != null
+        && metadata.getName().endsWith("-" + jobId);
+  }
+
   /**
    * Returns whether Gravitino requested to cancel the job of the Kubernetes 
resource.
    *
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
new file mode 100644
index 0000000000..59001e9638
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/NamespacedResourceCache.java
@@ -0,0 +1,183 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.job.k8s;
+
+import com.google.common.collect.ImmutableMap;
+import io.fabric8.kubernetes.api.model.HasMetadata;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.Function;
+import java.util.function.LongSupplier;
+import java.util.function.Predicate;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Caches the resources listed in a namespace for a short time, so that 
querying the status of many
+ * jobs in the same namespace costs one LIST call instead of one GET call per 
job.
+ *
+ * <p>A resource missing from the cache isn't necessarily gone: it may have 
been created after the
+ * 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.
+ *
+ * @param <T> the resource type
+ */
+final class NamespacedResourceCache<T extends HasMetadata> {
+
+  private static final class Snapshot<T> {
+
+    private final long listedAtMs;
+
+    private final Map<String, T> resources;
+
+    private Snapshot(long listedAtMs, Map<String, T> resources) {
+      this.listedAtMs = listedAtMs;
+      this.resources = resources;
+    }
+  }
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(NamespacedResourceCache.class);
+
+  private final long ttlMs;
+
+  private final Function<String, List<T>> lister;
+
+  private final Predicate<RuntimeException> isFatal;
+
+  private final LongSupplier clock;
+
+  // The listing of each namespace, being made or made. Only one thread lists 
a namespace at a
+  // time, the others wait for its listing. A listing is a network call, so it 
is made outside of
+  // the map, which only holds the namespaces listed within the TTL or being 
listed.
+  private final Map<String, CompletableFuture<Snapshot<T>>> snapshots = new 
ConcurrentHashMap<>();
+
+  /**
+   * Creates a cache.
+   *
+   * @param ttlMs how long a namespace listing is cached, caching is disabled 
if not positive
+   * @param lister lists the resources of a namespace
+   * @param isFatal tells the failures of the lister that getting a resource 
by itself would run
+   *     into as well, for example an API server that can't be reached. They 
are thrown to the
+   *     caller of {@link #find(String, String)} instead of being cached as an 
empty listing.
+   * @param clock the current time in milliseconds
+   */
+  NamespacedResourceCache(
+      long ttlMs,
+      Function<String, List<T>> lister,
+      Predicate<RuntimeException> isFatal,
+      LongSupplier clock) {
+    this.ttlMs = ttlMs;
+    this.lister = lister;
+    this.isFatal = isFatal;
+    this.clock = clock;
+  }
+
+  /**
+   * Finds a resource in the cached listing of its namespace, and lists the 
namespace again if the
+   * cached listing has expired. A namespace that can't be listed, for example 
because listing it is
+   * forbidden, isn't listed again until the cached failure expires.
+   *
+   * @param namespace the namespace of the resource
+   * @param name the name of the resource
+   * @return the resource, or empty if it isn't in the listing or the 
namespace can't be listed
+   * @throws RuntimeException the failure to list the namespace, if it is a 
fatal one
+   */
+  Optional<T> find(String namespace, String name) {
+    if (ttlMs <= 0) {
+      return Optional.empty();
+    }
+
+    CompletableFuture<Snapshot<T>> snapshot = snapshots.get(namespace);
+    if (isExpired(snapshot)) {
+      // Drop the expired listings, also of the namespaces whose jobs are all 
done.
+      snapshots.values().removeIf(this::isExpired);
+      CompletableFuture<Snapshot<T>> listing = new CompletableFuture<>();
+      snapshot =
+          snapshots.compute(namespace, (ns, current) -> isExpired(current) ? 
listing : current);
+      if (snapshot == listing) {
+        try {
+          listing.complete(snapshotOf(namespace));
+        } catch (Throwable t) {
+          // Whatever the failure, even an error, the listing must be 
completed and isn't cached,
+          // otherwise the next queries of the namespace would wait for it 
forever. The waiting
+          // callers get the failure too.
+          snapshots.remove(namespace, listing);
+          listing.completeExceptionally(t);
+          throw t;
+        }
+      }
+    }
+
+    try {
+      return Optional.ofNullable(snapshot.join().resources.get(name));
+    } catch (CompletionException e) {
+      if (e.getCause() instanceof Error) {
+        throw (Error) e.getCause();
+      }
+      throw e.getCause() instanceof RuntimeException ? (RuntimeException) 
e.getCause() : e;
+    }
+  }
+
+  /**
+   * Drops the cached listing of a namespace, after a resource in it is 
changed. A listing of it
+   * being made isn't cached either, as it may predate the change.
+   *
+   * @param namespace the namespace
+   */
+  void invalidate(String namespace) {
+    snapshots.remove(namespace);
+  }
+
+  /** 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()) {
+      return true;
+    }
+    // A listing being made is waited for.
+    Snapshot<T> made = snapshot.getNow(null);
+    return made != null && clock.getAsLong() - made.listedAtMs >= ttlMs;
+  }
+
+  private Snapshot<T> snapshotOf(String namespace) {
+    long listedAtMs = clock.getAsLong();
+    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());
+    } 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.
+      LOG.warn(
+          "Failed to list the resources in namespace {}, getting them one by 
one for the next {}"
+              + " ms: {}",
+          namespace,
+          ttlMs,
+          e.toString());
+      return new Snapshot<>(listedAtMs, ImmutableMap.of());
+    }
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/PodLogUtils.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/PodLogUtils.java
new file mode 100644
index 0000000000..578f116880
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/PodLogUtils.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.job.k8s;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableList;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+
+/** Reads the output of a job from the log of its pod. */
+public final class PodLogUtils {
+
+  private static final int READ_BUFFER_SIZE = 8192;
+
+  // The largest array size that is safe to allocate on every JVM.
+  private static final int MAX_BUFFER_SIZE = Integer.MAX_VALUE - 8;
+
+  private PodLogUtils() {}
+
+  /**
+   * Reads the last lines of a log stream, within its last bytes. Only the 
last bytes are kept in
+   * memory while reading. If the kept bytes start in the middle of a line, 
that partial line is
+   * dropped, unless it is the only line.
+   *
+   * <p>The whole stream is read: {@code maxBytes} bounds the memory and the 
lines returned, not the
+   * bytes read. The caller bounds those, for example by asking Kubernetes for 
the last lines of a
+   * log only.
+   *
+   * @param log the log stream, which is read to its end
+   * @param maxLines the maximum number of lines to return
+   * @param maxBytes the maximum number of bytes to read from the tail of the 
log
+   * @return the last lines of the log
+   * @throws IOException if the log can't be read
+   */
+  public static List<String> readLastLines(InputStream log, int maxLines, int 
maxBytes)
+      throws IOException {
+    Preconditions.checkArgument(maxLines > 0, "maxLines must be positive");
+    Preconditions.checkArgument(maxBytes > 0, "maxBytes must be positive");
+
+    // Keep one more byte than the window, to tell whether the window starts 
at a line boundary.
+    // The window is capped so that the buffer, of twice its size, fits in an 
array.
+    int keep = (int) Math.min(maxBytes + 1L, MAX_BUFFER_SIZE / 2);
+    int window = keep - 1;
+    int maxCapacity = Math.max(2 * keep, READ_BUFFER_SIZE);
+    // The buffer grows with the log, as most logs are much shorter than the 
window.
+    byte[] tail = new byte[READ_BUFFER_SIZE];
+    int length = 0;
+    byte[] chunk = new byte[READ_BUFFER_SIZE];
+    int read;
+    while ((read = log.read(chunk)) >= 0) {
+      if (length + read > tail.length && tail.length < maxCapacity) {
+        long capacity = Math.max(2L * tail.length, (long) length + read);
+        tail = Arrays.copyOf(tail, (int) Math.min(capacity, maxCapacity));
+      }
+      if (length + read > tail.length) {
+        // Drop the head, keeping the last bytes of the buffer and the chunk.
+        int kept = Math.max(keep - read, 0);
+        System.arraycopy(tail, length - kept, tail, 0, kept);
+        length = kept;
+        if (read > keep) {
+          System.arraycopy(chunk, read - keep, tail, 0, keep);
+          length = keep;
+          continue;
+        }
+      }
+      System.arraycopy(chunk, 0, tail, length, read);
+      length += read;
+    }
+
+    int start = 0;
+    boolean startsAtLineBoundary = true;
+    if (length > window) {
+      start = length - window;
+      startsAtLineBoundary = tail[start - 1] == '\n';
+    }
+    // Never start in the middle of a UTF-8 character. A '\n' is never part of 
one.
+    while (start < length && (tail[start] & 0xC0) == 0x80) {
+      start++;
+    }
+    String content = new String(tail, start, length - start, 
StandardCharsets.UTF_8);
+    if (!startsAtLineBoundary) {
+      int firstNewline = content.indexOf('\n');
+      if (firstNewline >= 0 && firstNewline < content.length() - 1) {
+        content = content.substring(firstNewline + 1);
+      }
+    }
+    if (content.isEmpty()) {
+      return ImmutableList.of();
+    }
+
+    List<String> lines = Arrays.asList(content.split("\\r?\\n", -1));
+    // A trailing line terminator doesn't start another line.
+    if (lines.get(lines.size() - 1).isEmpty()) {
+      lines = lines.subList(0, lines.size() - 1);
+    }
+    // The window only holds the line terminator of a line that starts before 
it.
+    if (!startsAtLineBoundary && lines.size() == 1 && lines.get(0).isEmpty()) {
+      return ImmutableList.of();
+    }
+    return ImmutableList.copyOf(lines.subList(Math.max(0, lines.size() - 
maxLines), lines.size()));
+  }
+}
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 90c17a664e..f78a498137 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
@@ -115,12 +115,21 @@ public final class SparkApplicationStatusUtils {
    *
    * <ul>
    *   <li>A SparkApplication that Gravitino requested to cancel is cancelled 
once it is being
-   *       deleted or has stopped.
+   *       deleted, unless it had stopped before, as the operator recorded it: 
by an outcome or a
+   *       stopped state recorded before the deletion, or whose time is 
unknown. A driver that
+   *       terminated before the deletion doesn't count, as the operator may 
never record its
+   *       outcome once the application is deleted. The cancel mark alone 
doesn't cancel it: a
+   *       cancellation that failed after marking it, for example because the 
server stopped or a
+   *       response was lost, leaves the mark on a job that keeps running, 
which then keeps the
+   *       outcome it reaches.
    *   <li>A SparkApplication being deleted by someone else, for example by 
its TTL, keeps the
    *       outcome it reached, or fails if it has none.
    *   <li>The stopped states walk back through the state transition history 
to the last outcome.
    *   <li>A SparkApplication without any status after the timeout fails, for 
example if its
-   *       namespace isn't watched by the operator.
+   *       namespace isn't watched by the operator. One with a status that has 
no state this version
+   *       can read stays queued, even if it was started before, and nothing 
ends it until the
+   *       operator deletes it after its TTL. It means the operator is newer 
than this version
+   *       supports, which must be fixed rather than waited out.
    *   <li>An unknown state, of a newer operator, after an outcome is a 
stopped state too. Before
    *       any outcome it keeps the job unfinished: started if the application 
has reached a started
    *       state, queued otherwise.
@@ -133,8 +142,8 @@ public final class SparkApplicationStatusUtils {
    * reached a started state, and finished when it reached its outcome. As the 
driver container
    * status is only known once the job finished, the started time of a running 
job is refined to the
    * driver's when it finishes. A job that stopped without an outcome finished 
when its driver
-   * container terminated if that is known, else when it was deleted, or else 
when it entered its
-   * last state.
+   * container terminated if that is known, else when it entered its stopped 
state if that is known,
+   * or else when it was deleted.
    *
    * @param app the SparkApplication
    * @param driver the driver pod, or null if unknown
@@ -151,7 +160,7 @@ public final class SparkApplicationStatusUtils {
     Instant deletedAt = parseTime(app.getMetadata().getDeletionTimestamp());
 
     ContainerState driverState =
-        driver == null ? null : driverContainerState(driver, 
driverContainerName(app));
+        driver == null ? null : driverContainerState(driver, 
getDriverContainerName(app));
     Instant driverStartedAt = null;
     Instant driverFinishedAt = null;
     if (driverState != null && driverState.getTerminated() != null) {
@@ -163,24 +172,33 @@ public final class SparkApplicationStatusUtils {
     Instant startedAt = startedAt(history, driverStartedAt, createdAt);
     // When the application stopped: when the driver container terminated if 
that is known, even
     // before the operator records an outcome, else when the application 
reached its outcome, else
-    // when it was deleted, else when it entered its current state. A deletion 
time is in whole
-    // seconds, so it may read earlier than a started time of the same second, 
which Gravitino
-    // would then drop.
+    // when it was deleted if it hadn't stopped by then, else when it entered 
its current state. A
+    // deletion time is in whole seconds, so it may read earlier than a 
started time of the same
+    // second, which Gravitino would then drop.
+    Instant deletedOrStartedAt =
+        deletedAt != null && startedAt != null && deletedAt.isBefore(startedAt)
+            ? startedAt
+            : deletedAt;
     Instant stoppedAt;
     if (driverFinishedAt != null) {
       stoppedAt = driverFinishedAt;
     } else if (outcome != null) {
       stoppedAt = outcome.driverFinishedAt != null ? outcome.driverFinishedAt 
: outcome.time;
-    } else if (deletedAt != null) {
-      stoppedAt = startedAt != null && deletedAt.isBefore(startedAt) ? 
startedAt : deletedAt;
+    } else if (deletedAt != null && !isStopped(current, outcome)) {
+      stoppedAt = deletedOrStartedAt;
     } else {
       stoppedAt = current == null ? null : current.time;
     }
+    if (stoppedAt == null) {
+      // The time of the state it stopped in is unknown, the deletion is the 
best known one.
+      stoppedAt = deletedOrStartedAt;
+    }
 
     JobHandle.Status status;
     Instant finishedAt = null;
     if (K8sJobResourceUtils.isCancelRequested(app)
-        && (deletedAt != null || isStopped(current, outcome))) {
+        && deletedAt != null
+        && !stoppedBefore(history, deletedAt)) {
       status = JobHandle.Status.CANCELLED;
       finishedAt = stoppedAt;
     } else if (deletedAt != null || isStopped(current, outcome)) {
@@ -188,7 +206,15 @@ public final class SparkApplicationStatusUtils {
       status = outcome == null ? JobHandle.Status.FAILED : statusOf(outcome);
       finishedAt = stoppedAt;
     } else if (current == null) {
-      if (createdAt != null && 
createdAt.plusMillis(noStatusTimeoutMs).isBefore(now)) {
+      if (hasStatus(app)) {
+        // The operator wrote a status this version can't read, for example a 
newer operator. The
+        // application is known to the operator, so the job stays unfinished 
rather than failing.
+        status = JobHandle.Status.QUEUED;
+        LOG.warn(
+            "Can't read the status of SparkApplication {}, considering it as 
{}",
+            nameOf(app),
+            status);
+      } else if (createdAt != null && 
createdAt.plusMillis(noStatusTimeoutMs).isBefore(now)) {
         LOG.warn(
             "SparkApplication {} has no status after {} ms, is its namespace 
watched by the Spark"
                 + " operator? Considering it as failed",
@@ -224,6 +250,36 @@ public final class SparkApplicationStatusUtils {
         .build();
   }
 
+  /**
+   * 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.
+   *
+   * @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);
+  }
+
+  /**
+   * Returns whether the operator has written a status of the 
SparkApplication, whether or not this
+   * version can read a state from it.
+   *
+   * @param app the SparkApplication
+   * @return true if the application has a non-empty status
+   */
+  public static boolean hasStatus(GenericKubernetesResource app) {
+    Object status = app.getAdditionalProperties().get("status");
+    return status instanceof Map && !((Map<?, ?>) status).isEmpty();
+  }
+
   /**
    * Returns whether the operator has reported a state of the SparkApplication 
that can be read.
    *
@@ -262,6 +318,20 @@ public final class SparkApplicationStatusUtils {
         || (!running && outcome != null);
   }
 
+  /**
+   * Returns whether the application had reached an outcome or a stopped state 
before it was
+   * deleted. A deletion time is in whole seconds, so a state of the same 
second counts as after. A
+   * state whose time is unknown counts as before, like {@link 
#isStopped(State, State)}, which
+   * doesn't need the times either.
+   */
+  private static boolean stoppedBefore(List<State> history, Instant deletedAt) 
{
+    return history.stream()
+        .anyMatch(
+            s ->
+                (isOutcome(s) || STOPPED_STATES.contains(s.summary))
+                    && (s.time == null || s.time.isBefore(deletedAt)));
+  }
+
   private static boolean isOutcome(State state) {
     return SUCCEEDED_STATE.equals(state.summary) || 
FAILED_STATES.contains(state.summary);
   }
@@ -321,7 +391,7 @@ public final class SparkApplicationStatusUtils {
     if (!(status instanceof Map)) {
       return history;
     }
-    String driverContainer = driverContainerName(app);
+    String driverContainer = getDriverContainerName(app);
 
     Object transitions = ((Map<String, Object>) 
status).get("stateTransitionHistory");
     if (transitions instanceof Map) {
@@ -392,10 +462,13 @@ public final class SparkApplicationStatusUtils {
   }
 
   /**
-   * Returns the name of the driver container: the one a job names with a pod 
template, or else the
-   * one Spark gives it.
+   * Returns the name of the driver container of a SparkApplication: the one 
the job names for its
+   * driver pod template, or else the one Spark gives it.
+   *
+   * @param app the SparkApplication
+   * @return the name of the driver container
    */
-  private static String driverContainerName(GenericKubernetesResource app) {
+  public static String getDriverContainerName(GenericKubernetesResource app) {
     Map<String, Object> spec = 
mapOf(app.getAdditionalProperties().get("spec"));
     Map<String, Object> sparkConf = spec == null ? null : 
mapOf(spec.get("sparkConf"));
     Object name =
diff --git 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
index aa49a1c5fb..73ea8d6c9c 100644
--- 
a/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
+++ 
b/plugins/k8s-job-executor/src/main/java/org/apache/gravitino/job/k8s/spark/SparkApplicationUtils.java
@@ -75,6 +75,7 @@ public final class SparkApplicationUtils {
 
   private static final String SPARK_MASTER = "spark.master";
   private static final String SPARK_DEPLOY_MODE = "spark.submit.deployMode";
+  private static final String CLUSTER_DEPLOY_MODE = "cluster";
   private static final String SPARK_IMAGE = "spark.kubernetes.container.image";
   private static final String SPARK_DRIVER_SERVICE_ACCOUNT =
       "spark.kubernetes.authenticate.driver.serviceAccountName";
@@ -117,6 +118,16 @@ public final class SparkApplicationUtils {
 
     Map<String, String> sparkConf = new TreeMap<>(configs.sparkConf());
     sparkConf.putAll(template.configs());
+    // The operator only supports cluster mode, so a job that asks for client 
mode is rejected
+    // rather than silently run in cluster mode.
+    String deployMode = 
StringUtils.trimToNull(sparkConf.get(SPARK_DEPLOY_MODE));
+    if (deployMode != null && 
!CLUSTER_DEPLOY_MODE.equalsIgnoreCase(deployMode)) {
+      throw new IllegalArgumentException(
+          String.format(
+              "%s is set to %s by job template %s or by the k8s job executor 
configurations, but"
+                  + " the k8s job executor only runs Spark jobs in %s mode",
+              SPARK_DEPLOY_MODE, deployMode, template.name(), 
CLUSTER_DEPLOY_MODE));
+    }
     // The operator decides the master and the deploy mode.
     sparkConf.remove(SPARK_MASTER);
     sparkConf.remove(SPARK_DEPLOY_MODE);
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
new file mode 100644
index 0000000000..dd5bd4125c
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobExecutor.java
@@ -0,0 +1,1195 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.job.k8s;
+
+import com.fasterxml.jackson.core.JsonParseException;
+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.OwnerReference;
+import io.fabric8.kubernetes.api.model.OwnerReferenceBuilder;
+import io.fabric8.kubernetes.api.model.PodBuilder;
+import io.fabric8.kubernetes.api.model.StatusBuilder;
+import io.fabric8.kubernetes.client.Config;
+import io.fabric8.kubernetes.client.ConfigBuilder;
+import io.fabric8.kubernetes.client.KubernetesClient;
+import io.fabric8.kubernetes.client.KubernetesClientBuilder;
+import io.fabric8.kubernetes.client.KubernetesClientException;
+import io.fabric8.kubernetes.client.dsl.base.CustomResourceDefinitionContext;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMixedDispatcher;
+import io.fabric8.kubernetes.client.server.mock.KubernetesMockServer;
+import io.fabric8.mockwebserver.Context;
+import io.fabric8.mockwebserver.MockWebServer;
+import io.fabric8.mockwebserver.ServerRequest;
+import io.fabric8.mockwebserver.ServerResponse;
+import io.fabric8.mockwebserver.http.RecordedRequest;
+import java.io.File;
+import java.io.IOException;
+import java.net.ConnectException;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+import org.apache.gravitino.connector.job.JobContext;
+import org.apache.gravitino.connector.job.JobExecutionInfo;
+import org.apache.gravitino.exceptions.NoSuchJobException;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.job.ShellJobTemplate;
+import org.apache.gravitino.job.SparkJobTemplate;
+import org.apache.gravitino.job.k8s.spark.SparkApplicationUtils;
+import org.apache.gravitino.job.k8s.spark.TestSparkApplicationUtils;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public class TestK8sJobExecutor {
+
+  private static final String APPS_PATH =
+      "/apis/spark.apache.org/v1/namespaces/default/sparkapplications";
+
+  private KubernetesMockServer server;
+
+  private KubernetesClient client;
+
+  private K8sJobExecutor executor;
+
+  private final AtomicReference<Instant> now = new 
AtomicReference<>(Instant.now());
+
+  @BeforeEach
+  public void setUp() {
+    Map<ServerRequest, Queue<ServerResponse>> responses = new HashMap<>();
+    server =
+        new KubernetesMockServer(
+            new Context(),
+            new MockWebServer(),
+            responses,
+            new KubernetesMixedDispatcher(responses),
+            false);
+    server.init();
+    server.expectCustomResource(
+        new CustomResourceDefinitionContext.Builder()
+            .withGroup(SparkApplicationUtils.SPARK_APPLICATION.getGroup())
+            .withVersion(SparkApplicationUtils.SPARK_APPLICATION.getVersion())
+            .withKind(SparkApplicationUtils.SPARK_APPLICATION.getKind())
+            .withPlural(SparkApplicationUtils.SPARK_APPLICATION.getPlural())
+            .withScope("Namespaced")
+            .build());
+    client = server.createClient();
+
+    executor =
+        new K8sJobExecutor(
+            new Clock() {
+              @Override
+              public ZoneId getZone() {
+                return ZoneOffset.UTC;
+              }
+
+              @Override
+              public Clock withZone(ZoneId zone) {
+                return this;
+              }
+
+              @Override
+              public Instant instant() {
+                return now.get();
+              }
+            });
+    executor.initialize(
+        TestSparkApplicationUtils.newConfigs(
+            ImmutableMap.of(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS, "0")),
+        client);
+  }
+
+  @AfterEach
+  public void tearDown() {
+    executor.close();
+    server.destroy();
+  }
+
+  @Test
+  public void testSubmitAndQuery() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+
+    Assertions.assertEquals("default/sparkapp/default/gravitino-job-1", 
jobExecutionId);
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    Assertions.assertNotNull(app);
+    Assertions.assertEquals(
+        "1", 
app.getMetadata().getLabels().get(K8sJobResourceUtils.LABEL_JOB_ID));
+    Assertions.assertEquals(
+        JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+
+    setStates("gravitino-job-1", "Submitted", "DriverReady", "RunningHealthy");
+    JobExecutionInfo running = executor.getJobExecutionInfo(jobExecutionId);
+    Assertions.assertEquals(JobHandle.Status.STARTED, running.status());
+    Assertions.assertNotNull(running.startedAt());
+
+    setStates("gravitino-job-1", "Submitted", "DriverReady", "Succeeded", 
"ResourceReleased");
+    Assertions.assertEquals(
+        JobHandle.Status.SUCCEEDED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+
+    // The times of a finished job come from its driver pod. The driver can't 
start before its
+    // SparkApplication was created, which the mock server does at the current 
time.
+    Instant driverStartedAt = 
Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.SECONDS);
+    Instant driverFinishedAt = driverStartedAt.plusSeconds(1);
+    client
+        .pods()
+        .inNamespace("default")
+        .resource(
+            new PodBuilder()
+                .withNewMetadata()
+                .addToOwnerReferences(ownerReference("gravitino-job-1"))
+                .withName("driver")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_APP_NAME, 
"gravitino-job-1")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_ROLE, "driver")
+                .endMetadata()
+                .withNewStatus()
+                .addNewContainerStatus()
+                .withName("spark-kubernetes-driver")
+                .withNewState()
+                .withNewTerminated()
+                .withStartedAt(driverStartedAt.toString())
+                .withFinishedAt(driverFinishedAt.toString())
+                .endTerminated()
+                .endState()
+                .endContainerStatus()
+                .endStatus()
+                .build())
+        .create();
+    JobExecutionInfo succeeded = executor.getJobExecutionInfo(jobExecutionId);
+    Assertions.assertEquals(driverStartedAt, succeeded.startedAt());
+    Assertions.assertEquals(driverFinishedAt, succeeded.finishedAt());
+  }
+
+  @Test
+  public void testStatusCacheFallsBackToGet() {
+    executor.initialize(
+        TestSparkApplicationUtils.newConfigs(
+            ImmutableMap.of(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS, 
"60000")),
+        client);
+    String job1 = executor.submitJob(context(1L), sparkTemplate());
+    Assertions.assertEquals(JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(job1).status());
+
+    // Created after the namespace was listed, so it isn't in the cached 
listing.
+    String job2 = executor.submitJob(context(2L), sparkTemplate());
+    Assertions.assertEquals(JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(job2).status());
+
+    // The cached listing is used within its TTL, so the new state isn't seen 
yet.
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    Assertions.assertEquals(JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(job1).status());
+  }
+
+  @Test
+  public void testNamespaceOfJob() {
+    SparkJobTemplate template =
+        SparkJobTemplate.builder()
+            .withName("user-job")
+            .withExecutable("local:///opt/app.jar")
+            .withClassName("org.example.App")
+            
.withConfigs(ImmutableMap.of(SparkApplicationUtils.SPARK_NAMESPACE, "team-a"))
+            .build();
+
+    String jobExecutionId = executor.submitJob(context(1L), template);
+
+    Assertions.assertEquals("default/sparkapp/team-a/gravitino-job-1", 
jobExecutionId);
+    Assertions.assertNull(sparkApplication("gravitino-job-1"));
+    Assertions.assertEquals(
+        JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    executor.cancelJob(jobExecutionId);
+    Assertions.assertThrows(
+        NoSuchJobException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+  }
+
+  @Test
+  public void testRejectedByKubernetes() {
+    for (int code : new int[] {400, 403, 404, 422}) {
+      server
+          .expect()
+          .post()
+          .withPath(APPS_PATH)
+          .andReturn(
+              code, new StatusBuilder().withCode(code).withMessage("rejected " 
+ code).build())
+          .once();
+
+      IllegalArgumentException e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> executor.submitJob(context(1L), sparkTemplate()));
+      Assertions.assertTrue(e.getMessage().contains("rejected " + code), 
e.getMessage());
+    }
+
+    // Other failures aren't the job's fault. The client retries server 
errors, so use another.
+    server
+        .expect()
+        .post()
+        .withPath(APPS_PATH)
+        .andReturn(410, new StatusBuilder().withCode(410).build())
+        .once();
+    Assertions.assertThrows(
+        KubernetesClientException.class, () -> executor.submitJob(context(1L), 
sparkTemplate()));
+  }
+
+  @Test
+  public void testFailJobWithoutStatus() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    now.set(now.get().plusSeconds(601));
+
+    // The SparkApplication can't be deleted, so the job isn't failed yet.
+    server
+        .expect()
+        .delete()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReturn(403, new StatusBuilder().withCode(403).build())
+        .once();
+    Assertions.assertThrows(
+        KubernetesClientException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+    Assertions.assertNotNull(sparkApplication("gravitino-job-1"));
+
+    // Deleted, so that the operator doesn't run it after the job failed.
+    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());
+    setStates(
+        "gravitino-job-1",
+        "Submitted",
+        "DriverRequested",
+        "DriverStartTimedOut",
+        "TerminatedWithoutReleaseResources");
+    // The pending driver pod is retained, and would run once it can be 
scheduled.
+    createDriverPod("driver", "spark-kubernetes-driver");
+
+    Assertions.assertEquals(
+        JobHandle.Status.FAILED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    
Assertions.assertNull(client.pods().inNamespace("default").withName("driver").get());
+    Assertions.assertNotNull(sparkApplication("gravitino-job-1"));
+  }
+
+  @Test
+  public void testKeepTerminatedDriverOfFailedJob() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady", "Failed");
+    client
+        .pods()
+        .inNamespace("default")
+        .resource(
+            new PodBuilder()
+                .withNewMetadata()
+                .addToOwnerReferences(ownerReference("gravitino-job-1"))
+                .withName("driver")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_APP_NAME, 
"gravitino-job-1")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_ROLE, "driver")
+                .endMetadata()
+                .withNewStatus()
+                .withPhase("Failed")
+                .endStatus()
+                .build())
+        .create();
+
+    Assertions.assertEquals(
+        JobHandle.Status.FAILED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    // Kept, so that its output can still be read.
+    
Assertions.assertNotNull(client.pods().inNamespace("default").withName("driver").get());
+  }
+
+  @Test
+  public void testIsUnreachable() {
+    Assertions.assertTrue(
+        K8sJobExecutor.isUnreachable(
+            new KubernetesClientException("failed", new IOException(new 
ConnectException()))));
+    Assertions.assertTrue(
+        K8sJobExecutor.isUnreachable(
+            new KubernetesClientException(
+                "failed", new IllegalStateException(new IOException("timed 
out")))));
+    // The API server answered, or the failure has nothing to do with the 
connection.
+    Assertions.assertFalse(
+        K8sJobExecutor.isUnreachable(
+            new KubernetesClientException(new 
StatusBuilder().withCode(503).build())));
+    Assertions.assertFalse(K8sJobExecutor.isUnreachable(new 
KubernetesClientException("failed")));
+    // A response came back, only not a JSON one, for example the login page 
of a proxy.
+    Assertions.assertFalse(
+        K8sJobExecutor.isUnreachable(
+            new KubernetesClientException(
+                "failed", new JsonParseException((JsonParser) null, 
"<html>"))));
+    Assertions.assertFalse(
+        K8sJobExecutor.isUnreachable(
+            new KubernetesClientException("failed", new 
IllegalStateException())));
+  }
+
+  @Test
+  public void testFailFastWhileApiServerUnreachable() {
+    String job1 = "default/sparkapp/default/gravitino-job-1";
+    String job2 = "default/sparkapp/other/gravitino-job-2";
+    // With and without the namespace listing: nothing listens on the port.
+    for (String statusCacheTtlMs : new String[] {"60000", "0"}) {
+      try (KubernetesClient unreachable =
+          new KubernetesClientBuilder()
+              .withConfig(
+                  new ConfigBuilder(Config.empty())
+                      .withMasterUrl("http://127.0.0.1:1";)
+                      .withRequestRetryBackoffLimit(0)
+                      .build())
+              .build()) {
+        executor.initialize(
+            TestSparkApplicationUtils.newConfigs(
+                ImmutableMap.of(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS, 
statusCacheTtlMs)),
+            unreachable);
+
+        KubernetesClientException first =
+            Assertions.assertThrows(
+                KubernetesClientException.class, () -> 
executor.getJobExecutionInfo(job1));
+        Assertions.assertTrue(K8sJobExecutor.isUnreachable(first), 
first.toString());
+
+        // The other jobs fail right away, whatever their namespace, without 
another request.
+        for (String jobExecutionId : new String[] {job2, job1}) {
+          KubernetesClientException next =
+              Assertions.assertThrows(
+                  KubernetesClientException.class,
+                  () -> executor.getJobExecutionInfo(jobExecutionId));
+          Assertions.assertTrue(next.getMessage().contains("isn't queried 
again"), next.toString());
+          Assertions.assertSame(first, next.getCause());
+        }
+
+        // Once the backoff is over, the API server is tried again.
+        now.set(now.get().plusMillis(K8sJobExecutor.UNREACHABLE_BACKOFF_MS));
+        KubernetesClientException again =
+            Assertions.assertThrows(
+                KubernetesClientException.class, () -> 
executor.getJobExecutionInfo(job2));
+        Assertions.assertFalse(
+            again.getMessage().contains("isn't queried again"), 
again.toString());
+        Assertions.assertNotSame(first, again);
+        now.set(now.get().plusMillis(K8sJobExecutor.UNREACHABLE_BACKOFF_MS));
+      }
+    }
+  }
+
+  @Test
+  public void testApiServerErrorDoesNotFailFast() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    server
+        .expect()
+        .get()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReturn(403, new StatusBuilder().withCode(403).build())
+        .once();
+
+    // The API server answered, so the next query goes to it again.
+    Assertions.assertThrows(
+        KubernetesClientException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+    Assertions.assertEquals(
+        JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+  }
+
+  @Test
+  public void testDriverLookupFailureKeepsFailedJob() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates(
+        "gravitino-job-1",
+        "Submitted",
+        "DriverRequested",
+        "DriverStartTimedOut",
+        "TerminatedWithoutReleaseResources");
+    createDriverPod("driver", "spark-kubernetes-driver");
+    // Listing the pods fails once, it can't be told whether the driver still 
runs.
+    server
+        .expect()
+        .get()
+        .withPath(
+            "/api/v1/namespaces/default/pods?labelSelector="
+                + 
"spark.operator%2Fspark-app-name%3Dgravitino-job-1%2Cspark-role%3Ddriver")
+        .andReturn(403, new StatusBuilder().withCode(403).build())
+        .once();
+
+    // The job isn't failed while its pending driver could still run.
+    Assertions.assertThrows(
+        KubernetesClientException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+    
Assertions.assertNotNull(client.pods().inNamespace("default").withName("driver").get());
+
+    Assertions.assertEquals(
+        JobHandle.Status.FAILED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    
Assertions.assertNull(client.pods().inNamespace("default").withName("driver").get());
+  }
+
+  @Test
+  public void testKeepDriverWithSidecarOfFailedJob() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady", "Failed");
+    // The driver exited, a sidecar keeps the pod running.
+    client
+        .pods()
+        .inNamespace("default")
+        .resource(
+            new PodBuilder()
+                .withNewMetadata()
+                .addToOwnerReferences(ownerReference("gravitino-job-1"))
+                .withName("driver")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_APP_NAME, 
"gravitino-job-1")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_ROLE, "driver")
+                .endMetadata()
+                .withNewStatus()
+                .withPhase("Running")
+                .addNewContainerStatus()
+                .withName("spark-kubernetes-driver")
+                .withNewState()
+                .withNewTerminated()
+                .withExitCode(1)
+                .endTerminated()
+                .endState()
+                .endContainerStatus()
+                .addNewContainerStatus()
+                .withName("istio-proxy")
+                .withNewState()
+                .withNewRunning()
+                .endRunning()
+                .endState()
+                .endContainerStatus()
+                .endStatus()
+                .build())
+        .create();
+
+    Assertions.assertEquals(
+        JobHandle.Status.FAILED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    // Kept, so that its output can still be read.
+    
Assertions.assertNotNull(client.pods().inNamespace("default").withName("driver").get());
+  }
+
+  @Test
+  public void testDriverPodOfAnotherSparkApplicationIsIgnored() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverRequested", 
"DriverStartTimedOut");
+    // A pending pod with the same labels, owned by another SparkApplication.
+    client
+        .pods()
+        .inNamespace("default")
+        .resource(
+            new PodBuilder()
+                .withNewMetadata()
+                .addToOwnerReferences(
+                    new OwnerReferenceBuilder()
+                        .withApiVersion("spark.apache.org/v1")
+                        .withKind("SparkApplication")
+                        .withName("gravitino-job-1")
+                        .withUid("another-uid")
+                        .build())
+                .withName("other-driver")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_APP_NAME, 
"gravitino-job-1")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_ROLE, "driver")
+                .endMetadata()
+                .withNewStatus()
+                .withPhase("Pending")
+                .endStatus()
+                .build())
+        .create();
+
+    Assertions.assertEquals(
+        JobHandle.Status.FAILED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    
Assertions.assertNotNull(client.pods().inNamespace("default").withName("other-driver").get());
+  }
+
+  @Test
+  public void testJobWithoutStatusCancelledMeanwhile() {
+    executor.initialize(
+        TestSparkApplicationUtils.newConfigs(
+            ImmutableMap.of(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS, 
"3600000")),
+        client);
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    Assertions.assertEquals(
+        JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+
+    // Another server cancels it, while this one still has it without status 
in its cache.
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    app.getMetadata()
+        
.setAnnotations(ImmutableMap.of(K8sJobResourceUtils.ANNOTATION_CANCEL_REQUESTED,
 "true"));
+    app.getMetadata().setFinalizers(ImmutableList.of("foregroundDeletion"));
+    app.getMetadata().setDeletionTimestamp(Instant.now().toString());
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .resource(app)
+        .update();
+    now.set(now.get().plusSeconds(601));
+
+    Assertions.assertEquals(
+        JobHandle.Status.CANCELLED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+  }
+
+  @Test
+  public void testAdoptNoResourceBeingDeleted() {
+    // A SparkApplication of the same job, but being deleted.
+    GenericKubernetesResource deleting =
+        SparkApplicationUtils.buildSparkApplication(
+            context(1L), sparkTemplate(), 
TestSparkApplicationUtils.newConfigs(ImmutableMap.of()));
+    
deleting.getMetadata().setFinalizers(ImmutableList.of("foregroundDeletion"));
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .resource(deleting)
+        .create();
+    GenericKubernetesResource created = sparkApplication("gravitino-job-1");
+    created.getMetadata().setDeletionTimestamp(Instant.now().toString());
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .resource(created)
+        .update();
+
+    Assertions.assertThrows(
+        IllegalStateException.class, () -> executor.submitJob(context(1L), 
sparkTemplate()));
+  }
+
+  @Test
+  public void testSubmitWithoutResponse() {
+    // Whether the SparkApplication was created can't be told, the submission 
fails anyway.
+    try (KubernetesClient unreachable =
+        new KubernetesClientBuilder()
+            .withConfig(
+                new ConfigBuilder(Config.empty())
+                    .withMasterUrl("http://127.0.0.1:1";)
+                    .withRequestRetryBackoffLimit(0)
+                    .build())
+            .build()) {
+      
executor.initialize(TestSparkApplicationUtils.newConfigs(ImmutableMap.of()), 
unreachable);
+      KubernetesClientException e =
+          Assertions.assertThrows(
+              KubernetesClientException.class,
+              () -> executor.submitJob(context(1L), sparkTemplate()));
+      Assertions.assertTrue(K8sJobExecutor.isUnreachable(e), e.toString());
+    }
+  }
+
+  @Test
+  public void testStdoutOfRenamedDriverContainer() throws InterruptedException 
{
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    Map<String, Object> spec =
+        new HashMap<>((Map<String, Object>) 
app.getAdditionalProperties().get("spec"));
+    Map<String, Object> sparkConf = new HashMap<>((Map<String, Object>) 
spec.get("sparkConf"));
+    sparkConf.put(SparkApplicationUtils.SPARK_DRIVER_CONTAINER_NAME, "main");
+    spec.put("sparkConf", sparkConf);
+    app.setAdditionalProperty("spec", spec);
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .resource(app)
+        .update();
+    createDriverPod("driver", "main", "sidecar");
+
+    executor.getJobStdout(jobExecutionId, 10, 1024);
+
+    RecordedRequest log = null;
+    RecordedRequest request;
+    while ((request = server.takeRequest(10, TimeUnit.MILLISECONDS)) != null) {
+      if (request.getPath().contains("/pods/driver/log")) {
+        log = request;
+      }
+    }
+    Assertions.assertNotNull(log);
+    Assertions.assertTrue(log.getPath().contains("container=main"), 
log.getPath());
+  }
+
+  @Test
+  public void testStdoutBeforeInitialize() {
+    Assertions.assertEquals(
+        ImmutableList.of(),
+        new 
K8sJobExecutor().getJobStdout("default/sparkapp/default/gravitino-job-1", 10, 
1024));
+  }
+
+  @Test
+  public void testListFailureFallsBackToGet() throws InterruptedException {
+    executor.initialize(
+        TestSparkApplicationUtils.newConfigs(
+            ImmutableMap.of(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS, 
"60000")),
+        client);
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    server
+        .expect()
+        .get()
+        .withPath(APPS_PATH + 
"?labelSelector=app.kubernetes.io%2Fmanaged-by%3Dgravitino")
+        .andReturn(403, new StatusBuilder().withCode(403).build())
+        .always();
+
+    Assertions.assertEquals(
+        JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    // Each query gets the SparkApplication itself, so it sees the new state 
right away.
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    Assertions.assertEquals(
+        JobHandle.Status.STARTED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+
+    // The namespace is listed once, then the failure is cached.
+    int lists = 0;
+    RecordedRequest request;
+    while ((request = server.takeRequest(10, TimeUnit.MILLISECONDS)) != null) {
+      if ("GET".equals(request.getMethod()) && 
request.getPath().contains("labelSelector")) {
+        lists++;
+      }
+    }
+    Assertions.assertEquals(1, lists);
+  }
+
+  @Test
+  public void testResubmitSameJob() {
+    String first = executor.submitJob(context(1L), sparkTemplate());
+    String second = executor.submitJob(context(1L), sparkTemplate());
+    Assertions.assertEquals(first, second);
+  }
+
+  @Test
+  public void testRejectResourceOfAnotherJob() {
+    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();
+
+    Assertions.assertThrows(
+        IllegalStateException.class, () -> executor.submitJob(context(1L), 
sparkTemplate()));
+  }
+
+  @Test
+  public void testRejectShellJob() {
+    ShellJobTemplate template =
+        
ShellJobTemplate.builder().withName("shell-job").withExecutable("echo").build();
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> executor.submitJob(context(1L), 
template));
+  }
+
+  @Test
+  public void testNoSuchJob() {
+    Assertions.assertThrows(
+        NoSuchJobException.class,
+        () -> 
executor.getJobExecutionInfo("default/sparkapp/default/gravitino-job-9"));
+    Assertions.assertThrows(
+        NoSuchJobException.class,
+        () -> executor.cancelJob("default/sparkapp/default/gravitino-job-9"));
+    Assertions.assertThrows(
+        NoSuchJobException.class, () -> 
executor.getJobExecutionInfo("gravitino-job-9"));
+
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    Assertions.assertThrows(
+        NoSuchJobException.class,
+        () -> 
executor.getJobExecutionInfo(jobExecutionId.replace("/sparkapp/", "/job/")));
+  }
+
+  @Test
+  public void testJobOfAnotherCluster() {
+    // For example, submitted before the cluster was renamed. The job isn't 
missing, which would
+    // end it while it may still run, so its queries fail and leave it as it 
is.
+    String jobExecutionId =
+        executor.submitJob(context(1L), 
sparkTemplate()).replaceFirst("^default/", "other/");
+    Assertions.assertThrows(
+        IllegalStateException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+    Assertions.assertThrows(IllegalStateException.class, () -> 
executor.cancelJob(jobExecutionId));
+    Assertions.assertFalse(
+        
K8sJobResourceUtils.isCancelRequested(sparkApplication("gravitino-job-1")));
+    Assertions.assertEquals(ImmutableList.of(), 
executor.getJobStdout(jobExecutionId, 10, 1024));
+  }
+
+  @Test
+  public void testIgnoreResourceOfAnotherJob() {
+    // The SparkApplication of the job is gone, and its name is used by 
another job's, for example
+    // of another Gravitino deployment with the same name prefix in the 
namespace.
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .withName("gravitino-job-1")
+        .delete();
+    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();
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    createDriverPod("driver", "spark-kubernetes-driver");
+    server
+        .expect()
+        .get()
+        
.withPath("/api/v1/namespaces/default/pods/driver/log?pretty=false&tailLines=10")
+        .andReturn(200, "other job\n")
+        .always();
+
+    Assertions.assertThrows(
+        NoSuchJobException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+    Assertions.assertThrows(NoSuchJobException.class, () -> 
executor.cancelJob(jobExecutionId));
+    Assertions.assertEquals(ImmutableList.of(), 
executor.getJobStdout(jobExecutionId, 10, 1024));
+    GenericKubernetesResource kept = sparkApplication("gravitino-job-1");
+    Assertions.assertNotNull(kept);
+    Assertions.assertFalse(K8sJobResourceUtils.isCancelRequested(kept));
+    
Assertions.assertNotNull(client.pods().inNamespace("default").withName("driver").get());
+
+    // Nor one that isn't managed by Gravitino.
+    kept.getMetadata().setLabels(ImmutableMap.of());
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .resource(kept)
+        .update();
+    Assertions.assertThrows(
+        NoSuchJobException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+    Assertions.assertEquals(ImmutableList.of(), 
executor.getJobStdout(jobExecutionId, 10, 1024));
+  }
+
+  @Test
+  public void testKeepJobWithUnreadableStatus() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    // A status this version can't read, for example of a newer operator.
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    app.setAdditionalProperty("status", ImmutableMap.of("phase", "Running"));
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .resource(app)
+        .update();
+    now.set(now.get().plusSeconds(601));
+
+    Assertions.assertEquals(
+        JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    Assertions.assertNotNull(sparkApplication("gravitino-job-1"));
+  }
+
+  @Test
+  public void testJobWithoutStatusGoneMeanwhile() {
+    executor.initialize(
+        TestSparkApplicationUtils.newConfigs(
+            ImmutableMap.of(K8sJobExecutorConfigs.STATUS_CACHE_TTL_MS, 
"3600000")),
+        client);
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    Assertions.assertEquals(
+        JobHandle.Status.QUEUED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+
+    // Another server cancels it and it is gone, while this one still has it 
in its cache. It is
+    // reported missing rather than failed, which settles a cancelling job as 
cancelled.
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .withName("gravitino-job-1")
+        .delete();
+    now.set(now.get().plusSeconds(601));
+
+    Assertions.assertThrows(
+        NoSuchJobException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+  }
+
+  @Test
+  public void testCancelWhoseDeletionResponseIsLost() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    GenericKubernetesResource running = sparkApplication("gravitino-job-1");
+    // The deletion takes effect, but its response is lost. From then on the 
SparkApplication is
+    // marked and being deleted.
+    GenericKubernetesResource deleting = sparkApplication("gravitino-job-1");
+    deleting
+        .getMetadata()
+        
.setAnnotations(ImmutableMap.of(K8sJobResourceUtils.ANNOTATION_CANCEL_REQUESTED,
 "true"));
+    deleting.getMetadata().setDeletionTimestamp(Instant.now().toString());
+    AtomicBoolean deleted = new AtomicBoolean();
+    server
+        .expect()
+        .get()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReply(200, request -> deleted.get() ? deleting : running)
+        .always();
+    server
+        .expect()
+        .delete()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReply(
+            403,
+            request -> {
+              deleted.set(true);
+              return new StatusBuilder().withCode(403).build();
+            })
+        .once();
+
+    // The job is being cancelled, so the cancellation succeeds and the job 
keeps its mark.
+    executor.cancelJob(jobExecutionId);
+
+    Assertions.assertTrue(deleted.get());
+    Assertions.assertTrue(
+        
K8sJobResourceUtils.isCancelRequested(sparkApplication("gravitino-job-1")));
+    Assertions.assertEquals(
+        JobHandle.Status.CANCELLED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+  }
+
+  @Test
+  public void testCancel() throws InterruptedException {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    // Keep the SparkApplication, as it stays until its pods are deleted.
+    server
+        .expect()
+        .delete()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReturn(200, sparkApplication("gravitino-job-1"))
+        .always();
+
+    executor.cancelJob(jobExecutionId);
+
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    Assertions.assertTrue(K8sJobResourceUtils.isCancelRequested(app));
+    RecordedRequest delete = lastRequest("DELETE");
+    
Assertions.assertTrue(delete.getBody().readUtf8().contains("\"Foreground\""));
+
+    // The job is only cancelled once the deletion takes effect, and a status 
query never deletes.
+    Assertions.assertEquals(
+        JobHandle.Status.STARTED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+    Assertions.assertNull(lastRequest("DELETE"));
+  }
+
+  @Test
+  public void testCancelledJobFinishedWithDriver() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    // Cancelled while running: being deleted, with no outcome recorded by the 
operator.
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    app.getMetadata()
+        
.setAnnotations(ImmutableMap.of(K8sJobResourceUtils.ANNOTATION_CANCEL_REQUESTED,
 "true"));
+    // A finalizer keeps it while it is being deleted, as the Foreground 
deletion does.
+    app.getMetadata().setFinalizers(ImmutableList.of("foregroundDeletion"));
+    app.getMetadata().setDeletionTimestamp(Instant.now().toString());
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .resource(app)
+        .update();
+    Assertions.assertNotNull(
+        
sparkApplication("gravitino-job-1").getMetadata().getDeletionTimestamp());
+
+    // The driver terminated after the deletion was requested.
+    Instant driverStartedAt = 
Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.SECONDS);
+    Instant driverFinishedAt = driverStartedAt.plusSeconds(30);
+    client
+        .pods()
+        .inNamespace("default")
+        .resource(
+            new PodBuilder()
+                .withNewMetadata()
+                .addToOwnerReferences(ownerReference("gravitino-job-1"))
+                .withName("driver")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_APP_NAME, 
"gravitino-job-1")
+                .addToLabels(SparkApplicationUtils.LABEL_SPARK_ROLE, "driver")
+                .endMetadata()
+                .withNewStatus()
+                .withPhase("Failed")
+                .addNewContainerStatus()
+                .withName("spark-kubernetes-driver")
+                .withNewState()
+                .withNewTerminated()
+                .withStartedAt(driverStartedAt.toString())
+                .withFinishedAt(driverFinishedAt.toString())
+                .endTerminated()
+                .endState()
+                .endContainerStatus()
+                .endStatus()
+                .build())
+        .create();
+
+    JobExecutionInfo cancelled = executor.getJobExecutionInfo(jobExecutionId);
+    Assertions.assertEquals(JobHandle.Status.CANCELLED, cancelled.status());
+    Assertions.assertEquals(driverStartedAt, cancelled.startedAt());
+    Assertions.assertEquals(driverFinishedAt, cancelled.finishedAt());
+  }
+
+  @Test
+  public void testCancelAgainAfterFailedDeletion() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    server
+        .expect()
+        .delete()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReturn(403, new StatusBuilder().withCode(403).build())
+        .once();
+
+    // The cancellation fails, and the job keeps running, without the cancel 
mark, so that it
+    // isn't reported as cancelled once it finishes by itself.
+    Assertions.assertThrows(
+        KubernetesClientException.class, () -> 
executor.cancelJob(jobExecutionId));
+    Assertions.assertFalse(
+        
K8sJobResourceUtils.isCancelRequested(sparkApplication("gravitino-job-1")));
+    Assertions.assertEquals(
+        JobHandle.Status.STARTED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+
+    // Cancelling it again deletes it.
+    executor.cancelJob(jobExecutionId);
+    Assertions.assertNull(sparkApplication("gravitino-job-1"));
+    Assertions.assertThrows(
+        NoSuchJobException.class, () -> 
executor.getJobExecutionInfo(jobExecutionId));
+  }
+
+  @Test
+  public void testCancelWhoseDeletionFailsAfterTheJobIsGone() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    // The deletion takes effect and the SparkApplication is gone, but the 
response is lost: it is
+    // read once to be marked, and is gone when read after the failed deletion.
+    server
+        .expect()
+        .get()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReturn(200, sparkApplication("gravitino-job-1"))
+        .once();
+    server
+        .expect()
+        .delete()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReturn(403, new StatusBuilder().withCode(403).build())
+        .once();
+    server
+        .expect()
+        .get()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReturn(404, new StatusBuilder().withCode(404).build())
+        .always();
+
+    // The job is gone, so the cancellation succeeds.
+    executor.cancelJob(jobExecutionId);
+  }
+
+  @Test
+  public void testCancelConflictRetriesRunOut() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady");
+    server
+        .expect()
+        .put()
+        .withPath(APPS_PATH + "/gravitino-job-1")
+        .andReturn(409, new StatusBuilder().withCode(409).build())
+        .times(4);
+
+    KubernetesClientException e =
+        Assertions.assertThrows(
+            KubernetesClientException.class, () -> 
executor.cancelJob(jobExecutionId));
+    Assertions.assertEquals(409, e.getCode());
+    // Not marked nor deleted, the job keeps running.
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    Assertions.assertFalse(K8sJobResourceUtils.isCancelRequested(app));
+    Assertions.assertNull(app.getMetadata().getDeletionTimestamp());
+    Assertions.assertEquals(
+        JobHandle.Status.STARTED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+
+    // Once the conflicts are over, it can be cancelled.
+    executor.cancelJob(jobExecutionId);
+    Assertions.assertNull(sparkApplication("gravitino-job-1"));
+  }
+
+  @Test
+  public void testCancelJobThatJustStopped() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    // The job succeeds between the read of the cancellation and its mark.
+    GenericKubernetesResource running = sparkApplication("gravitino-job-1");
+    setStates("gravitino-job-1", "Submitted", "DriverReady", "Succeeded");
+    server.expect().get().withPath(APPS_PATH + 
"/gravitino-job-1").andReturn(200, running).once();
+
+    executor.cancelJob(jobExecutionId);
+
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    Assertions.assertFalse(K8sJobResourceUtils.isCancelRequested(app));
+    Assertions.assertEquals(
+        JobHandle.Status.SUCCEEDED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+  }
+
+  @Test
+  public void testCancelStoppedJob() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    setStates("gravitino-job-1", "Submitted", "DriverReady", "Succeeded");
+
+    executor.cancelJob(jobExecutionId);
+
+    GenericKubernetesResource app = sparkApplication("gravitino-job-1");
+    Assertions.assertFalse(K8sJobResourceUtils.isCancelRequested(app));
+    Assertions.assertEquals(
+        JobHandle.Status.SUCCEEDED, 
executor.getJobExecutionInfo(jobExecutionId).status());
+  }
+
+  @Test
+  public void testStdout() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    Assertions.assertEquals(ImmutableList.of(), 
executor.getJobStdout(jobExecutionId, 10, 1024));
+
+    for (String name : new String[] {"driver", "executor"}) {
+      client
+          .pods()
+          .inNamespace("default")
+          .resource(
+              new PodBuilder()
+                  .withNewMetadata()
+                  .addToOwnerReferences(ownerReference("gravitino-job-1"))
+                  .withName(name)
+                  .addToLabels(SparkApplicationUtils.LABEL_SPARK_APP_NAME, 
"gravitino-job-1")
+                  .addToLabels(
+                      SparkApplicationUtils.LABEL_SPARK_ROLE,
+                      name.equals("executor") ? "executor" : "driver")
+                  .endMetadata()
+                  .build())
+          .create();
+    }
+    server
+        .expect()
+        .get()
+        
.withPath("/api/v1/namespaces/default/pods/driver/log?pretty=false&tailLines=10")
+        .andReturn(200, "line 1\nline 2\n")
+        .always();
+
+    Assertions.assertEquals(
+        ImmutableList.of("line 1", "line 2"), 
executor.getJobStdout(jobExecutionId, 10, 1024));
+    Assertions.assertEquals(ImmutableList.of(), 
executor.getJobStderr(jobExecutionId, 10, 1024));
+    Assertions.assertEquals(ImmutableList.of(), 
executor.getJobStdout("invalid", 10, 1024));
+  }
+
+  @Test
+  public void testStdoutFailure() {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    createDriverPod("driver", "spark-kubernetes-driver");
+    server
+        .expect()
+        .get()
+        
.withPath("/api/v1/namespaces/default/pods/driver/log?pretty=false&tailLines=10")
+        .andReturn(403, "forbidden")
+        .always();
+
+    Assertions.assertEquals(ImmutableList.of(), 
executor.getJobStdout(jobExecutionId, 10, 1024));
+  }
+
+  @Test
+  public void testStdoutOfDriverWithSidecar() throws InterruptedException {
+    String jobExecutionId = executor.submitJob(context(1L), sparkTemplate());
+    createDriverPod("driver", "spark-kubernetes-driver", "sidecar");
+
+    executor.getJobStdout(jobExecutionId, 10, 1024);
+
+    RecordedRequest log = null;
+    RecordedRequest request;
+    while ((request = server.takeRequest(10, TimeUnit.MILLISECONDS)) != null) {
+      if (request.getPath().contains("/pods/driver/log")) {
+        log = request;
+      }
+    }
+    Assertions.assertNotNull(log);
+    Assertions.assertTrue(
+        log.getPath().contains("container=spark-kubernetes-driver"), 
log.getPath());
+  }
+
+  private void createDriverPod(String name, String... containers) {
+    PodBuilder pod =
+        new PodBuilder()
+            .withNewMetadata()
+            .addToOwnerReferences(ownerReference("gravitino-job-1"))
+            .withName(name)
+            .addToLabels(SparkApplicationUtils.LABEL_SPARK_APP_NAME, 
"gravitino-job-1")
+            .addToLabels(SparkApplicationUtils.LABEL_SPARK_ROLE, "driver")
+            .endMetadata()
+            .withNewSpec()
+            .endSpec();
+    for (String container : containers) {
+      
pod.editSpec().addNewContainer().withName(container).endContainer().endSpec();
+    }
+    client.pods().inNamespace("default").resource(pod.build()).create();
+  }
+
+  /** The owner reference the operator sets on the driver pod of a 
SparkApplication. */
+  private OwnerReference ownerReference(String appName) {
+    GenericKubernetesResource app = sparkApplication(appName);
+    return new OwnerReferenceBuilder()
+        .withApiVersion("spark.apache.org/v1")
+        .withKind("SparkApplication")
+        .withName(appName)
+        .withUid(app.getMetadata().getUid())
+        .build();
+  }
+
+  private GenericKubernetesResource sparkApplication(String name) {
+    return client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .withName(name)
+        .get();
+  }
+
+  private void setStates(String name, String... states) {
+    Map<String, Object> history = new HashMap<>();
+    Instant time = Instant.parse("2026-09-30T00:00:00Z");
+    for (int i = 0; i < states.length; i++) {
+      history.put(
+          String.valueOf(i),
+          ImmutableMap.of(
+              "currentStateSummary",
+              states[i],
+              "lastTransitionTime",
+              time.plusSeconds(i).toString()));
+    }
+    GenericKubernetesResource app = sparkApplication(name);
+    app.setAdditionalProperty(
+        "status",
+        ImmutableMap.of(
+            "currentState",
+            history.get(String.valueOf(states.length - 1)),
+            "stateTransitionHistory",
+            history));
+    client
+        .genericKubernetesResources(SparkApplicationUtils.SPARK_APPLICATION)
+        .inNamespace("default")
+        .resource(app)
+        .update();
+  }
+
+  private RecordedRequest lastRequest(String method) throws 
InterruptedException {
+    RecordedRequest last = null;
+    RecordedRequest request;
+    while ((request = server.takeRequest(10, TimeUnit.MILLISECONDS)) != null) {
+      if (method.equals(request.getMethod())) {
+        last = request;
+      }
+    }
+    return last;
+  }
+
+  private static JobContext context(long jobId) {
+    return new JobContext(jobId, "ml", new File("/tmp/job-" + jobId));
+  }
+
+  private static SparkJobTemplate sparkTemplate() {
+    return SparkJobTemplate.builder()
+        .withName("user-job")
+        .withExecutable("local:///opt/spark/examples/jars/spark-examples.jar")
+        .withClassName("org.apache.spark.examples.SparkPi")
+        .withArguments(ImmutableList.of("10"))
+        .build();
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobResourceUtils.java
 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobResourceUtils.java
index 76c7661738..74104fc084 100644
--- 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobResourceUtils.java
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestK8sJobResourceUtils.java
@@ -54,6 +54,27 @@ public class TestK8sJobResourceUtils {
     Assertions.assertFalse(K8sJobResourceUtils.isOfJob(new ConfigMap(), 42L));
   }
 
+  @Test
+  public void testIsJobResource() {
+    
Assertions.assertTrue(K8sJobResourceUtils.isJobResource(resource("gravitino-job-42",
 42L)));
+    
Assertions.assertTrue(K8sJobResourceUtils.isJobResource(resource("other-prefix-42",
 42L)));
+    // Another job's resource with this name, for example of another 
deployment.
+    
Assertions.assertFalse(K8sJobResourceUtils.isJobResource(resource("gravitino-job-42",
 43L)));
+    
Assertions.assertFalse(K8sJobResourceUtils.isJobResource(resource("gravitino-job-142",
 42L)));
+    // Not managed by Gravitino.
+    ConfigMap unmanaged = resource("gravitino-job-42", 42L);
+    
unmanaged.getMetadata().setLabels(ImmutableMap.of(K8sJobResourceUtils.LABEL_JOB_ID,
 "42"));
+    Assertions.assertFalse(K8sJobResourceUtils.isJobResource(unmanaged));
+    Assertions.assertFalse(
+        K8sJobResourceUtils.isJobResource(
+            new ConfigMapBuilder()
+                .withNewMetadata()
+                .withName("gravitino-job-42")
+                .endMetadata()
+                .build()));
+    Assertions.assertFalse(K8sJobResourceUtils.isJobResource(new ConfigMap()));
+  }
+
   @Test
   public void testIsCancelRequested() {
     Assertions.assertFalse(
@@ -75,6 +96,15 @@ public class TestK8sJobResourceUtils {
     }
   }
 
+  private static ConfigMap resource(String name, long jobId) {
+    return new ConfigMapBuilder()
+        .withNewMetadata()
+        .withName(name)
+        .withLabels(K8sJobResourceUtils.jobLabels(jobId))
+        .endMetadata()
+        .build();
+  }
+
   private static ConfigMap annotated(String cancelRequested) {
     return new ConfigMapBuilder()
         .withNewMetadata()
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
new file mode 100644
index 0000000000..aa358c0ee4
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestNamespacedResourceCache.java
@@ -0,0 +1,248 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.job.k8s;
+
+import com.google.common.collect.ImmutableList;
+import io.fabric8.kubernetes.api.model.ConfigMap;
+import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestNamespacedResourceCache {
+
+  private final AtomicLong now = new AtomicLong(1_000L);
+
+  private final List<String> listed = new ArrayList<>();
+
+  @Test
+  public void testCachedWithinTtl() {
+    NamespacedResourceCache<ConfigMap> cache =
+        new NamespacedResourceCache<>(
+            100L,
+            namespace -> {
+              listed.add(namespace);
+              return ImmutableList.of(resource(namespace, "a"), 
resource(namespace, "b"));
+            },
+            e -> false,
+            now::get);
+
+    Assertions.assertEquals("a", cache.find("ns1", 
"a").get().getMetadata().getName());
+    Assertions.assertEquals("b", cache.find("ns1", 
"b").get().getMetadata().getName());
+    Assertions.assertFalse(cache.find("ns1", "c").isPresent());
+    Assertions.assertTrue(cache.find("ns2", "a").isPresent());
+    Assertions.assertEquals(ImmutableList.of("ns1", "ns2"), listed);
+
+    now.addAndGet(100L);
+    Assertions.assertTrue(cache.find("ns1", "a").isPresent());
+    Assertions.assertEquals(ImmutableList.of("ns1", "ns2", "ns1"), listed);
+
+    cache.invalidate("ns1");
+    Assertions.assertTrue(cache.find("ns1", "a").isPresent());
+    Assertions.assertEquals(ImmutableList.of("ns1", "ns2", "ns1", "ns1"), 
listed);
+  }
+
+  @Test
+  public void testDisabled() {
+    NamespacedResourceCache<ConfigMap> cache =
+        new NamespacedResourceCache<>(
+            0L,
+            namespace -> {
+              listed.add(namespace);
+              return ImmutableList.of(resource(namespace, "a"));
+            },
+            e -> false,
+            now::get);
+
+    Assertions.assertFalse(cache.find("ns1", "a").isPresent());
+    Assertions.assertTrue(listed.isEmpty());
+  }
+
+  @Test
+  public void testListFailureIsCached() {
+    NamespacedResourceCache<ConfigMap> cache =
+        new NamespacedResourceCache<>(
+            100L,
+            namespace -> {
+              listed.add(namespace);
+              throw new IllegalStateException("forbidden");
+            },
+            e -> false,
+            now::get);
+
+    Assertions.assertFalse(cache.find("ns1", "a").isPresent());
+    // The namespace isn't listed again until the failure expires.
+    Assertions.assertFalse(cache.find("ns1", "b").isPresent());
+    Assertions.assertEquals(ImmutableList.of("ns1"), listed);
+
+    now.addAndGet(100L);
+    Assertions.assertFalse(cache.find("ns1", "a").isPresent());
+    Assertions.assertEquals(ImmutableList.of("ns1", "ns1"), listed);
+  }
+
+  @Test
+  public void testInvalidatedWhileListing() {
+    AtomicReference<NamespacedResourceCache<ConfigMap>> cache = new 
AtomicReference<>();
+    cache.set(
+        new NamespacedResourceCache<>(
+            100L,
+            namespace -> {
+              listed.add(namespace);
+              // A resource changes while the namespace is being listed.
+              cache.get().invalidate(namespace);
+              return ImmutableList.of(resource(namespace, "a"));
+            },
+            e -> false,
+            now::get));
+
+    Assertions.assertTrue(cache.get().find("ns1", "a").isPresent());
+    // The listing may predate the change, so it isn't cached.
+    Assertions.assertTrue(cache.get().find("ns1", "a").isPresent());
+    Assertions.assertEquals(ImmutableList.of("ns1", "ns1"), listed);
+  }
+
+  @Test
+  public void testFatalListFailureIsThrown() {
+    NamespacedResourceCache<ConfigMap> cache =
+        new NamespacedResourceCache<>(
+            100L,
+            namespace -> {
+              listed.add(namespace);
+              throw new IllegalStateException("unreachable");
+            },
+            e -> e instanceof IllegalStateException,
+            now::get);
+
+    Assertions.assertThrows(IllegalStateException.class, () -> 
cache.find("ns1", "a"));
+    // Not cached as an empty listing, so the next query fails the same way.
+    Assertions.assertThrows(IllegalStateException.class, () -> 
cache.find("ns1", "a"));
+    Assertions.assertEquals(ImmutableList.of("ns1", "ns1"), listed);
+  }
+
+  @Test
+  public void testInvalidationOfAnotherNamespace() {
+    AtomicReference<NamespacedResourceCache<ConfigMap>> cache = new 
AtomicReference<>();
+    cache.set(
+        new NamespacedResourceCache<>(
+            100L,
+            namespace -> {
+              listed.add(namespace);
+              // Another namespace changes while this one is being listed.
+              cache.get().invalidate("other");
+              return ImmutableList.of(resource(namespace, "a"));
+            },
+            e -> false,
+            now::get));
+
+    Assertions.assertTrue(cache.get().find("ns1", "a").isPresent());
+    // The listing of ns1 is still cached.
+    Assertions.assertTrue(cache.get().find("ns1", "a").isPresent());
+    Assertions.assertEquals(ImmutableList.of("ns1"), listed);
+  }
+
+  @Test
+  public void testConcurrentFindsListOnce() throws Exception {
+    CountDownLatch listing = new CountDownLatch(1);
+    CountDownLatch release = new CountDownLatch(1);
+    AtomicInteger lists = new AtomicInteger();
+    NamespacedResourceCache<ConfigMap> cache =
+        new NamespacedResourceCache<>(
+            100L,
+            namespace -> {
+              lists.incrementAndGet();
+              // Only the listing of ns1 takes a while.
+              if ("ns1".equals(namespace)) {
+                listing.countDown();
+                try {
+                  release.await(10, TimeUnit.SECONDS);
+                } catch (InterruptedException e) {
+                  Thread.currentThread().interrupt();
+                }
+              }
+              return ImmutableList.of(resource(namespace, "a"));
+            },
+            e -> false,
+            now::get);
+
+    AtomicReference<Optional<ConfigMap>> first = new AtomicReference<>();
+    AtomicReference<Optional<ConfigMap>> second = new AtomicReference<>();
+    Thread firstFind = new Thread(() -> first.set(cache.find("ns1", "a")));
+    firstFind.start();
+    Assertions.assertTrue(listing.await(10, TimeUnit.SECONDS));
+
+    // The second find waits for the listing being made, instead of listing 
again. Release the
+    // listing only once it is waiting, so that it can't find a listing 
already made.
+    Thread secondFind = new Thread(() -> second.set(cache.find("ns1", "a")));
+    secondFind.start();
+    long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+    while (secondFind.getState() != Thread.State.WAITING && System.nanoTime() 
< deadline) {
+      Thread.sleep(10);
+    }
+    Assertions.assertEquals(Thread.State.WAITING, secondFind.getState());
+    // Another namespace isn't held up by it.
+    Assertions.assertFalse(cache.find("ns2", "b").isPresent());
+    release.countDown();
+
+    firstFind.join(10_000);
+    secondFind.join(10_000);
+    Assertions.assertTrue(first.get().isPresent());
+    Assertions.assertTrue(second.get().isPresent());
+    // ns1 once, and ns2.
+    Assertions.assertEquals(2, lists.get());
+  }
+
+  @Test
+  public void testErrorWhileListing() {
+    AtomicInteger lists = new AtomicInteger();
+    NamespacedResourceCache<ConfigMap> cache =
+        new NamespacedResourceCache<>(
+            100L,
+            namespace -> {
+              if (lists.incrementAndGet() == 1) {
+                throw new NoClassDefFoundError("boom");
+              }
+              return ImmutableList.of(resource(namespace, "a"));
+            },
+            e -> false,
+            now::get);
+
+    Assertions.assertThrows(NoClassDefFoundError.class, () -> 
cache.find("ns1", "a"));
+    // The failed listing isn't left behind, the next query lists again 
instead of waiting.
+    Assertions.assertTimeoutPreemptively(
+        Duration.ofSeconds(10), () -> Assertions.assertTrue(cache.find("ns1", 
"a").isPresent()));
+    Assertions.assertEquals(2, lists.get());
+  }
+
+  private static ConfigMap resource(String namespace, String name) {
+    return new ConfigMapBuilder()
+        .withNewMetadata()
+        .withNamespace(namespace)
+        .withName(name)
+        .endMetadata()
+        .build();
+  }
+}
diff --git 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestPodLogUtils.java
 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestPodLogUtils.java
new file mode 100644
index 0000000000..bdee1d4f98
--- /dev/null
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/TestPodLogUtils.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.job.k8s;
+
+import com.google.common.collect.ImmutableList;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class TestPodLogUtils {
+
+  @Test
+  public void testEmpty() throws IOException {
+    Assertions.assertEquals(ImmutableList.of(), read("", 10, 100));
+  }
+
+  @Test
+  public void testWithinLimits() throws IOException {
+    Assertions.assertEquals(ImmutableList.of("a", "b"), read("a\nb\n", 10, 
100));
+    Assertions.assertEquals(ImmutableList.of("a", "", "b"), read("a\r\n\r\nb", 
10, 100));
+  }
+
+  @Test
+  public void testMaxLines() throws IOException {
+    Assertions.assertEquals(ImmutableList.of("b", "c"), read("a\nb\nc\n", 2, 
100));
+  }
+
+  @Test
+  public void testMaxBytesDropsPartialLine() throws IOException {
+    // The last 6 bytes are "bb\ncc\n", "bb" is a partial line.
+    Assertions.assertEquals(ImmutableList.of("cc"), read("aaa\nbbbb\ncc\n", 
10, 6));
+    // The last 8 bytes are "bbbb\ncc\n", which start right after a newline.
+    Assertions.assertEquals(ImmutableList.of("bbbb", "cc"), 
read("aaa\nbbbb\ncc\n", 10, 8));
+  }
+
+  @Test
+  public void testMaxBytesKeepsSingleLongLine() throws IOException {
+    Assertions.assertEquals(ImmutableList.of("6789"), read("0123456789\n", 10, 
5));
+  }
+
+  @Test
+  public void testMaxBytesSkipsPartialCharacter() throws IOException {
+    // "é" is 2 bytes in UTF-8, the window starts at its second byte.
+    Assertions.assertEquals(ImmutableList.of("xy"), read("éxy", 10, 3));
+  }
+
+  @Test
+  public void testWindowOnlyHoldsLineTerminator() throws IOException {
+    Assertions.assertEquals(ImmutableList.of(), read("abc\n", 10, 1));
+    Assertions.assertEquals(ImmutableList.of(), read("abc\r\n", 10, 1));
+    Assertions.assertEquals(ImmutableList.of(), read("abc\r\n", 10, 2));
+    // An empty last line is a line of its own.
+    Assertions.assertEquals(ImmutableList.of(""), read("abc\n\n", 10, 1));
+  }
+
+  @Test
+  public void testHugeMaxBytes() throws IOException {
+    for (int maxBytes : new int[] {(1 << 30) - 1, 1 << 30, Integer.MAX_VALUE}) 
{
+      Assertions.assertEquals(ImmutableList.of("a", "b"), read("a\nb\n", 10, 
maxBytes));
+    }
+  }
+
+  @Test
+  public void testLongLog() throws IOException {
+    String log =
+        IntStream.range(0, 100_000).mapToObj(i -> "line-" + 
i).collect(Collectors.joining("\n"));
+    Assertions.assertEquals(
+        ImmutableList.of("line-99998", "line-99999"),
+        read(log, 1000, "line-99998\nline-99999".length()));
+    Assertions.assertEquals(ImmutableList.of("line-99999"), read(log, 1, 
100_000));
+    List<String> lines = read(log, 1000, 1 << 20);
+    Assertions.assertEquals(1000, lines.size());
+    Assertions.assertEquals("line-99000", lines.get(0));
+  }
+
+  private static List<String> read(String log, int maxLines, int maxBytes) 
throws IOException {
+    return PodLogUtils.readLastLines(
+        new ByteArrayInputStream(log.getBytes(StandardCharsets.UTF_8)), 
maxLines, maxBytes);
+  }
+}
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 464bdec9cd..499a94ed5d 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
@@ -90,6 +90,30 @@ public class TestSparkApplicationStatusUtils {
             .status());
   }
 
+  @Test
+  public void testUnreadableStatus() {
+    // A status without any state this version can read, for example of a 
newer operator, keeps
+    // the job unfinished after the no-status timeout.
+    GenericKubernetesResource app = app();
+    app.setAdditionalProperty("status", ImmutableMap.of("phase", "Running"));
+    Assertions.assertTrue(SparkApplicationStatusUtils.hasStatus(app));
+    Assertions.assertFalse(SparkApplicationStatusUtils.hasState(app));
+    Assertions.assertEquals(
+        JobHandle.Status.QUEUED,
+        SparkApplicationStatusUtils.executionInfoOf(
+                app, null, CREATED_AT.plusMillis(NO_STATUS_TIMEOUT_MS + 1), 
NO_STATUS_TIMEOUT_MS)
+            .status());
+
+    // An empty status is no status.
+    app.setAdditionalProperty("status", ImmutableMap.of());
+    Assertions.assertFalse(SparkApplicationStatusUtils.hasStatus(app));
+    Assertions.assertEquals(
+        JobHandle.Status.FAILED,
+        SparkApplicationStatusUtils.executionInfoOf(
+                app, null, CREATED_AT.plusMillis(NO_STATUS_TIMEOUT_MS + 1), 
NO_STATUS_TIMEOUT_MS)
+            .status());
+  }
+
   @Test
   public void testTimes() {
     JobExecutionInfo queued = executionInfoOf(app("Submitted", 
"DriverRequested"));
@@ -296,12 +320,40 @@ public class TestSparkApplicationStatusUtils {
     Assertions.assertEquals(timeOf(1), cancelled.startedAt());
     Assertions.assertEquals(deletedAt, cancelled.finishedAt());
 
-    // The application stopped before the deletion.
+    // The application stopped by itself after it was marked, but before the 
deletion.
     GenericKubernetesResource stopped = app("Submitted", "DriverReady", 
"Failed");
     cancel(stopped);
+    stopped.getMetadata().setDeletionTimestamp(timeOf(3).toString());
     JobExecutionInfo info = executionInfoOf(stopped);
-    Assertions.assertEquals(JobHandle.Status.CANCELLED, info.status());
+    Assertions.assertEquals(JobHandle.Status.FAILED, info.status());
     Assertions.assertEquals(timeOf(2), info.finishedAt());
+
+    // An outcome recorded in the second of the deletion counts as after it.
+    GenericKubernetesResource sameSecond = app("Submitted", "DriverReady", 
"Failed");
+    cancel(sameSecond);
+    
sameSecond.getMetadata().setDeletionTimestamp(CREATED_AT.plusSeconds(2).toString());
+    Assertions.assertEquals(JobHandle.Status.CANCELLED, 
executionInfoOf(sameSecond).status());
+  }
+
+  @Test
+  public void testCancelMarkWithoutDeletion() {
+    // A cancellation that failed after marking the job, for example because 
its response was
+    // lost, leaves the mark. The job keeps running and keeps the outcome it 
reaches.
+    GenericKubernetesResource succeeded = app("Submitted", "DriverReady", 
"Succeeded");
+    cancel(succeeded);
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
executionInfoOf(succeeded).status());
+
+    GenericKubernetesResource failed = app("Submitted", "DriverReady", 
"Failed");
+    cancel(failed);
+    Assertions.assertEquals(JobHandle.Status.FAILED, 
executionInfoOf(failed).status());
+
+    GenericKubernetesResource running = app("Submitted", "DriverReady", 
"RunningHealthy");
+    cancel(running);
+    Assertions.assertEquals(JobHandle.Status.STARTED, 
executionInfoOf(running).status());
+
+    // Deleted by its TTL after it succeeded, it still succeeded.
+    succeeded.getMetadata().setDeletionTimestamp(NOW.toString());
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
executionInfoOf(succeeded).status());
   }
 
   @Test
@@ -420,18 +472,41 @@ public class TestSparkApplicationStatusUtils {
     Assertions.assertEquals(JobHandle.Status.FAILED, failed.status());
     Assertions.assertEquals(timeOf(2), failed.finishedAt());
 
+    // Stopped before it was cancelled, it isn't cancelled.
     cancel(app);
-    JobExecutionInfo cancelled = executionInfoOf(app);
-    Assertions.assertEquals(JobHandle.Status.CANCELLED, cancelled.status());
-    Assertions.assertEquals(timeOf(2), cancelled.finishedAt());
+    app.getMetadata().setDeletionTimestamp(NOW.toString());
+    Assertions.assertEquals(JobHandle.Status.FAILED, 
executionInfoOf(app).status());
+    Assertions.assertEquals(timeOf(2), executionInfoOf(app).finishedAt());
 
     GenericKubernetesResource stopped =
         app("Submitted", "DriverReady", "Succeeded", 
"TerminatedWithoutReleaseResources");
     cancel(stopped);
-    Assertions.assertEquals(JobHandle.Status.CANCELLED, 
executionInfoOf(stopped).status());
+    stopped.getMetadata().setDeletionTimestamp(NOW.toString());
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
executionInfoOf(stopped).status());
     Assertions.assertEquals(timeOf(2), executionInfoOf(stopped).finishedAt());
   }
 
+  @Test
+  public void testStoppedStateWithoutTime() {
+    GenericKubernetesResource app = app("Submitted", "DriverReady", 
"ResourceReleased");
+    history(app)
+        .put(
+            "2",
+            ImmutableMap.of(
+                "currentStateSummary", "ResourceReleased", 
"lastTransitionTime", "soon"));
+    status(app).put("currentState", history(app).get("2"));
+    app.getMetadata().setDeletionTimestamp(NOW.toString());
+
+    // Deleted after it stopped at an unknown time, it finished when it was 
deleted at the latest.
+    JobExecutionInfo info = executionInfoOf(app);
+    Assertions.assertEquals(JobHandle.Status.FAILED, info.status());
+    Assertions.assertEquals(NOW, info.finishedAt());
+
+    // Nor is it cancelled.
+    cancel(app);
+    Assertions.assertEquals(JobHandle.Status.FAILED, 
executionInfoOf(app).status());
+  }
+
   @Test
   public void testDeletionNotBeforeStart() {
     // The deletion time is in whole seconds, the started time of the same 
second isn't.
@@ -616,6 +691,14 @@ public class TestSparkApplicationStatusUtils {
     Assertions.assertEquals(timeOf(1), info.startedAt());
     Assertions.assertNull(info.finishedAt());
 
+    // Marked and deleted, it had still stopped before, as its outcome time is 
unknown. It finished
+    // when it was deleted at the latest.
+    cancel(app);
+    app.getMetadata().setDeletionTimestamp(NOW.toString());
+    info = executionInfoOf(app);
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, info.status());
+    Assertions.assertEquals(NOW, info.finishedAt());
+
     // Without a creation time, an application without status never times out.
     GenericKubernetesResource noCreationTime = app();
     noCreationTime.getMetadata().setCreationTimestamp(null);
diff --git 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
index 71e3abb38b..e9fb7b8a09 100644
--- 
a/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
+++ 
b/plugins/k8s-job-executor/src/test/java/org/apache/gravitino/job/k8s/spark/TestSparkApplicationUtils.java
@@ -38,6 +38,35 @@ public class TestSparkApplicationUtils {
 
   private final K8sJobExecutorConfigs configs = newConfigs(ImmutableMap.of());
 
+  @Test
+  public void testRejectClientDeployMode() {
+    SparkJobTemplate template =
+        SparkJobTemplate.builder()
+            .withName("user-job")
+            .withExecutable("s3a://bucket/app.jar")
+            .withClassName("org.example.App")
+            .withConfigs(ImmutableMap.of("spark.submit.deployMode", " Client 
"))
+            .build();
+    IllegalArgumentException e =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> SparkApplicationUtils.buildSparkApplication(CONTEXT, 
template, configs));
+    Assertions.assertTrue(e.getMessage().contains("Client"), e.getMessage());
+
+    // Nor set by the executor configurations.
+    K8sJobExecutorConfigs clientMode =
+        newConfigs(ImmutableMap.of("spark.conf.spark.submit.deployMode", 
"client"));
+    SparkJobTemplate noMode =
+        SparkJobTemplate.builder()
+            .withName("user-job")
+            .withExecutable("s3a://bucket/app.jar")
+            .withClassName("org.example.App")
+            .build();
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () -> SparkApplicationUtils.buildSparkApplication(CONTEXT, noMode, 
clientMode));
+  }
+
   @Test
   @SuppressWarnings("unchecked")
   public void testBuildSparkApplication() {
@@ -53,7 +82,7 @@ public class TestSparkApplicationUtils {
             .withConfigs(
                 ImmutableMap.of(
                     "spark.master", "local[*]",
-                    "spark.submit.deployMode", "client",
+                    "spark.submit.deployMode", "cluster",
                     "spark.executor.instances", "2",
                     "spark.jars", "https://repo/other.jar";))
             .build();

Reply via email to