This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 6f6050d776 [Cherry-pick to branch-1.3] [#13146] fix(core): Track local 
jobs only by their owning executor in multi-node deployments (#13147) (#13164)
6f6050d776 is described below

commit 6f6050d77659c31294f31f4cd5c0e8649201eb81
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Sep 15 13:55:19 2026 +0800

    [Cherry-pick to branch-1.3] [#13146] fix(core): Track local jobs only by 
their owning executor in multi-node deployments (#13147) (#13164)
    
    **Cherry-pick Information:**
    - Original commit: 8d2c01bd111676a4b03e54b00b3d2472756e5a3d
    - Target branch: `branch-1.3`
    - Status: ✅ **Conflicts resolved**
    
    Conflicts in `JobManager.java` and `TestJobManager.java` were resolved
    by keeping the changes from the original commit. Code depending on
    features that are not in `branch-1.3` was removed:
    `OptimisticLockException`, `JobEntity#startedAt`/`runtimeJobTemplate`
    and `JobHandle#finishedAt`, as well as an unrelated OCC cleanup test
    pulled in by the conflict block.
    
    `TestJobManagerMultiNode` now restores the `GravitinoEnv` config and
    lock manager after it runs, since `MetricsSource` on `branch-1.3` still
    reads the global config and the metrics tests running later failed
    otherwise.
    
    Validation on `branch-1.3`: `./gradlew :core:test -PskipITs` (1579
    tests) and `JobIT` pass.
    
    ---------
    
    Co-authored-by: Jerry Shao <[email protected]>
    Co-authored-by: Claude Opus 5 <[email protected]>
    Co-authored-by: Jerry Shao <[email protected]>
---
 .../gravitino/client/integration/test/JobIT.java   |  99 +++++-
 .../gravitino/connector/job/JobExecutor.java       |  32 ++
 .../java/org/apache/gravitino/job/JobManager.java  | 307 +++++++++++------
 .../gravitino/job/local/LocalJobExecutor.java      |  35 +-
 .../org/apache/gravitino/job/TestJobManager.java   | 367 +++++++++++++++++++++
 .../gravitino/job/TestJobManagerMultiNode.java     | 306 +++++++++++++++++
 .../gravitino/job/local/TestLocalJobExecutor.java  |  83 +++++
 docs/manage-jobs-in-gravitino.md                   |  38 ++-
 8 files changed, 1163 insertions(+), 104 deletions(-)

diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JobIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JobIT.java
index 8681e5cd25..cea050cf55 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JobIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/JobIT.java
@@ -21,29 +21,39 @@ package org.apache.gravitino.client.integration.test;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import java.io.File;
+import java.io.IOException;
 import java.nio.file.Files;
+import java.time.Duration;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
 import org.apache.commons.io.FileUtils;
+import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.client.GravitinoMetalake;
 import org.apache.gravitino.exceptions.JobTemplateAlreadyExistsException;
 import org.apache.gravitino.exceptions.NoSuchJobException;
 import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
 import org.apache.gravitino.integration.test.util.BaseIT;
 import org.apache.gravitino.integration.test.util.GravitinoITUtils;
+import org.apache.gravitino.integration.test.util.ITUtils;
 import org.apache.gravitino.job.JobHandle;
 import org.apache.gravitino.job.JobTemplate;
 import org.apache.gravitino.job.JobTemplateChange;
 import org.apache.gravitino.job.ShellJobTemplate;
 import org.apache.gravitino.job.SparkJobTemplate;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.JobEntity;
+import org.apache.gravitino.utils.NamespaceUtil;
 import org.awaitility.Awaitility;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -52,6 +62,16 @@ public class JobIT extends BaseIT {
 
   private static final String METALAKE_NAME = 
GravitinoITUtils.genRandomName("job_it_metalake");
 
+  private static final long STATUS_PULL_INTERVAL_IN_MS = 3000L;
+
+  // Finished jobs, and active jobs not updated, for this long are cleaned up. 
The cleanup runs
+  // every tenth of it.
+  private static final long JOB_KEEP_TIME_IN_MS = 60_000L;
+
+  // A job execution id that no job executor instance of this server owns, as 
if the job was
+  // submitted by the local job executor of another Gravitino server sharing 
the metadata store.
+  private static final String OTHER_SERVER_EXECUTION_ID_PREFIX = 
"local-job-otherserver-";
+
   private File testStagingDir;
   private File testSparkHome;
   private String testEntryScriptPath;
@@ -83,7 +103,9 @@ public class JobIT extends BaseIT {
             "gravitino.job.stagingDir",
             testStagingDir.getAbsolutePath(),
             "gravitino.job.statusPullIntervalInMs",
-            "3000",
+            String.valueOf(STATUS_PULL_INTERVAL_IN_MS),
+            "gravitino.job.stagingDirKeepTimeInMs",
+            String.valueOf(JOB_KEEP_TIME_IN_MS),
             "gravitino.jobExecutor.local.sparkHome",
             testSparkHome.getAbsolutePath());
     registerCustomConfigs(configs);
@@ -458,6 +480,81 @@ public class JobIT extends BaseIT {
         NoSuchJobException.class, () -> 
metalake.cancelJob("non_existent_job_id"));
   }
 
+  @Test
+  public void testJobOwnedByAnotherServerIsNotFailed() throws Exception {
+    Assumptions.assumeTrue(
+        ITUtils.EMBEDDED_TEST_MODE.equals(testMode),
+        "Simulating another server needs direct access to the server's 
metadata store");
+    JobTemplate template = builder.withName("test_other_server_job").build();
+    metalake.registerJobTemplate(template);
+
+    // Another server's job can't be found in this server's job executor, but 
this server must not
+    // mark it as FAILED when pulling job statuses.
+    JobEntity job = insertOtherServerJob(template.name(), 
JobHandle.Status.STARTED, Instant.now());
+    Awaitility.await()
+        .during(STATUS_PULL_INTERVAL_IN_MS * 2, TimeUnit.MILLISECONDS)
+        .atMost(STATUS_PULL_INTERVAL_IN_MS * 2 + 1000, TimeUnit.MILLISECONDS)
+        .until(() -> metalake.getJob(job.name()).jobStatus() == 
JobHandle.Status.STARTED);
+  }
+
+  @Test
+  public void testCancelJobOwnedByAnotherServer() throws Exception {
+    Assumptions.assumeTrue(
+        ITUtils.EMBEDDED_TEST_MODE.equals(testMode),
+        "Simulating another server needs direct access to the server's 
metadata store");
+    JobTemplate template = 
builder.withName("test_other_server_cancel").build();
+    metalake.registerJobTemplate(template);
+    JobEntity job = insertOtherServerJob(template.name(), 
JobHandle.Status.STARTED, Instant.now());
+
+    // This server can't cancel another server's job, so it marks the job as 
CANCELLING for its
+    // owner to cancel, instead of failing the request.
+    JobHandle cancellingJob = metalake.cancelJob(job.name());
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, 
cancellingJob.jobStatus());
+    Awaitility.await()
+        .during(STATUS_PULL_INTERVAL_IN_MS * 2, TimeUnit.MILLISECONDS)
+        .atMost(STATUS_PULL_INTERVAL_IN_MS * 2 + 1000, TimeUnit.MILLISECONDS)
+        .until(() -> metalake.getJob(job.name()).jobStatus() == 
JobHandle.Status.CANCELLING);
+  }
+
+  @Test
+  public void testStaleActiveJobIsMarkedFailed() throws Exception {
+    Assumptions.assumeTrue(
+        ITUtils.EMBEDDED_TEST_MODE.equals(testMode),
+        "Simulating another server needs direct access to the server's 
metadata store");
+    JobTemplate template = builder.withName("test_stale_job").build();
+    metalake.registerJobTemplate(template);
+
+    // The job was left behind by a server that exited long ago, so nobody 
updates it anymore.
+    JobEntity job =
+        insertOtherServerJob(
+            template.name(), JobHandle.Status.STARTED, 
Instant.now().minus(Duration.ofDays(30)));
+    Assertions.assertEquals(JobHandle.Status.STARTED, 
metalake.getJob(job.name()).jobStatus());
+
+    // The cleanup marks the job as FAILED, as it has not been updated for 
longer than the keep
+    // time. The failed job is kept for another keep time before being removed.
+    Awaitility.await()
+        .atMost(1, TimeUnit.MINUTES)
+        .until(() -> metalake.getJob(job.name()).jobStatus() == 
JobHandle.Status.FAILED);
+  }
+
+  private JobEntity insertOtherServerJob(
+      String templateName, JobHandle.Status status, Instant createTime) throws 
IOException {
+    long jobId = GravitinoEnv.getInstance().idGenerator().nextId();
+    JobEntity job =
+        JobEntity.builder()
+            .withId(jobId)
+            .withJobExecutionId(OTHER_SERVER_EXECUTION_ID_PREFIX + 
UUID.randomUUID())
+            .withJobTemplateName(templateName)
+            .withStatus(status)
+            .withNamespace(NamespaceUtil.ofJob(METALAKE_NAME))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("test").withCreateTime(createTime).build())
+            .withFinishedAt(0L)
+            .build();
+    GravitinoEnv.getInstance().entityStore().put(job, false /* overwrite */);
+    return job;
+  }
+
   private String generateTestEntryScript() {
     String content =
         "#!/bin/bash\n"
diff --git 
a/core/src/main/java/org/apache/gravitino/connector/job/JobExecutor.java 
b/core/src/main/java/org/apache/gravitino/connector/job/JobExecutor.java
index 40eacac9de..e14fb6dce1 100644
--- a/core/src/main/java/org/apache/gravitino/connector/job/JobExecutor.java
+++ b/core/src/main/java/org/apache/gravitino/connector/job/JobExecutor.java
@@ -80,4 +80,36 @@ public interface JobExecutor extends Closeable {
    * @throws NoSuchJobException If the job with the given identifier does not 
exist.
    */
   void cancelJob(String jobId) throws NoSuchJobException;
+
+  /**
+   * Whether the job with the given identifier is owned by this job executor 
instance, which means
+   * this instance is able to query and cancel it.
+   *
+   * <p>In a multi-node deployment every Gravitino server has its own job 
executor instance, and all
+   * of them see the same jobs from the shared metadata store. Gravitino only 
queries the status of,
+   * or cancels, a job through the executor instance that owns it. The default 
implementation
+   * returns {@code true}, which fits job executors backed by an external job 
runner that any
+   * Gravitino server can reach.
+   *
+   * @param jobId The unique identifier of the job.
+   * @return {@code true} if this executor instance owns the job, {@code 
false} otherwise.
+   */
+  default boolean ownsJob(String jobId) {
+    return true;
+  }
+
+  /**
+   * Whether the job state is only kept by the executor instance that owns the 
job, for example the
+   * processes launched on the local node. If so, only the owning instance can 
cancel the job, so a
+   * cancellation requested on another Gravitino server is carried out by the 
owner when it pulls
+   * the job status.
+   *
+   * <p>The default implementation returns {@code false}.
+   *
+   * @return {@code true} if the job state is local to the owning executor 
instance, {@code false}
+   *     otherwise.
+   */
+  default boolean isJobStateNodeLocal() {
+    return false;
+  }
 }
diff --git a/core/src/main/java/org/apache/gravitino/job/JobManager.java 
b/core/src/main/java/org/apache/gravitino/job/JobManager.java
index 501eb88a9c..bd6e8ecc8e 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -28,6 +28,7 @@ import java.io.IOException;
 import java.net.URI;
 import java.nio.file.Files;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
@@ -35,6 +36,8 @@ import java.util.Optional;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -498,12 +501,32 @@ public class JobManager implements JobOperationDispatcher 
{
       return jobEntity;
     }
 
-    // Cancel the job using the job executor
-    try {
-      jobExecutor.cancelJob(jobEntity.jobExecutionId());
-    } catch (Exception e) {
-      throw new RuntimeException(
-          String.format("Failed to cancel job with ID %s under metalake %s", 
jobId, metalake), e);
+    // Cancel the job using the job executor if this server owns the job. 
Otherwise, the job runs
+    // on another server, so only mark it as CANCELLING below, and the owning 
server cancels it
+    // when it pulls the job status next time.
+    if (jobExecutor.ownsJob(jobEntity.jobExecutionId())) {
+      try {
+        jobExecutor.cancelJob(jobEntity.jobExecutionId());
+      } catch (NoSuchJobException e) {
+        // The job is lost by the job executor, mark it as CANCELLING and the 
status pull will
+        // settle it as CANCELLED.
+        LOG.warn(
+            "Job {} with execution id {} under metalake {} is not found in the 
job executor, "
+                + "marking it as CANCELLING",
+            jobId,
+            jobEntity.jobExecutionId(),
+            metalake);
+      } catch (Exception e) {
+        throw new RuntimeException(
+            String.format("Failed to cancel job with ID %s under metalake %s", 
jobId, metalake), e);
+      }
+    } else {
+      LOG.info(
+          "Job {} with execution id {} under metalake {} is owned by another 
job executor "
+              + "instance, marking it as CANCELLING for the owner to cancel 
it",
+          jobId,
+          jobEntity.jobExecutionId(),
+          metalake);
     }
 
     // Update the job status to CANCELING
@@ -591,85 +614,11 @@ public class JobManager implements JobOperationDispatcher 
{
 
       activeJobs.forEach(
           job -> {
-            JobHandle.Status newStatus = job.status();
-            try {
-              newStatus = jobExecutor.getJobStatus(job.jobExecutionId());
-            } catch (NoSuchJobException e) {
-              // If the job is not found in the external job executor, we 
assume the job is
-              // FAILED if it is not in CANCELLING status, otherwise we assume 
it is CANCELLED.
-              if (job.status() == JobHandle.Status.CANCELLING) {
-                newStatus = JobHandle.Status.CANCELLED;
-              } else {
-                newStatus = JobHandle.Status.FAILED;
-              }
-              LOG.warn(
-                  "Job {} with execution id {} under metalake {} is not found 
in the "
-                      + "external job executor, marking it as {}. This could 
be due to the job "
-                      + "being deleted by the external job executor. Please 
check the external job "
-                      + "executor to know more details.",
-                  job.name(),
-                  job.jobExecutionId(),
-                  metalake,
-                  newStatus);
-            } catch (Exception e) {
-              LOG.error(
-                  "Failed to get job status for job {} by execution id {}",
-                  job.name(),
-                  job.jobExecutionId(),
-                  e);
-            }
-
-            if (newStatus != job.status()) {
-              // Update the job entity with new status. entityStore.update() 
re-fetches the
-              // latest entity itself right before applying the updater, so 
the transition below
-              // is derived from latestJobEntity - the state as of right 
before the write - rather
-              // than the possibly-stale `job` snapshot taken by listJobs() 
above. A concurrent
-              // writer (e.g. cancelJob(), or another poll run) may have 
already moved the job to
-              // a terminal state, into CANCELLING, or recorded a real 
finishedAt in the gap
-              // between that snapshot and this point; the updater must not 
regress any of that
-              // using the stale snapshot's view of the world.
-              JobHandle.Status finalNewStatus = newStatus;
-              JobEntity updated;
-              try {
-                updated =
-                    TreeLockUtils.doWithTreeLock(
-                        NameIdentifierUtil.ofJob(metalake, job.name()),
-                        LockType.WRITE,
-                        () -> {
-                          try {
-                            return entityStore.update(
-                                NameIdentifierUtil.ofJob(metalake, job.name()),
-                                JobEntity.class,
-                                Entity.EntityType.JOB,
-                                latestJobEntity ->
-                                    toUpdatedStatusJobEntity(latestJobEntity, 
finalNewStatus));
-                          } catch (IOException e) {
-                            throw new RuntimeException(
-                                String.format(
-                                    "Failed to update job entity %s to status 
%s",
-                                    job.name(), finalNewStatus),
-                                e);
-                          }
-                        });
-              } catch (NoSuchEntityException e) {
-                // The job could have been deleted concurrently (e.g. by 
legacy-timeline cleanup)
-                // in the gap between the listJobs() snapshot above and this 
update. Skip it rather
-                // than letting the exception escape this scheduled task, 
which would silently
-                // cancel all future status-pull runs 
(ScheduledExecutorService semantics).
-                LOG.warn(
-                    "Job {} under metalake {} no longer exists, skipping 
status update to {}. "
-                        + "This could be due to the job being deleted 
concurrently.",
-                    job.name(),
-                    metalake,
-                    finalNewStatus);
-                return;
-              }
-
-              LOG.info(
-                  "Updated the job {} with execution id {} status to {}",
-                  job.name(),
-                  job.jobExecutionId(),
-                  updated.status());
+            // Only the job executor instance owning the job can query its 
status. The jobs
+            // owned by other servers are skipped, and the jobs left behind by 
a server that has
+            // exited are settled by cleanUpStagingDirs() once they expire.
+            if (jobExecutor.ownsJob(job.jobExecutionId())) {
+              pullAndUpdateOwnedJobStatus(metalake, job);
             }
           });
     }
@@ -725,21 +674,27 @@ public class JobManager implements JobOperationDispatcher 
{
     List<String> metalakes = MetalakeManager.listInUseMetalakes(entityStore);
 
     for (String metalake : metalakes) {
-      List<JobEntity> finishedJobs =
-          listJobs(metalake, Optional.empty()).stream()
-              .filter(
-                  job ->
-                      job.status() == JobHandle.Status.CANCELLED
-                          || job.status() == JobHandle.Status.SUCCEEDED
-                          || job.status() == JobHandle.Status.FAILED)
-              .filter(
-                  job ->
-                      job.finishedAt() > 0
-                          && job.finishedAt() + jobStagingDirKeepTimeInMs
-                              < System.currentTimeMillis())
-              .toList();
+      long now = System.currentTimeMillis();
+      List<JobEntity> expiredJobs = new ArrayList<>();
+      for (JobEntity job : listJobs(metalake, Optional.empty())) {
+        if (isFinishedStatus(job.status())) {
+          if (job.finishedAt() > 0 && job.finishedAt() + 
jobStagingDirKeepTimeInMs < now) {
+            expiredJobs.add(job);
+          }
+        } else if (jobExecutor.isJobStateNodeLocal() && isStaleActiveJob(job, 
now)) {
+          // The state of a node local job is lost when the Gravitino server 
running it exits, so
+          // an active job that has not been updated for the whole retention 
time is considered
+          // left behind. Mark it as finished, and it is cleaned up once it 
expires as a finished
+          // job. Jobs of other job executors can be tracked by any server, so 
they never expire.
+          try {
+            expireStaleActiveJob(metalake, job, now);
+          } catch (RuntimeException e) {
+            LOG.error("Failed to expire job {} under metalake {}", job.name(), 
metalake, e);
+          }
+        }
+      }
 
-      finishedJobs.forEach(
+      expiredJobs.forEach(
           job -> {
             try {
               entityStore.delete(
@@ -1041,4 +996,158 @@ public class JobManager implements 
JobOperationDispatcher {
   private <T> T updatedValue(T currentValue, Optional<T> newValue) {
     return newValue.orElse(currentValue);
   }
+
+  private void pullAndUpdateOwnedJobStatus(String metalake, JobEntity job) {
+    JobHandle.Status newStatus = job.status();
+    try {
+      newStatus = jobExecutor.getJobStatus(job.jobExecutionId());
+      // The job was marked as CANCELLING by another server, which can't 
cancel it itself, so
+      // cancel it here as the owner. This only applies to node local job 
state, other job
+      // executors are cancelled directly by the server handling the request, 
and they may keep
+      // reporting the job as running while cancelling it asynchronously.
+      if (jobExecutor.isJobStateNodeLocal()
+          && job.status() == JobHandle.Status.CANCELLING
+          && (newStatus == JobHandle.Status.QUEUED || newStatus == 
JobHandle.Status.STARTED)) {
+        newStatus = cancelOwnedJob(metalake, job);
+      }
+    } catch (NoSuchJobException e) {
+      // If the job is not found in the external job executor, we assume the 
job is
+      // FAILED if it is not in CANCELLING status, otherwise we assume it is 
CANCELLED.
+      if (job.status() == JobHandle.Status.CANCELLING) {
+        newStatus = JobHandle.Status.CANCELLED;
+      } else {
+        newStatus = JobHandle.Status.FAILED;
+      }
+      LOG.warn(
+          "Job {} with execution id {} under metalake {} is not found in the "
+              + "external job executor, marking it as {}. This could be due to 
the job "
+              + "being deleted by the external job executor. Please check the 
external job "
+              + "executor to know more details.",
+          job.name(),
+          job.jobExecutionId(),
+          metalake,
+          newStatus);
+    } catch (Exception e) {
+      // Keep the job unchanged, and retry it in the next poll.
+      newStatus = job.status();
+      LOG.error(
+          "Failed to pull or cancel job {} by execution id {}",
+          job.name(),
+          job.jobExecutionId(),
+          e);
+    }
+
+    if (newStatus != job.status()) {
+      // Update the job entity with new status. entityStore.update() 
re-fetches the
+      // latest entity itself right before applying the updater, so the 
transition below
+      // is derived from latestJobEntity - the state as of right before the 
write - rather
+      // than the possibly-stale `job` snapshot taken by listJobs() above. A 
concurrent
+      // writer (e.g. cancelJob(), or another poll run) may have already moved 
the job to
+      // a terminal state, into CANCELLING, or recorded a real finishedAt in 
the
+      // gap between that snapshot and this point; the updater must not 
regress any of
+      // that using the stale snapshot's view of the world.
+      JobHandle.Status finalNewStatus = newStatus;
+      updateJobEntity(
+              metalake,
+              job,
+              latestJobEntity -> toUpdatedStatusJobEntity(latestJobEntity, 
finalNewStatus))
+          .ifPresent(
+              updated ->
+                  LOG.info(
+                      "Updated the job {} with execution id {} status to {}",
+                      job.name(),
+                      job.jobExecutionId(),
+                      updated.status()));
+    }
+  }
+
+  private JobHandle.Status cancelOwnedJob(String metalake, JobEntity job) {
+    LOG.info(
+        "Cancelling job {} with execution id {} under metalake {} as it is 
marked as CANCELLING",
+        job.name(),
+        job.jobExecutionId(),
+        metalake);
+    jobExecutor.cancelJob(job.jobExecutionId());
+    return jobExecutor.getJobStatus(job.jobExecutionId());
+  }
+
+  private Optional<JobEntity> updateJobEntity(
+      String metalake, JobEntity job, Function<JobEntity, JobEntity> updater) {
+    try {
+      return Optional.of(
+          TreeLockUtils.doWithTreeLock(
+              NameIdentifierUtil.ofJob(metalake, job.name()),
+              LockType.WRITE,
+              () -> {
+                try {
+                  return entityStore.update(
+                      NameIdentifierUtil.ofJob(metalake, job.name()),
+                      JobEntity.class,
+                      Entity.EntityType.JOB,
+                      updater);
+                } catch (IOException e) {
+                  throw new RuntimeException(
+                      String.format("Failed to update job entity %s", 
job.name()), e);
+                }
+              }));
+    } catch (NoSuchEntityException e) {
+      // The job could have been deleted concurrently (e.g. by legacy-timeline 
cleanup)
+      // in the gap between the listJobs() snapshot above and this update. 
Skip it rather
+      // than letting the exception escape this scheduled task, which would 
silently
+      // cancel all future status-pull runs (ScheduledExecutorService 
semantics).
+      LOG.warn(
+          "Job {} under metalake {} no longer exists, skipping the update. 
This could be due "
+              + "to the job being deleted concurrently.",
+          job.name(),
+          metalake);
+      return Optional.empty();
+    }
+  }
+
+  private void expireStaleActiveJob(String metalake, JobEntity job, long now) {
+    AtomicBoolean expired = new AtomicBoolean(false);
+    updateJobEntity(
+            metalake,
+            job,
+            latestJobEntity -> {
+              // The job may have been updated since the listJobs() snapshot.
+              if (!isStaleActiveJob(latestJobEntity, now)) {
+                return latestJobEntity;
+              }
+              expired.set(true);
+              // The expired job gets the current time as its finished time, 
so it's kept for
+              // another retention time like any other finished job before 
being cleaned up.
+              return toUpdatedStatusJobEntity(
+                  latestJobEntity,
+                  latestJobEntity.status() == JobHandle.Status.CANCELLING
+                      ? JobHandle.Status.CANCELLED
+                      : JobHandle.Status.FAILED);
+            })
+        .filter(expiredJob -> expired.get())
+        .ifPresent(
+            expiredJob ->
+                LOG.warn(
+                    "Job {} with execution id {} under metalake {} has not 
been updated for more "
+                        + "than {} ms, marking it as {}. This could be due to 
the Gravitino server "
+                        + "running the job having exited.",
+                    job.name(),
+                    job.jobExecutionId(),
+                    metalake,
+                    jobStagingDirKeepTimeInMs,
+                    expiredJob.status()));
+  }
+
+  private boolean isStaleActiveJob(JobEntity job, long now) {
+    return !isFinishedStatus(job.status())
+        && lastUpdatedTimeInMs(job) + jobStagingDirKeepTimeInMs < now;
+  }
+
+  private static long lastUpdatedTimeInMs(JobEntity job) {
+    AuditInfo auditInfo = job.auditInfo();
+    Instant lastUpdatedTime =
+        auditInfo.lastModifiedTime() != null
+            ? auditInfo.lastModifiedTime()
+            : auditInfo.createTime();
+    return lastUpdatedTime == null ? 0L : lastUpdatedTime.toEpochMilli();
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutor.java 
b/core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutor.java
index 295a0a8483..e441e7b207 100644
--- a/core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutor.java
+++ b/core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutor.java
@@ -37,6 +37,7 @@ import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadLocalRandom;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import org.apache.commons.lang3.tuple.Pair;
@@ -56,6 +57,12 @@ public class LocalJobExecutor implements JobExecutor {
 
   private static final long UNEXPIRED_TIME_IN_MS = -1L;
 
+  // A random id of this executor instance, it is embedded in every job id 
submitted by this
+  // instance, so that each Gravitino server only tracks the jobs it runs 
itself.
+  private String executorId;
+
+  private String ownedJobIdPrefix;
+
   private Map<String, String> configs;
 
   private BlockingQueue<Pair<String, JobTemplate>> waitingQueue;
@@ -80,6 +87,9 @@ public class LocalJobExecutor implements JobExecutor {
   @Override
   public void initialize(Map<String, String> configs) {
     this.configs = configs;
+    this.executorId = String.format("%08x", 
ThreadLocalRandom.current().nextInt());
+    this.ownedJobIdPrefix = LOCAL_JOB_PREFIX + executorId + "-";
+    LOG.info("Initializing local job executor with executor id {}", 
executorId);
 
     int waitingQueueSize =
         configs.containsKey(WAITING_QUEUE_SIZE)
@@ -109,9 +119,11 @@ public class LocalJobExecutor implements JobExecutor {
     Preconditions.checkArgument(
         maxRunningJobs > 0, "Max running jobs must be greater than 0, but got: 
%s", maxRunningJobs);
 
-    this.jobExecutorService =
+    // With an unbounded queue, the pool never grows beyond its core size, so 
the core size must be
+    // the max running jobs. Idle core threads are still allowed to time out.
+    ThreadPoolExecutor threadPoolExecutor =
         new ThreadPoolExecutor(
-            0,
+            maxRunningJobs,
             maxRunningJobs,
             60L,
             TimeUnit.SECONDS,
@@ -122,6 +134,8 @@ public class LocalJobExecutor implements JobExecutor {
               thread.setDaemon(true);
               return thread;
             });
+    threadPoolExecutor.allowCoreThreadTimeOut(true);
+    this.jobExecutorService = threadPoolExecutor;
 
     this.jobStatus = Maps.newHashMap();
 
@@ -171,7 +185,7 @@ public class LocalJobExecutor implements JobExecutor {
       SparkProcessBuilder.resolveSparkSubmit(configs);
     }
 
-    String newJobId = LOCAL_JOB_PREFIX + UUID.randomUUID();
+    String newJobId = ownedJobIdPrefix + UUID.randomUUID();
     Pair<String, JobTemplate> jobPair = Pair.of(newJobId, jobTemplate);
 
     synchronized (lock) {
@@ -240,6 +254,16 @@ public class LocalJobExecutor implements JobExecutor {
     }
   }
 
+  @Override
+  public boolean ownsJob(String jobId) {
+    return jobId != null && jobId.startsWith(ownedJobIdPrefix);
+  }
+
+  @Override
+  public boolean isJobStateNodeLocal() {
+    return true;
+  }
+
   @Override
   public void close() throws IOException {
     // Mark the executor as finished to stop processing jobs
@@ -340,6 +364,11 @@ public class LocalJobExecutor implements JobExecutor {
     }
   }
 
+  @VisibleForTesting
+  String executorId() {
+    return executorId;
+  }
+
   @VisibleForTesting
   void cleanupJobStatus() {
     long currentTime = System.currentTimeMillis();
diff --git a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java 
b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
index 6ffb8c2688..1d73004281 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -41,6 +41,7 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.time.Instant;
+import java.time.temporal.ChronoUnit;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
@@ -49,6 +50,7 @@ import java.util.UUID;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
+import javax.annotation.Nullable;
 import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.ArrayUtils;
 import org.apache.commons.lang3.reflect.FieldUtils;
@@ -68,6 +70,7 @@ import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchJobException;
 import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
 import org.apache.gravitino.exceptions.NoSuchMetalakeException;
+import org.apache.gravitino.job.local.LocalJobExecutor;
 import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
@@ -120,6 +123,8 @@ public class TestJobManager {
 
     entityStore = Mockito.mock(EntityStore.class);
     jobExecutor = Mockito.mock(JobExecutor.class);
+    // Mocks don't call the default interface methods, so stub the defaults 
explicitly.
+    when(jobExecutor.ownsJob(any())).thenReturn(true);
     idGenerator = new RandomIdGenerator();
     JobManager jm = new JobManager(config, entityStore, idGenerator, 
jobExecutor);
     jobManager = Mockito.spy(jm);
@@ -895,6 +900,302 @@ public class TestJobManager {
     Assertions.assertEquals(0L, result.finishedAt());
   }
 
+  @Test
+  public void testPullJobStatusSkipsJobOwnedByAnotherExecutor() throws 
IOException {
+    // The jobs are run by the job executor on another server, so this server 
must neither query
+    // their status (the local executor can't find them) nor touch the job 
entities, no matter how
+    // long they have not been updated.
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity freshJob =
+        newJobEntity("local-job-other-1", JobHandle.Status.QUEUED, 
Instant.now(), null);
+    JobEntity staleJob =
+        newJobEntity("local-job-other-2", JobHandle.Status.STARTED, longAgo, 
longAgo);
+    mockListActiveJobs(freshJob, staleJob);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(jobExecutor, never()).getJobStatus(any());
+    verify(entityStore, never())
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
+  }
+
+  @Test
+  public void 
testPullJobStatusCancelsOwnedJobMarkedCancellingByAnotherServer() throws 
IOException {
+    // A STARTED job marked as CANCELLING by another server is cancelled by 
its owner, and stays
+    // CANCELLING until the process exits, so there's nothing to update.
+    JobEntity startedJob =
+        newJobEntity("local-job-mine-1", JobHandle.Status.CANCELLING, 
Instant.now(), null);
+    mockListActiveJobs(startedJob);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    when(jobExecutor.getJobStatus(startedJob.jobExecutionId()))
+        .thenReturn(JobHandle.Status.STARTED, JobHandle.Status.CANCELLING);
+    stubEntityStoreUpdateToApply(startedJob);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(jobExecutor, times(1)).cancelJob(startedJob.jobExecutionId());
+    verify(entityStore, never())
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
+
+    // A QUEUED job marked as CANCELLING by another server is cancelled right 
away.
+    Mockito.clearInvocations(entityStore, jobExecutor);
+    JobEntity queuedJob =
+        newJobEntity("local-job-mine-2", JobHandle.Status.CANCELLING, 
Instant.now(), null);
+    mockListActiveJobs(queuedJob);
+    when(jobExecutor.getJobStatus(queuedJob.jobExecutionId()))
+        .thenReturn(JobHandle.Status.QUEUED, JobHandle.Status.CANCELLED);
+    stubEntityStoreUpdateToApply(queuedJob);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(jobExecutor, times(1)).cancelJob(queuedJob.jobExecutionId());
+    JobEntity cancelled = captureUpdatedJobEntity(queuedJob);
+    Assertions.assertEquals(JobHandle.Status.CANCELLED, cancelled.status());
+    Assertions.assertTrue(cancelled.finishedAt() > 0);
+  }
+
+  @Test
+  public void testPullJobStatusKeepsCancellingWhenOwnerFailsToCancel() throws 
IOException {
+    JobEntity job =
+        newJobEntity("local-job-mine-1", JobHandle.Status.CANCELLING, 
Instant.now(), null);
+    mockListActiveJobs(job);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    
when(jobExecutor.getJobStatus(job.jobExecutionId())).thenReturn(JobHandle.Status.STARTED);
+    doThrow(new RuntimeException("cancel 
failed")).when(jobExecutor).cancelJob(any());
+    stubEntityStoreUpdateToApply(job);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    // Nothing changes, and the next poll retries the cancellation.
+    verify(jobExecutor, times(1)).cancelJob(job.jobExecutionId());
+    verify(entityStore, never())
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
+  }
+
+  @Test
+  public void testPullJobStatusDoesNotRecancelJobOfNonNodeLocalExecutor() 
throws IOException {
+    // An external job executor is cancelled directly by the server handling 
the cancel request,
+    // and may keep reporting the job as running while cancelling it 
asynchronously. The status
+    // pull must not cancel it again on every poll.
+    JobEntity job =
+        newJobEntity("external-job-1", JobHandle.Status.CANCELLING, 
Instant.now(), null);
+    mockListActiveJobs(job);
+    
when(jobExecutor.getJobStatus(job.jobExecutionId())).thenReturn(JobHandle.Status.STARTED);
+    stubEntityStoreUpdateToApply(job);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(jobExecutor, never()).cancelJob(any());
+    // The observed STARTED status never regresses the CANCELLING job.
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, 
captureUpdatedJobEntity(job).status());
+  }
+
+  @Test
+  public void testPullJobStatusDoesNotCancelFinishedJobMarkedCancelling() 
throws IOException {
+    // The job finished before its owner noticed the cancellation request.
+    JobEntity job =
+        newJobEntity("local-job-mine-1", JobHandle.Status.CANCELLING, 
Instant.now(), null);
+    mockListActiveJobs(job);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    
when(jobExecutor.getJobStatus(job.jobExecutionId())).thenReturn(JobHandle.Status.SUCCEEDED);
+    stubEntityStoreUpdateToApply(job);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(jobExecutor, never()).cancelJob(any());
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
captureUpdatedJobEntity(job).status());
+  }
+
+  @Test
+  public void testCancelJobOwnedByAnotherExecutor() throws IOException {
+    mockedMetalake
+        .when(() -> MetalakeManager.checkMetalake(metalakeIdent, entityStore))
+        .thenAnswer(a -> null);
+
+    JobEntity job =
+        newJobEntity("local-job-other-1", JobHandle.Status.STARTED, 
Instant.now(), null);
+    doReturn(job).when(jobManager).getJob(metalake, job.name());
+    when(jobExecutor.ownsJob(job.jobExecutionId())).thenReturn(false);
+    stubEntityStoreUpdateToApply(job);
+
+    // The job runs on another server, so only mark it as CANCELLING for its 
owner to cancel.
+    JobEntity cancellingJob = jobManager.cancelJob(metalake, job.name());
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, 
cancellingJob.status());
+    verify(jobExecutor, never()).cancelJob(any());
+  }
+
+  @Test
+  public void testCancelJobNotFoundInOwnedExecutor() throws IOException {
+    mockedMetalake
+        .when(() -> MetalakeManager.checkMetalake(metalakeIdent, entityStore))
+        .thenAnswer(a -> null);
+
+    JobEntity job = newJobEntity("local-job-mine-1", JobHandle.Status.STARTED, 
Instant.now(), null);
+    doReturn(job).when(jobManager).getJob(metalake, job.name());
+    doThrow(new 
NoSuchJobException("lost")).when(jobExecutor).cancelJob(job.jobExecutionId());
+    stubEntityStoreUpdateToApply(job);
+
+    // The status pull later settles a job lost by the executor as CANCELLED.
+    JobEntity cancellingJob = jobManager.cancelJob(metalake, job.name());
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, 
cancellingJob.status());
+  }
+
+  @Test
+  public void testPullJobStatusAcrossServersWithLocalJobExecutors() throws 
Exception {
+    // Reproduces the multi-node deployment: two servers share the same 
metadata store, and each
+    // has its own local job executor. The server that didn't run the job must 
not mark it FAILED.
+    LocalJobExecutor ownerExecutor = new LocalJobExecutor();
+    LocalJobExecutor otherExecutor = new LocalJobExecutor();
+    ownerExecutor.initialize(Collections.emptyMap());
+    otherExecutor.initialize(Collections.emptyMap());
+    JobManager ownerManager =
+        Mockito.spy(new JobManager(config, entityStore, idGenerator, 
ownerExecutor));
+    JobManager otherManager =
+        Mockito.spy(new JobManager(config, entityStore, idGenerator, 
otherExecutor));
+    File jobStagingDir = 
Files.createTempDirectory("gravitino-test-multi-node-job").toFile();
+
+    try {
+      JobTemplate jobTemplate =
+          JobManager.createRuntimeJobTemplate(
+              newShellJobTemplateEntity("shell_job", "echo"),
+              Collections.emptyMap(),
+              jobStagingDir);
+      String executionId = ownerExecutor.submitJob(jobTemplate);
+      Awaitility.await()
+          .atMost(1, TimeUnit.MINUTES)
+          .until(() -> ownerExecutor.getJobStatus(executionId) == 
JobHandle.Status.SUCCEEDED);
+
+      JobEntity job =
+          newJobEntity(
+              idGenerator.nextId(), executionId, JobHandle.Status.QUEUED, 
Instant.now(), null);
+      mockedMetalake
+          .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+          .thenReturn(ImmutableList.of(metalake));
+      doReturn(ImmutableList.of(job)).when(otherManager).listJobs(metalake, 
Optional.empty());
+      doReturn(ImmutableList.of(job)).when(ownerManager).listJobs(metalake, 
Optional.empty());
+      stubEntityStoreUpdateToApply(job);
+
+      otherManager.pullAndUpdateJobStatus();
+      verify(entityStore, never())
+          .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), 
any());
+
+      ownerManager.pullAndUpdateJobStatus();
+      Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
captureUpdatedJobEntity(job).status());
+    } finally {
+      ownerManager.close();
+      otherManager.close();
+      FileUtils.deleteDirectory(jobStagingDir);
+    }
+  }
+
+  @Test
+  public void testCleanUpStagingDirsExpiresStaleActiveJobs() throws 
IOException {
+    // Active jobs that have not been updated for the whole retention time are 
left behind, e.g. by
+    // a server that exited while running them. They are marked as finished, 
and kept for another
+    // retention time before being cleaned up.
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity queuedJob = newJobEntity("local-job-gone-1", 
JobHandle.Status.QUEUED, longAgo, null);
+    JobEntity startedJob =
+        newJobEntity("local-job-gone-2", JobHandle.Status.STARTED, longAgo, 
longAgo);
+    JobEntity cancellingJob =
+        newJobEntity("local-job-gone-3", JobHandle.Status.CANCELLING, longAgo, 
longAgo);
+    JobEntity activeJob =
+        newJobEntity("local-job-mine-1", JobHandle.Status.STARTED, longAgo, 
Instant.now());
+    mockListActiveJobs(queuedJob, startedJob, cancellingJob, activeJob);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    for (JobEntity job : ImmutableList.of(queuedJob, startedJob, 
cancellingJob, activeJob)) {
+      stubEntityStoreUpdateToApply(job, job);
+    }
+    File jobStagingDir = new File(testStagingDir, metalake + "/shell_job/" + 
startedJob.name());
+    Assertions.assertTrue(jobStagingDir.mkdirs());
+
+    long beforeCleanUp = System.currentTimeMillis();
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    Assertions.assertEquals(
+        JobHandle.Status.FAILED, captureUpdatedJobEntity(queuedJob, 
queuedJob).status());
+    JobEntity expiredStartedJob = captureUpdatedJobEntity(startedJob, 
startedJob);
+    Assertions.assertEquals(JobHandle.Status.FAILED, 
expiredStartedJob.status());
+    // The expire time is used as the finished time, so the job is kept for 
another retention time.
+    Assertions.assertTrue(expiredStartedJob.finishedAt() >= beforeCleanUp);
+    Assertions.assertEquals(
+        JobHandle.Status.CANCELLED, captureUpdatedJobEntity(cancellingJob, 
cancellingJob).status());
+    verify(entityStore, never()).delete(any(), any());
+    Assertions.assertTrue(jobStagingDir.exists());
+
+    // A job updated recently is still active, so it's not expired.
+    verify(entityStore, never())
+        .update(
+            eq(NameIdentifierUtil.ofJob(metalake, activeJob.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            any());
+  }
+
+  @Test
+  public void testCleanUpStagingDirsDoesNotExpireJobOfNonNodeLocalExecutor() 
throws IOException {
+    // Any server can track the jobs of an external job executor, so they are 
never considered left
+    // behind, no matter how long their status has not changed.
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity job = newJobEntity("external-job-1", JobHandle.Status.STARTED, 
longAgo, longAgo);
+    mockListActiveJobs(job);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    verify(entityStore, never())
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
+    verify(entityStore, never()).delete(any(), any());
+  }
+
+  @Test
+  public void testCleanUpStagingDirsDoesNotExpireJobUpdatedConcurrently() 
throws IOException {
+    // listJobs() observes a stale job, but it is updated before 
entityStore.update() re-fetches
+    // it, so it's still active and must be kept.
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity staleSnapshot =
+        newJobEntity("local-job-other-1", JobHandle.Status.STARTED, longAgo, 
longAgo);
+    JobEntity latestUpdated =
+        newJobEntity(
+            staleSnapshot.id(),
+            staleSnapshot.jobExecutionId(),
+            JobHandle.Status.STARTED,
+            longAgo,
+            Instant.now());
+    mockListActiveJobs(staleSnapshot);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    stubEntityStoreUpdateToApply(latestUpdated);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    Assertions.assertSame(latestUpdated, 
captureUpdatedJobEntity(latestUpdated));
+    verify(entityStore, never()).delete(any(), any());
+  }
+
+  @Test
+  public void testCleanUpStagingDirsContinuesAfterFailingToExpireJob() throws 
IOException {
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity failingJob =
+        newJobEntity("local-job-gone-1", JobHandle.Status.STARTED, longAgo, 
longAgo);
+    JobEntity otherJob =
+        newJobEntity("local-job-gone-2", JobHandle.Status.STARTED, longAgo, 
longAgo);
+    mockListActiveJobs(failingJob, otherJob);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    when(entityStore.update(
+            eq(NameIdentifierUtil.ofJob(metalake, failingJob.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            any()))
+        .thenThrow(new IOException("store error"));
+    stubEntityStoreUpdateToApply(otherJob, otherJob);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    Assertions.assertEquals(
+        JobHandle.Status.FAILED, captureUpdatedJobEntity(otherJob, 
otherJob).status());
+  }
+
   @Test
   public void testCleanUpStagingDirs() throws IOException, 
InterruptedException {
     JobEntity job = newJobEntity("shell_job", JobHandle.Status.STARTED);
@@ -1202,6 +1503,44 @@ public class TestJobManager {
         .build();
   }
 
+  private void mockListActiveJobs(JobEntity... jobs) {
+    mockedMetalake
+        .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+        .thenReturn(ImmutableList.of(metalake));
+    doReturn(ImmutableList.copyOf(jobs)).when(jobManager).listJobs(metalake, 
Optional.empty());
+  }
+
+  private JobEntity newJobEntity(
+      String executionId,
+      JobHandle.Status status,
+      Instant createTime,
+      @Nullable Instant lastModifiedTime) {
+    return newJobEntity(idGenerator.nextId(), executionId, status, createTime, 
lastModifiedTime);
+  }
+
+  private JobEntity newJobEntity(
+      long id,
+      String executionId,
+      JobHandle.Status status,
+      Instant createTime,
+      @Nullable Instant lastModifiedTime) {
+    return JobEntity.builder()
+        .withId(id)
+        .withJobExecutionId(executionId)
+        .withNamespace(NamespaceUtil.ofJob(metalake))
+        .withJobTemplateName("shell_job")
+        .withStatus(status)
+        .withFinishedAt(0L)
+        .withAuditInfo(
+            AuditInfo.builder()
+                .withCreator("test")
+                .withCreateTime(createTime)
+                .withLastModifier(lastModifiedTime == null ? null : "modifier")
+                .withLastModifiedTime(lastModifiedTime)
+                .build())
+        .build();
+  }
+
   // cancelJob/pullAndUpdateJobStatus now go through entityStore.update(), 
which re-fetches the
   // latest entity and applies an updater function internally. Since 
entityStore is a full mock,
   // this stubs that re-fetch to hand back the given entity, mirroring what 
the real
@@ -1224,6 +1563,34 @@ public class TestJobManager {
     return captor.getValue().apply(latestJobEntity);
   }
 
+  @SuppressWarnings("unchecked")
+  private void stubEntityStoreUpdateToApply(JobEntity job, JobEntity 
latestJobEntity)
+      throws IOException {
+    when(entityStore.update(
+            eq(NameIdentifierUtil.ofJob(metalake, job.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            any()))
+        .thenAnswer(
+            invocation -> {
+              Function<JobEntity, JobEntity> updater = 
invocation.getArgument(3);
+              return updater.apply(latestJobEntity);
+            });
+  }
+
+  @SuppressWarnings("unchecked")
+  private JobEntity captureUpdatedJobEntity(JobEntity job, JobEntity 
latestJobEntity)
+      throws IOException {
+    ArgumentCaptor<Function<JobEntity, JobEntity>> captor = 
ArgumentCaptor.forClass(Function.class);
+    verify(entityStore, times(1))
+        .update(
+            eq(NameIdentifierUtil.ofJob(metalake, job.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            captor.capture());
+    return captor.getValue().apply(latestJobEntity);
+  }
+
   private HttpServer createLoopbackHttpServer(String response) throws 
IOException {
     HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 
0), 0);
     server.createContext(
diff --git 
a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java 
b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
new file mode 100644
index 0000000000..abed136a55
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
@@ -0,0 +1,306 @@
+/*
+ * 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;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.Configs;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.cache.NoOpsCache;
+import org.apache.gravitino.connector.job.JobExecutor;
+import org.apache.gravitino.exceptions.NoSuchJobException;
+import org.apache.gravitino.job.local.LocalJobExecutor;
+import org.apache.gravitino.lock.LockManager;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.JobEntity;
+import org.apache.gravitino.meta.JobTemplateEntity;
+import org.apache.gravitino.storage.RandomIdGenerator;
+import org.apache.gravitino.storage.relational.RelationalEntityStore;
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.utils.NameIdentifierUtil;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.TestTemplate;
+
+/**
+ * Tests the job system in a multi-node deployment: two Gravitino servers, 
each with its own {@link
+ * JobManager} and {@link LocalJobExecutor}, share the same relational 
metadata store. The status
+ * pull and the cleanup are triggered manually, so that the test controls 
which node runs them when.
+ */
+public class TestJobManagerMultiNode extends TestJDBCBackend {
+
+  private static final String METALAKE = "metalake_job_multi_node";
+
+  private static final String TEMPLATE = "sleep_job";
+
+  // Active jobs not updated for this long are expired, and finished jobs are 
cleaned up after it.
+  // The tests move the job timestamps back instead of waiting for it to 
elapse.
+  private static final long JOB_KEEP_TIME_IN_MS = TimeUnit.HOURS.toMillis(1);
+
+  private File testDir;
+
+  private LocalJobExecutor executorA;
+
+  private LocalJobExecutor executorB;
+
+  private JobManager nodeA;
+
+  private JobManager nodeB;
+
+  private EntityStore entityStore;
+
+  private Object originalConfig;
+
+  private Object originalLockManager;
+
+  @BeforeAll
+  public void saveGravitinoEnv() throws IllegalAccessException {
+    // The backend extension and this test replace the global config and lock 
manager, restore them
+    // afterwards so that other tests running in the same JVM are not affected.
+    originalConfig = FieldUtils.readField(GravitinoEnv.getInstance(), 
"config", true);
+    originalLockManager = FieldUtils.readField(GravitinoEnv.getInstance(), 
"lockManager", true);
+  }
+
+  @AfterAll
+  public void restoreGravitinoEnv() throws IllegalAccessException {
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "config", 
originalConfig, true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", 
originalLockManager, true);
+  }
+
+  @BeforeEach
+  public void setUpNodes() throws Exception {
+    testDir = 
Files.createTempDirectory("gravitino-test-job-multi-node").toFile();
+
+    Config config = new Config(false) {};
+    config.set(Configs.JOB_STAGING_DIR, new File(testDir, 
"staging").getAbsolutePath());
+    config.set(Configs.JOB_STAGING_DIR_KEEP_TIME_IN_MS, JOB_KEEP_TIME_IN_MS);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", new 
LockManager(config), true);
+
+    // Both nodes share the same metadata store, backed by the relational 
backend under test.
+    RelationalEntityStore relationalEntityStore = new RelationalEntityStore();
+    FieldUtils.writeField(relationalEntityStore, "backend", backend, true);
+    FieldUtils.writeField(relationalEntityStore, "cache", new 
NoOpsCache(config), true);
+    entityStore = relationalEntityStore;
+
+    createAndInsertMakeLake(METALAKE);
+    backend.insert(newSleepJobTemplateEntity(), false);
+
+    executorA = new LocalJobExecutor();
+    executorA.initialize(Collections.emptyMap());
+    executorB = new LocalJobExecutor();
+    executorB.initialize(Collections.emptyMap());
+    nodeA = newJobManager(config, entityStore, executorA);
+    nodeB = newJobManager(config, entityStore, executorB);
+  }
+
+  @AfterEach
+  public void tearDownNodes() throws IOException {
+    // Closing a node also kills the job processes it runs.
+    if (nodeA != null) {
+      nodeA.close();
+    }
+    if (nodeB != null) {
+      nodeB.close();
+    }
+    FileUtils.deleteDirectory(testDir);
+  }
+
+  @TestTemplate
+  public void testJobIsOnlyTrackedByItsOwnerNode() throws IOException {
+    JobEntity job = nodeA.runJob(METALAKE, TEMPLATE, 
ImmutableMap.of("seconds", "0"));
+    Assertions.assertTrue(executorA.ownsJob(job.jobExecutionId()));
+    Assertions.assertFalse(executorB.ownsJob(job.jobExecutionId()));
+    Awaitility.await()
+        .atMost(1, TimeUnit.MINUTES)
+        .until(() -> executorA.getJobStatus(job.jobExecutionId()) == 
JobHandle.Status.SUCCEEDED);
+
+    // Before the fix, node B couldn't find the job in its own executor and 
marked it as FAILED.
+    nodeB.pullAndUpdateJobStatus();
+    JobEntity afterNodeBPull = getJob(job.name());
+    Assertions.assertEquals(JobHandle.Status.QUEUED, afterNodeBPull.status());
+    Assertions.assertEquals(job.auditInfo(), afterNodeBPull.auditInfo());
+
+    nodeA.pullAndUpdateJobStatus();
+    JobEntity afterNodeAPull = getJob(job.name());
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
afterNodeAPull.status());
+    Assertions.assertTrue(afterNodeAPull.finishedAt() > 0);
+  }
+
+  @TestTemplate
+  public void testCancelJobFromAnotherNode() throws IOException {
+    JobEntity job = runLongJobOnNodeA();
+
+    // Node B can't cancel the job itself, so it only marks the job as 
CANCELLING.
+    JobEntity cancelling = nodeB.cancelJob(METALAKE, job.name());
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, cancelling.status());
+    Assertions.assertEquals(JobHandle.Status.STARTED, 
executorA.getJobStatus(job.jobExecutionId()));
+
+    // Node A cancels the job when it pulls the job status next time.
+    nodeA.pullAndUpdateJobStatus();
+    Awaitility.await()
+        .atMost(1, TimeUnit.MINUTES)
+        .until(() -> executorA.getJobStatus(job.jobExecutionId()) == 
JobHandle.Status.CANCELLED);
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, 
getJob(job.name()).status());
+
+    nodeA.pullAndUpdateJobStatus();
+    JobEntity cancelled = getJob(job.name());
+    Assertions.assertEquals(JobHandle.Status.CANCELLED, cancelled.status());
+    Assertions.assertTrue(cancelled.finishedAt() > 0);
+  }
+
+  @TestTemplate
+  public void testJobsLeftByExitedNodeAreExpiredByAnotherNode() throws 
Exception {
+    JobEntity startedJob = runLongJobOnNodeA();
+    // Cancel this job from node B, node A exits before cancelling it.
+    JobEntity cancellingJob = nodeA.runJob(METALAKE, TEMPLATE, 
ImmutableMap.of("seconds", "600"));
+    nodeB.cancelJob(METALAKE, cancellingJob.name());
+
+    // Node A exits, and nobody updates its jobs anymore.
+    nodeA.close();
+    nodeA = null;
+
+    // Neither the status pull nor the cleanup of node B touches the jobs 
before they expire.
+    nodeB.pullAndUpdateJobStatus();
+    nodeB.cleanUpStagingDirs();
+    Assertions.assertEquals(JobHandle.Status.STARTED, 
getJob(startedJob.name()).status());
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, 
getJob(cancellingJob.name()).status());
+
+    // Once the jobs have not been updated for the keep time, node B marks 
them as finished.
+    moveJobTimestampsBack(startedJob.name());
+    moveJobTimestampsBack(cancellingJob.name());
+    nodeB.cleanUpStagingDirs();
+    JobEntity failedJob = getJob(startedJob.name());
+    JobEntity cancelledJob = getJob(cancellingJob.name());
+    Assertions.assertEquals(JobHandle.Status.FAILED, failedJob.status());
+    Assertions.assertEquals(JobHandle.Status.CANCELLED, cancelledJob.status());
+
+    // The finished jobs are kept for another keep time, and then cleaned up.
+    nodeB.cleanUpStagingDirs();
+    Assertions.assertTrue(jobExists(startedJob.name()));
+    Assertions.assertTrue(jobExists(cancellingJob.name()));
+
+    moveJobTimestampsBack(startedJob.name());
+    moveJobTimestampsBack(cancellingJob.name());
+    nodeB.cleanUpStagingDirs();
+    Assertions.assertFalse(jobExists(startedJob.name()));
+    Assertions.assertFalse(jobExists(cancellingJob.name()));
+  }
+
+  private JobEntity runLongJobOnNodeA() throws IOException {
+    JobEntity job = nodeA.runJob(METALAKE, TEMPLATE, 
ImmutableMap.of("seconds", "600"));
+    Awaitility.await()
+        .atMost(1, TimeUnit.MINUTES)
+        .until(() -> executorA.getJobStatus(job.jobExecutionId()) == 
JobHandle.Status.STARTED);
+    nodeA.pullAndUpdateJobStatus();
+    Assertions.assertEquals(JobHandle.Status.STARTED, 
getJob(job.name()).status());
+    return job;
+  }
+
+  private JobEntity getJob(String jobName) {
+    // Read through node B, as it works the same from any node sharing the 
metadata store.
+    return nodeB.getJob(METALAKE, jobName);
+  }
+
+  // Simulates that the keep time has elapsed since the job was last updated, 
or finished.
+  private void moveJobTimestampsBack(String jobName) throws IOException {
+    long offsetInMs = JOB_KEEP_TIME_IN_MS + TimeUnit.MINUTES.toMillis(1);
+    entityStore.update(
+        NameIdentifierUtil.ofJob(METALAKE, jobName),
+        JobEntity.class,
+        Entity.EntityType.JOB,
+        job ->
+            JobEntity.builder()
+                .withId(job.id())
+                .withJobExecutionId(job.jobExecutionId())
+                .withJobTemplateName(job.jobTemplateName())
+                .withStatus(job.status())
+                .withNamespace(job.namespace())
+                .withAuditInfo(
+                    AuditInfo.builder()
+                        .withCreator(job.auditInfo().creator())
+                        
.withCreateTime(job.auditInfo().createTime().minusMillis(offsetInMs))
+                        .withLastModifier(job.auditInfo().lastModifier())
+                        .withLastModifiedTime(
+                            job.auditInfo().lastModifiedTime() == null
+                                ? null
+                                : 
job.auditInfo().lastModifiedTime().minusMillis(offsetInMs))
+                        .build())
+                .withFinishedAt(job.finishedAt() > 0 ? job.finishedAt() - 
offsetInMs : 0L)
+                .build());
+  }
+
+  private boolean jobExists(String jobName) {
+    try {
+      getJob(jobName);
+      return true;
+    } catch (NoSuchJobException e) {
+      return false;
+    }
+  }
+
+  private JobTemplateEntity newSleepJobTemplateEntity() throws IOException {
+    File script = new File(testDir, "sleep-job.sh");
+    // Exec the sleep, so that killing the job process also stops the sleep.
+    Files.writeString(script.toPath(), "#!/bin/bash\nexec sleep \"$1\"\n");
+    Assertions.assertTrue(script.setExecutable(true));
+
+    return JobTemplateEntity.builder()
+        .withId(RandomIdGenerator.INSTANCE.nextId())
+        .withName(TEMPLATE)
+        .withNamespace(NamespaceUtil.ofJobTemplate(METALAKE))
+        .withTemplateContent(
+            JobTemplateEntity.TemplateContent.builder()
+                .withJobType(JobTemplate.JobType.SHELL)
+                .withExecutable(script.getAbsolutePath())
+                .withArguments(Lists.newArrayList("{{seconds}}"))
+                .withEnvironments(Collections.emptyMap())
+                .withCustomFields(Collections.emptyMap())
+                .withScripts(Collections.emptyList())
+                .build())
+        .withAuditInfo(
+            
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+        .build();
+  }
+
+  private static JobManager newJobManager(
+      Config config, EntityStore entityStore, JobExecutor jobExecutor) {
+    JobManager jobManager =
+        new JobManager(config, entityStore, RandomIdGenerator.INSTANCE, 
jobExecutor);
+    // Stop the background schedulers, the test pulls the job status manually.
+    jobManager.statusPullExecutor.shutdownNow();
+    jobManager.cleanUpExecutor.shutdownNow();
+    return jobManager;
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/job/local/TestLocalJobExecutor.java 
b/core/src/test/java/org/apache/gravitino/job/local/TestLocalJobExecutor.java
index 45b49e01e4..ec5f4d58d8 100644
--- 
a/core/src/test/java/org/apache/gravitino/job/local/TestLocalJobExecutor.java
+++ 
b/core/src/test/java/org/apache/gravitino/job/local/TestLocalJobExecutor.java
@@ -27,12 +27,15 @@ import java.net.URL;
 import java.nio.file.Files;
 import java.util.Collections;
 import java.util.Map;
+import java.util.UUID;
 import java.util.concurrent.TimeUnit;
 import org.apache.commons.io.FileUtils;
 import org.apache.gravitino.connector.job.JobExecutor;
+import org.apache.gravitino.exceptions.NoSuchJobException;
 import org.apache.gravitino.job.JobHandle;
 import org.apache.gravitino.job.JobManager;
 import org.apache.gravitino.job.JobTemplate;
+import org.apache.gravitino.job.ShellJobTemplate;
 import org.apache.gravitino.job.SparkJobTemplate;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.JobTemplateEntity;
@@ -133,6 +136,67 @@ public class TestLocalJobExecutor {
     Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
jobExecutor.getJobStatus(jobId));
   }
 
+  @Test
+  public void testJobOwnership() throws IOException {
+    LocalJobExecutor executor = (LocalJobExecutor) jobExecutor;
+    Assertions.assertTrue(executor.isJobStateNodeLocal());
+    Assertions.assertTrue(executor.executorId().matches("[0-9a-f]{8}"));
+
+    JobTemplate template =
+        JobManager.createRuntimeJobTemplate(
+            jobTemplateEntity,
+            ImmutableMap.of("arg1", "value1", "arg2", "success", "var", 
"value3"),
+            workingDir);
+    String jobId = executor.submitJob(template);
+    Assertions.assertTrue(
+        jobId.matches("local-job-" + executor.executorId() + 
"-[0-9a-f-]{36}"), jobId);
+    Assertions.assertTrue(executor.ownsJob(jobId));
+
+    // Jobs submitted before the executor id was introduced aren't owned by 
any executor.
+    Assertions.assertFalse(executor.ownsJob("local-job-" + UUID.randomUUID()));
+    Assertions.assertFalse(executor.ownsJob(null));
+
+    LocalJobExecutor anotherExecutor = new LocalJobExecutor();
+    try {
+      anotherExecutor.initialize(Collections.emptyMap());
+      Assertions.assertNotEquals(executor.executorId(), 
anotherExecutor.executorId());
+      Assertions.assertFalse(anotherExecutor.ownsJob(jobId));
+      Assertions.assertThrows(NoSuchJobException.class, () -> 
anotherExecutor.getJobStatus(jobId));
+    } finally {
+      anotherExecutor.close();
+    }
+
+    Awaitility.await()
+        .atMost(3, TimeUnit.MINUTES)
+        .until(() -> executor.getJobStatus(jobId) == 
JobHandle.Status.SUCCEEDED);
+  }
+
+  @Test
+  public void testRunJobsConcurrentlyUpToMaxRunningJobs() throws IOException {
+    LocalJobExecutor executor = new LocalJobExecutor();
+    try {
+      
executor.initialize(ImmutableMap.of(LocalJobExecutorConfigs.MAX_RUNNING_JOBS, 
"2"));
+      String jobId1 = executor.submitJob(newSleepJobTemplate("sleep-1"));
+      String jobId2 = executor.submitJob(newSleepJobTemplate("sleep-2"));
+      String jobId3 = executor.submitJob(newSleepJobTemplate("sleep-3"));
+
+      // Up to maxRunningJobs jobs run at the same time, the others wait in 
the queue.
+      Awaitility.await()
+          .atMost(1, TimeUnit.MINUTES)
+          .until(
+              () ->
+                  executor.getJobStatus(jobId1) == JobHandle.Status.STARTED
+                      && executor.getJobStatus(jobId2) == 
JobHandle.Status.STARTED);
+      Awaitility.await()
+          .during(1, TimeUnit.SECONDS)
+          .atMost(2, TimeUnit.SECONDS)
+          .until(() -> executor.getJobStatus(jobId3) == 
JobHandle.Status.QUEUED);
+    } finally {
+      // Closing the executor kills the running jobs.
+      executor.close();
+    }
+  }
+
   @Test
   public void testSubmitJobFailure() throws IOException {
     Map<String, String> jobConf =
@@ -289,4 +353,23 @@ public class TestLocalJobExecutor {
     long actualValue = (long) field.get(exec);
     Assertions.assertEquals(11L, actualValue);
   }
+
+  private JobTemplate newSleepJobTemplate(String name) throws IOException {
+    // The job runs in the directory of its executable, so give each job its 
own directory.
+    File jobDir = new File(workingDir, name);
+    Assertions.assertTrue(jobDir.mkdirs());
+    File script = new File(jobDir, "sleep.sh");
+    // Exec the sleep, so that killing the job process also stops the sleep.
+    Files.writeString(script.toPath(), "#!/bin/bash\nexec sleep 600\n");
+    Assertions.assertTrue(script.setExecutable(true));
+
+    return ShellJobTemplate.builder()
+        .withName(name)
+        .withExecutable(script.getAbsolutePath())
+        .withArguments(Collections.emptyList())
+        .withEnvironments(Collections.emptyMap())
+        .withCustomFields(Collections.emptyMap())
+        .withScripts(Collections.emptyList())
+        .build();
+  }
 }
diff --git a/docs/manage-jobs-in-gravitino.md b/docs/manage-jobs-in-gravitino.md
index c3e93de708..e210530b2c 100644
--- a/docs/manage-jobs-in-gravitino.md
+++ b/docs/manage-jobs-in-gravitino.md
@@ -236,7 +236,6 @@ default configurations:
 | `gravitino.job.stagingDirKeepTimeInMs` | The time in milliseconds to keep 
the staging directory after the job is completed | `604800000` (7 days)         
 | No       |
 | `gravitino.job.statusPullIntervalInMs` | The interval in milliseconds to 
pull the job status from the job executor         | `300000` (5 minutes)        
  | No       |
 
-
 #### Configurations for Local Job Executor
 
 The local job executor is used for testing and development purposes, it runs 
the job in the local process.
@@ -249,6 +248,43 @@ The following are the default configurations for the local 
job executor:
 | `gravitino.jobExecutor.local.jobStatusKeepTimeInMs` | The time in 
milliseconds to keep the job status in the local job executor                   
                                                      | `3600000` (1 hour)      
               | No       |
 | `gravitino.jobExecutor.local.sparkHome`             | The home directory of 
Spark, Gravitino checks this configuration firstly and then `SPARK_HOME` env. 
Either of them should be set to run Spark job | `None`                          
       | No       |
 
+The local job executor runs up to `gravitino.jobExecutor.local.maxRunningJobs` 
jobs at the same
+time, each in its own process on the Gravitino server host, and queues the 
others. Make sure the
+host has enough resources for that many jobs, or lower this value, especially 
when running Spark
+jobs.
+
+When multiple Gravitino servers share the same metadata store, each server's 
local job executor
+only tracks the jobs it runs itself:
+
+- A job can only be run and tracked by the server that received the run 
request. Other servers
+  skip it when pulling job statuses.
+- Cancelling a job on a server that doesn't run it marks the job as 
`CANCELLING`, and the server
+  running the job cancels it the next time it pulls job statuses. This can 
take up to
+  `gravitino.job.statusPullIntervalInMs`.
+- If a server exits while running jobs, nobody can track these jobs anymore. 
When such a job has
+  not been updated for `gravitino.job.stagingDirKeepTimeInMs`, it is marked as 
`FAILED`, or as
+  `CANCELLED` if it was being cancelled. Like other finished jobs, it is then 
kept for another
+  `gravitino.job.stagingDirKeepTimeInMs` before being cleaned up together with 
its staging
+  directory.
+
+:::caution
+The local job executor can't tell a job left behind by an exited server from a 
job that is still
+running without changing its status. A job of the local job executor that is 
still queued, started
+or cancelling after `gravitino.job.stagingDirKeepTimeInMs` is marked as 
`FAILED` (or `CANCELLED`),
+even if the job is still running, and keeps this status even if it later 
finishes. Set this time
+longer than any job can run, or stay queued, without changing its status.
+:::
+
+:::caution
+The local job executor gets a new identity every time the Gravitino server 
starts, so a restarted
+server doesn't recognize the jobs it ran before the restart. This also applies 
to a single-server
+deployment. The processes of these jobs are usually gone with the previous 
server process, but the
+jobs are only marked as `FAILED` once they expire as described above, which 
can take up to about
+1.1 times `gravitino.job.stagingDirKeepTimeInMs` (about 7.7 days by default), 
as the cleanup runs
+every tenth of that time. Until then, they are still reported as queued, 
started or cancelling.
+Cancelling such a job only marks it as `CANCELLING`, which also restarts the 
expiration.
+:::
+
 ## Future Work
 
 The job system still needs more work:

Reply via email to