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 23c489d761 [#14046] fix(core): Settle a local job whose cancellation 
its owner never answers (#14047)
23c489d761 is described below

commit 23c489d7612860da40358b0b3c819b07acf0910a
Author: Jerry Shao <[email protected]>
AuthorDate: Thu Oct 22 10:01:32 2026 +0800

    [#14046] fix(core): Settle a local job whose cancellation its owner never 
answers (#14047)
    
    ### What changes were proposed in this pull request?
    
    When a node-local job owned by another executor is still `CANCELLING`
    three status pull intervals after the cancel request, the server pulling
    job statuses marks it as `CANCELLED`.
    
    - **`JobManager`**:
    - In `pullAndUpdateJobStatus`, a job this server doesn't own is no
    longer always skipped. If the executor is node-local, the job is
    `CANCELLING`, and its last update is older than 3 ×
    `gravitino.job.statusPullIntervalInMs`, it is marked as `CANCELLED` and
    a warning is logged.
    - Like `expireStaleActiveJob`, the condition is checked again on the
    entity that the update re-reads. A job the owner settled in the meantime
    is left as it is.
    - **Docs**:
    - Cancelling a job left behind by an exited or restarted server now
    clears it, and its job template can then be deleted.
    - A new caution states the assumptions: every server uses the same pull
    interval, and the servers' clocks are in sync.
    
    ### Why are the changes needed?
    
    The local job executor gets a new identity on every server start, so a
    restarted server no longer owns the jobs it ran before. Cancelling such
    a job only marks it as `CANCELLING` for an owner that is gone. The job
    then stays `CANCELLING`, and its job template can't be deleted, until it
    expires after `gravitino.job.stagingDirKeepTimeInMs` (about 7.7 days by
    default).
    
    A live owner answers a cancellation within about two pull intervals: it
    cancels the job at its next pull, and records it as `CANCELLED` at the
    pull after the process exits. So a job still `CANCELLING` after three
    intervals has most likely been left behind, and cancelling across
    servers keeps working. When several servers settle the same job at once,
    OCC lets only one write through. The others are deferred to the next
    pull, which then sees the job as finished.
    
    Fix: #14046
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. With the local job executor, a job that another server owns and
    that has been `CANCELLING` for more than 3 ×
    `gravitino.job.statusPullIntervalInMs` is now marked as `CANCELLED`. If
    its owner is still alive but doesn't answer in time, the job's process
    may keep running. That can happen when the servers use different pull
    intervals or their clocks are off, when the owner can't pull for that
    long, or when the process takes longer than one interval to exit. The
    docs describe these cases.
    
    There are no API or configuration changes.
    
    ### How was this patch tested?
    
    - **`TestJobManager`**:
    - Settles a stale unanswered cancellation, including a job whose last
    modified time is unset.
    - Leaves a recent cancellation alone, and a job owned by another server
    that isn't `CANCELLING`.
      - Leaves jobs of non-node-local executors alone.
      - Keeps a job that its owner settled concurrently.
    - Tolerates an OCC conflict, and the next pull leaves the job as it is.
    - **`TestJobManagerMultiNode`**:
    - Node A exits and node B cancels its job. The job stays `CANCELLING`
    and blocks deleting the template until the timeout, then node B marks it
    as `CANCELLED` and the template can be deleted.
    - A pull on node B before the timeout doesn't affect a cancellation that
    the live node A then carries out.
    - **`JobIT`** (embedded): a job of another server that is cancelled
    becomes `CANCELLED`, and its template can then be deleted.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../gravitino/client/integration/test/JobIT.java   |   9 +
 .../java/org/apache/gravitino/job/JobManager.java  |  62 +++++-
 .../org/apache/gravitino/job/TestJobManager.java   | 240 +++++++++++++++++++++
 .../gravitino/job/TestJobManagerMultiNode.java     |  44 +++-
 docs/manage-jobs-in-gravitino.md                   |  31 ++-
 5 files changed, 378 insertions(+), 8 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 1442354417..363812907d 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
@@ -633,6 +633,15 @@ public class JobIT extends BaseIT {
         .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);
+
+    // The other server never answers the cancellation, as it doesn't exist. 
After three pull
+    // intervals this server considers it gone and marks the job as CANCELLED, 
so the job template
+    // can be deleted.
+    Awaitility.await()
+        .atMost(STATUS_PULL_INTERVAL_IN_MS * 5, TimeUnit.MILLISECONDS)
+        .until(() -> metalake.getJob(job.name()).jobStatus() == 
JobHandle.Status.CANCELLED);
+    Assertions.assertNotNull(metalake.getJob(job.name()).finishedAt());
+    Assertions.assertTrue(metalake.deleteJobTemplate(template.name()));
   }
 
   @Test
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 c69d76332d..86887db750 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -110,6 +110,17 @@ public class JobManager implements JobOperationDispatcher {
 
   private static final long JOB_STATUS_PULL_MIN_INTERVAL_IN_MS = 60 * 1000L; 
// 1 minute
 
+  // A live owner of a node local job cancels it at its next status pull, and 
records it as
+  // CANCELLED at the pull after the process exits, so it answers a 
cancellation within about two
+  // pull intervals plus the time the process takes to exit. A job still 
CANCELLING after this many
+  // pull intervals is considered left behind by an owner that has exited. 
This assumes all servers
+  // use the same pull interval and have their clocks in sync, and that a 
cancelled process exits
+  // within one pull interval, i.e. 
gravitino.jobExecutor.local.cancelForceKillDelayInMs is shorter
+  // than it, which holds for the recommended intervals of 1 minute or more. 
Otherwise a job may be
+  // marked as CANCELLED while its process is still exiting, after its owner 
has already asked it to
+  // stop.
+  private static final int CANCELLATION_TIMEOUT_IN_PULL_INTERVALS = 3;
+
   private final EntityStore entityStore;
 
   private final File stagingDir;
@@ -124,6 +135,8 @@ public class JobManager implements JobOperationDispatcher {
 
   private final int jobOutputMaxBytes;
 
+  private final long cancellationTimeoutInMs;
+
   @VisibleForTesting final ScheduledExecutorService cleanUpExecutor;
 
   @VisibleForTesting final ScheduledExecutorService statusPullExecutor;
@@ -200,6 +213,8 @@ public class JobManager implements JobOperationDispatcher {
           jobStatusPullIntervalInMs,
           JOB_STATUS_PULL_MIN_INTERVAL_IN_MS);
     }
+    this.cancellationTimeoutInMs =
+        jobStatusPullIntervalInMs * CANCELLATION_TIMEOUT_IN_PULL_INTERVALS;
     this.statusPullExecutor =
         Executors.newSingleThreadScheduledExecutor(
             runnable -> {
@@ -1031,17 +1046,21 @@ public class JobManager implements 
JobOperationDispatcher {
                         || job.status() == JobHandle.Status.CANCELLING)
             .toList();
 
+    long now = System.currentTimeMillis();
     activeJobs.forEach(
         job -> {
           // A failure on one job must never stop the status pull of the 
others.
           try {
             // Only the job executor instance owning the job can query its 
status. The jobs
-            // owned by other servers are skipped, and the jobs left behind by 
a server that has
-            // exited are settled by cleanUpStagingDirs() once they expire.
-            if (!jobExecutor.ownsJob(job.jobExecutionId())) {
-              return;
+            // owned by other servers are skipped, except a node local job 
whose cancellation its
+            // owner has not answered in time: the owner has likely exited, 
e.g. the server
+            // restarted, so nobody else would ever settle it. The other jobs 
left behind by a
+            // server that has exited are settled by cleanUpStagingDirs() once 
they expire.
+            if (jobExecutor.ownsJob(job.jobExecutionId())) {
+              pullAndUpdateOwnedJobStatus(metalake, job);
+            } else if (jobExecutor.isJobStateNodeLocal() && 
isCancellationUnanswered(job, now)) {
+              settleUnansweredCancellation(metalake, job, now);
             }
-            pullAndUpdateOwnedJobStatus(metalake, job);
           } catch (Throwable t) {
             LOG.error(
                 "Failed to update the status of job {} with execution id {} 
under metalake {}",
@@ -1443,6 +1462,39 @@ public class JobManager implements 
JobOperationDispatcher {
         && lastUpdatedTimeInMs(job) + jobStagingDirKeepTimeInMs < now;
   }
 
+  private void settleUnansweredCancellation(String metalake, JobEntity job, 
long now) {
+    AtomicBoolean settled = new AtomicBoolean(false);
+    updateJobEntity(
+            metalake,
+            job,
+            latestJobEntity -> {
+              // The owner may have answered the cancellation since the 
listJobs() snapshot.
+              if (!isCancellationUnanswered(latestJobEntity, now)) {
+                return latestJobEntity;
+              }
+              settled.set(true);
+              return toUpdatedStatusJobEntity(
+                  latestJobEntity, 
JobExecutionInfo.of(JobHandle.Status.CANCELLED));
+            })
+        .filter(settledJob -> settled.get())
+        .ifPresent(
+            settledJob ->
+                LOG.warn(
+                    "Job {} with execution id {} under metalake {} has been 
cancelling for more "
+                        + "than {} ms without its owner answering, marking it 
as {}. This could be "
+                        + "due to the Gravitino server running the job having 
exited.",
+                    job.name(),
+                    job.jobExecutionId(),
+                    metalake,
+                    cancellationTimeoutInMs,
+                    settledJob.status()));
+  }
+
+  private boolean isCancellationUnanswered(JobEntity job, long now) {
+    return job.status() == JobHandle.Status.CANCELLING
+        && now - lastUpdatedTimeInMs(job) > cancellationTimeoutInMs;
+  }
+
   private static long lastUpdatedTimeInMs(JobEntity job) {
     AuditInfo auditInfo = job.auditInfo();
     Instant lastUpdatedTime =
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 0699490a4a..a2a05f8fbb 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -1687,6 +1687,246 @@ public class TestJobManager {
         .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
   }
 
+  @Test
+  public void 
testPullJobStatusSettlesUnansweredCancellationOfJobOwnedByAnotherExecutor()
+      throws IOException {
+    // The job was cancelled an hour ago, far longer than a live owner takes 
to answer, so its
+    // owner has likely exited, e.g. the server running it restarted.
+    Instant queuedAt = Instant.now().minus(2, ChronoUnit.HOURS);
+    Instant cancelledAt = Instant.now().minus(1, ChronoUnit.HOURS);
+    JobEntity job =
+        newJobEntity("local-job-gone-1", JobHandle.Status.CANCELLING, 
queuedAt, cancelledAt);
+    mockListActiveJobs(job);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    stubEntityStoreUpdateToApply(job, job);
+
+    long beforePull = System.currentTimeMillis();
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    JobEntity settled = captureUpdatedJobEntity(job, job);
+    Assertions.assertEquals(JobHandle.Status.CANCELLED, settled.status());
+    Assertions.assertTrue(settled.finishedAt() >= beforePull);
+    Assertions.assertEquals(job.auditInfo().createTime(), 
settled.auditInfo().createTime());
+    // The job is not run by this server, so its executor is never asked about 
it.
+    verify(jobExecutor, never()).getJobExecutionInfo(any());
+    verify(jobExecutor, never()).cancelJob(any());
+  }
+
+  @Test
+  public void testPullJobStatusSettlesCancellationOnlyAfterTimeout() throws 
IOException {
+    // The timeout is three pull intervals. A job cancelled just within it is 
left to its owner, one
+    // cancelled just beyond it is settled.
+    long timeoutInMs = 3 * config.get(Configs.JOB_STATUS_PULL_INTERVAL_IN_MS);
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity withinTimeout =
+        newJobEntity(
+            "local-job-other-1",
+            JobHandle.Status.CANCELLING,
+            longAgo,
+            Instant.now().minusMillis(timeoutInMs).plusSeconds(10));
+    JobEntity beyondTimeout =
+        newJobEntity(
+            "local-job-other-2",
+            JobHandle.Status.CANCELLING,
+            longAgo,
+            Instant.now().minusMillis(timeoutInMs).minusSeconds(10));
+    mockListActiveJobs(withinTimeout, beyondTimeout);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    stubEntityStoreUpdateToApply(beyondTimeout, beyondTimeout);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(entityStore, never())
+        .update(
+            eq(NameIdentifierUtil.ofJob(metalake, withinTimeout.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            any());
+    Assertions.assertEquals(
+        JobHandle.Status.CANCELLED, captureUpdatedJobEntity(beyondTimeout, 
beyondTimeout).status());
+  }
+
+  @Test
+  public void testPullJobStatusContinuesAfterFailingToSettleCancellation() 
throws IOException {
+    // Failing to settle one job must not stop settling the others.
+    Instant longAgo = Instant.now().minus(1, ChronoUnit.HOURS);
+    JobEntity failingJob =
+        newJobEntity("local-job-gone-1", JobHandle.Status.CANCELLING, longAgo, 
longAgo);
+    JobEntity otherJob =
+        newJobEntity("local-job-gone-2", JobHandle.Status.CANCELLING, longAgo, 
longAgo);
+    mockListActiveJobs(failingJob, otherJob);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    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.pullAndUpdateJobStatus());
+
+    Assertions.assertEquals(
+        JobHandle.Status.CANCELLED, captureUpdatedJobEntity(otherJob, 
otherJob).status());
+  }
+
+  @Test
+  public void testPullJobStatusContinuesAfterFailingToCheckNodeLocalJobState() 
throws IOException {
+    // The job executor fails to tell whether its job state is node local for 
the first job, which
+    // must not stop settling the next one.
+    Instant longAgo = Instant.now().minus(1, ChronoUnit.HOURS);
+    JobEntity failingJob =
+        newJobEntity("local-job-gone-1", JobHandle.Status.CANCELLING, longAgo, 
longAgo);
+    JobEntity otherJob =
+        newJobEntity("local-job-gone-2", JobHandle.Status.CANCELLING, longAgo, 
longAgo);
+    mockListActiveJobs(failingJob, otherJob);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    when(jobExecutor.isJobStateNodeLocal())
+        .thenThrow(new AbstractMethodError("isJobStateNodeLocal"))
+        .thenReturn(true);
+    stubEntityStoreUpdateToApply(otherJob, otherJob);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(entityStore, never())
+        .update(
+            eq(NameIdentifierUtil.ofJob(metalake, failingJob.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            any());
+    Assertions.assertEquals(
+        JobHandle.Status.CANCELLED, captureUpdatedJobEntity(otherJob, 
otherJob).status());
+  }
+
+  @Test
+  public void testPullJobStatusWaitsForOwnerToAnswerCancellation() throws 
IOException {
+    // The job was cancelled just now, so its owner may still answer it. A job 
owned by another
+    // server that is not cancelling is skipped however long it has not been 
updated.
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity recentlyCancelled =
+        newJobEntity("local-job-other-1", JobHandle.Status.CANCELLING, 
longAgo, Instant.now());
+    JobEntity notCancelled =
+        newJobEntity("local-job-other-2", JobHandle.Status.STARTED, longAgo, 
longAgo);
+    mockListActiveJobs(recentlyCancelled, notCancelled);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(entityStore, never())
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
+  }
+
+  @Test
+  public void 
testPullJobStatusDoesNotSettleCancellationOfNonNodeLocalExecutor()
+      throws IOException {
+    // Any server can track the jobs of an external job executor, so a 
cancelling job is left to
+    // the job executor however long it takes.
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity job = newJobEntity("external-job-1", 
JobHandle.Status.CANCELLING, longAgo, longAgo);
+    mockListActiveJobs(job);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(entityStore, never())
+        .update(any(), eq(JobEntity.class), eq(Entity.EntityType.JOB), any());
+  }
+
+  @Test
+  public void testPullJobStatusDoesNotSettleCancellationAnsweredConcurrently() 
throws IOException {
+    // listJobs() observes an unanswered cancellation, but the owner records 
the job as CANCELLED
+    // before entityStore.update() re-fetches it, so the recorded job is kept 
as it is.
+    Instant longAgo = Instant.now().minus(1, ChronoUnit.HOURS);
+    JobEntity staleSnapshot =
+        newJobEntity("local-job-other-1", JobHandle.Status.CANCELLING, 
longAgo, longAgo);
+    JobEntity answered =
+        newJobEntity(
+            staleSnapshot.id(),
+            staleSnapshot.jobExecutionId(),
+            JobHandle.Status.CANCELLED,
+            longAgo,
+            Instant.now());
+    mockListActiveJobs(staleSnapshot);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    stubEntityStoreUpdateToApply(answered);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    Assertions.assertSame(answered, captureUpdatedJobEntity(answered));
+  }
+
+  @Test
+  public void 
testPullJobStatusSkipsCancellationSettledConcurrentlyByAnotherServer()
+      throws IOException {
+    // Another server settles the first job between this server's read and 
write, so the write
+    // conflicts. The other jobs are still settled, and the next poll sees the 
first job as finished
+    // and leaves it as it is.
+    Instant longAgo = Instant.now().minus(1, ChronoUnit.HOURS);
+    JobEntity conflictedJob =
+        newJobEntity("local-job-gone-1", JobHandle.Status.CANCELLING, longAgo, 
longAgo);
+    JobEntity otherJob =
+        newJobEntity("local-job-gone-2", JobHandle.Status.CANCELLING, longAgo, 
longAgo);
+    mockListActiveJobs(conflictedJob, otherJob);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    when(entityStore.update(
+            eq(NameIdentifierUtil.ofJob(metalake, conflictedJob.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            any()))
+        .thenThrow(new OptimisticLockException("job changed"));
+    stubEntityStoreUpdateToApply(otherJob, otherJob);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    Assertions.assertEquals(
+        JobHandle.Status.CANCELLED, captureUpdatedJobEntity(otherJob, 
otherJob).status());
+
+    JobEntity settledByOther =
+        newJobEntity(
+            conflictedJob.id(),
+            conflictedJob.jobExecutionId(),
+            JobHandle.Status.CANCELLED,
+            longAgo,
+            Instant.now());
+    mockListActiveJobs(settledByOther);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(entityStore, times(1))
+        .update(
+            eq(NameIdentifierUtil.ofJob(metalake, conflictedJob.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            any());
+  }
+
+  @Test
+  public void 
testPullJobStatusSettlesUnansweredCancellationWithoutLastModifiedTime()
+      throws IOException {
+    // The job has no last modified time, so how long it has been cancelling 
is taken from when it
+    // was created.
+    JobEntity job =
+        newJobEntity(
+            "local-job-gone-1",
+            JobHandle.Status.CANCELLING,
+            Instant.now().minus(1, ChronoUnit.HOURS),
+            null);
+    mockListActiveJobs(job);
+    when(jobExecutor.ownsJob(any())).thenReturn(false);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    stubEntityStoreUpdateToApply(job, job);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    Assertions.assertEquals(JobHandle.Status.CANCELLED, 
captureUpdatedJobEntity(job, job).status());
+  }
+
   @Test
   public void 
testPullJobStatusCancelsOwnedJobMarkedCancellingByAnotherServer() throws 
IOException {
     // A STARTED job marked as CANCELLING by another server is cancelled by 
its owner, and stays
diff --git 
a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java 
b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
index 94c84fd324..b696363999 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
@@ -42,6 +42,7 @@ import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.cache.NoOpsCache;
 import org.apache.gravitino.connector.job.JobExecutionInfo;
 import org.apache.gravitino.connector.job.JobExecutor;
+import org.apache.gravitino.exceptions.InUseException;
 import org.apache.gravitino.exceptions.NoSuchJobException;
 import org.apache.gravitino.job.local.LocalJobExecutor;
 import org.apache.gravitino.job.local.LocalJobExecutorConfigs;
@@ -81,6 +82,9 @@ public class TestJobManagerMultiNode extends TestJDBCBackend {
   // 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);
 
+  // A cancellation of a local job is answered by its owner within a few pull 
intervals.
+  private static final long JOB_STATUS_PULL_INTERVAL_IN_MS = 
TimeUnit.MINUTES.toMillis(1);
+
   private File testDir;
 
   private LocalJobExecutor executorA;
@@ -181,6 +185,12 @@ public class TestJobManagerMultiNode extends 
TestJDBCBackend {
     Assertions.assertEquals(JobHandle.Status.CANCELLING, cancelling.status());
     Assertions.assertEquals(JobHandle.Status.STARTED, 
executorA.getJobStatus(job.jobExecutionId()));
 
+    // Node B leaves the cancellation to node A when it pulls job statuses, as 
node A still has
+    // time to answer it.
+    nodeB.pullAndUpdateJobStatus();
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, 
getJob(job.name()).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()
@@ -235,6 +245,34 @@ public class TestJobManagerMultiNode extends 
TestJDBCBackend {
     Assertions.assertFalse(jobExists(cancellingJob.name()));
   }
 
+  @TestTemplate
+  public void testCancelJobLeftByExitedNodeFromAnotherNode() throws Exception {
+    JobEntity job = runLongJobOnNodeA();
+
+    // Node A exits, e.g. its server restarts, and the job process is gone 
with it.
+    nodeA.close();
+    nodeA = null;
+
+    // Node B can't cancel the job itself, so it marks the job as CANCELLING 
and gives the owner
+    // time to answer. Until then, the job still blocks deleting its template.
+    Assertions.assertEquals(
+        JobHandle.Status.CANCELLING, nodeB.cancelJob(METALAKE, 
job.name()).status());
+    nodeB.pullAndUpdateJobStatus();
+    Assertions.assertEquals(JobHandle.Status.CANCELLING, 
getJob(job.name()).status());
+    Assertions.assertThrows(
+        InUseException.class, () -> nodeB.deleteJobTemplate(METALAKE, 
TEMPLATE));
+
+    // Nobody answers the cancellation in time, so node B marks the job as 
CANCELLED, and the
+    // template can be deleted.
+    moveJobTimestampsBack(
+        job.name(), 3 * JOB_STATUS_PULL_INTERVAL_IN_MS + 
TimeUnit.MINUTES.toMillis(1));
+    nodeB.pullAndUpdateJobStatus();
+    JobEntity cancelled = getJob(job.name());
+    Assertions.assertEquals(JobHandle.Status.CANCELLED, cancelled.status());
+    Assertions.assertTrue(cancelled.finishedAt() > 0);
+    Assertions.assertTrue(nodeB.deleteJobTemplate(METALAKE, TEMPLATE));
+  }
+
   @TestTemplate
   public void testGetJobOutputFromAnotherNode() throws IOException {
     JobEntity job = runEchoJobOnNodeA("a");
@@ -429,7 +467,10 @@ public class TestJobManagerMultiNode extends 
TestJDBCBackend {
 
   // 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);
+    moveJobTimestampsBack(jobName, JOB_KEEP_TIME_IN_MS + 
TimeUnit.MINUTES.toMillis(1));
+  }
+
+  private void moveJobTimestampsBack(String jobName, long offsetInMs) throws 
IOException {
     entityStore.update(
         NameIdentifierUtil.ofJob(METALAKE, jobName),
         JobEntity.class,
@@ -505,6 +546,7 @@ public class TestJobManagerMultiNode extends 
TestJDBCBackend {
     Config config = new Config(false) {};
     config.set(Configs.JOB_STAGING_DIR, stagingDir.getAbsolutePath());
     config.set(Configs.JOB_STAGING_DIR_KEEP_TIME_IN_MS, JOB_KEEP_TIME_IN_MS);
+    config.set(Configs.JOB_STATUS_PULL_INTERVAL_IN_MS, 
JOB_STATUS_PULL_INTERVAL_IN_MS);
     return config;
   }
 
diff --git a/docs/manage-jobs-in-gravitino.md b/docs/manage-jobs-in-gravitino.md
index 451819781e..b09e5f23ca 100644
--- a/docs/manage-jobs-in-gravitino.md
+++ b/docs/manage-jobs-in-gravitino.md
@@ -441,6 +441,10 @@ only tracks the jobs it runs itself:
   `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.
+- To clear such a job sooner, cancel it. If the job is still `CANCELLING` more 
than three times
+  `gravitino.job.statusPullIntervalInMs` after the cancel request, the server 
running it is
+  considered gone, and another server marks the job as `CANCELLED` when it 
pulls job statuses. Its
+  job template can then be deleted.
 
 :::caution
 The local job executor can't tell a job left behind by an exited server from a 
job that is still
@@ -456,8 +460,31 @@ server doesn't recognize the jobs it ran before the 
restart. This also applies t
 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.
+every tenth of that time. Until then, they are still reported as queued, 
started or cancelling, and
+they keep their job templates from being deleted.
+
+To clear these jobs sooner, cancel them. A cancelled job is marked as 
`CANCELLED` within about four
+times `gravitino.job.statusPullIntervalInMs`, as described above. Lowering
+`gravitino.job.stagingDirKeepTimeInMs` also shortens the time, but finished 
jobs are then kept for
+a shorter time, and a job that runs longer than that without changing its 
status is marked as
+`FAILED`.
+:::
+
+:::caution
+Telling a gone server from a live one by how long a job stays `CANCELLING` 
requires all servers to
+use the same `gravitino.job.statusPullIntervalInMs` and to have their clocks 
in sync. A job is also
+marked as `CANCELLED` while its server is still running it if that server 
doesn't answer the
+cancellation in time, for example because:
+
+- it uses a longer `gravitino.job.statusPullIntervalInMs` than the other 
servers, or its clock is
+  off,
+- it fails to pull job statuses for that long, or
+- the job process takes longer than one pull interval to exit, e.g. as
+  `gravitino.jobExecutor.local.cancelForceKillDelayInMs` is longer than the 
pull interval.
+
+If the server hasn't asked the job process to stop yet, it never does once the 
job is `CANCELLED`,
+so the process keeps running until it finishes, and deleting its job template 
also deletes the
+staging directory the process uses.
 :::
 
 ## Future Work

Reply via email to