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 02954c331b [#14014] fix(core): keep job status polling and staging 
cleanup running after a failure (#14016)
02954c331b is described below

commit 02954c331b74d7a05f029276980ae744fb85db41
Author: Jerry Shao <[email protected]>
AuthorDate: Wed Oct 21 14:47:46 2026 +0800

    [#14014] fix(core): keep job status polling and staging cleanup running 
after a failure (#14016)
    
    ### What changes were proposed in this pull request?
    
    Guard both scheduled tasks in `JobManager`, `pullAndUpdateJobStatus` and
    `cleanUpStagingDirs`:
    - A failure in `MetalakeManager.listInUseMetalakes` is logged and ends
    the current run only.
    - Each metalake is processed in its own try, so a metalake that was
    disabled or dropped after being listed, or a store error while listing
    its jobs, only skips that metalake.
    - In the status poll, the `ownsJob` check is inside the per-job try.
    - The cleanup's per-job delete loop also catches `RuntimeException`, and
    its log names the metalake.
    
    ### Why are the changes needed?
    
    Both tasks are scheduled with `scheduleAtFixedRate`. Any exception
    escaping a run cancels all later runs, and the exception stays in a
    future nobody reads, so it is never logged. One transient store error,
    or disabling a metalake at the wrong moment, stopped all job status
    updates or staging cleanup until the server restarted.
    
    Fix: #14014
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Added unit tests to `TestJobManager` for both tasks: they recover after
    a failure to list metalakes, keep processing the other metalakes when
    one fails, and keep deleting other expired jobs when one delete fails
    unexpectedly. All five fail without this change.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../java/org/apache/gravitino/job/JobManager.java  | 187 ++++++++++-------
 .../org/apache/gravitino/job/TestJobManager.java   | 222 +++++++++++++++++++++
 2 files changed, 332 insertions(+), 77 deletions(-)

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 20feaf1ca5..c69d76332d 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -42,6 +42,7 @@ 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.Consumer;
 import java.util.function.Function;
 import java.util.regex.Pattern;
 import javax.annotation.Nullable;
@@ -61,6 +62,7 @@ import org.apache.gravitino.dto.job.JobTemplateDTO;
 import org.apache.gravitino.dto.util.DTOConverters;
 import org.apache.gravitino.exceptions.InUseException;
 import org.apache.gravitino.exceptions.JobTemplateAlreadyExistsException;
+import org.apache.gravitino.exceptions.MetalakeNotInUseException;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchJobException;
 import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
@@ -757,87 +759,12 @@ public class JobManager implements JobOperationDispatcher 
{
 
   @VisibleForTesting
   void pullAndUpdateJobStatus() {
-    List<String> metalakes = MetalakeManager.listInUseMetalakes(entityStore);
-    for (String metalake : metalakes) {
-      // This unnecessary list all the jobs, we need to improve the code to 
only list the active
-      // jobs.
-      List<JobEntity> activeJobs =
-          listJobs(metalake, Optional.empty()).stream()
-              .filter(
-                  job ->
-                      job.status() == JobHandle.Status.QUEUED
-                          || job.status() == JobHandle.Status.STARTED
-                          || job.status() == JobHandle.Status.CANCELLING)
-              .toList();
-
-      activeJobs.forEach(
-          job -> {
-            // 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;
-            }
-            // An exception escaping this scheduled task would cancel all its 
later runs, so a
-            // failure on one job must never stop the status pull of the 
others.
-            try {
-              pullAndUpdateOwnedJobStatus(metalake, job);
-            } catch (RuntimeException e) {
-              LOG.error(
-                  "Failed to update the status of job {} with execution id {} 
under metalake {}",
-                  job.name(),
-                  job.jobExecutionId(),
-                  metalake,
-                  e);
-            }
-          });
-    }
+    forEachInUseMetalake("job status pull", this::pullAndUpdateJobStatus);
   }
 
   @VisibleForTesting
   void cleanUpStagingDirs() {
-    List<String> metalakes = MetalakeManager.listInUseMetalakes(entityStore);
-
-    for (String metalake : metalakes) {
-      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);
-          }
-        }
-      }
-
-      expiredJobs.forEach(
-          job -> {
-            try {
-              entityStore.delete(
-                  NameIdentifierUtil.ofJob(metalake, job.name()), 
Entity.EntityType.JOB);
-              deleteJobStagingDir(job);
-            } catch (OptimisticLockException e) {
-              // Keep files when deletion conflicts or times out on a lock. 
The next cleanup run
-              // re-reads the job and checks retention eligibility again; 
other jobs in this batch
-              // can still be processed.
-              LOG.info(
-                  "Job {} under metalake {} could not be deleted because of a 
concurrent operation; deferring cleanup",
-                  job.name(),
-                  metalake);
-            } catch (IOException e) {
-              LOG.error("Failed to delete job and staging directory for job 
{}", job.name(), e);
-            }
-          });
-    }
+    forEachInUseMetalake("job staging directory cleanup", 
this::cleanUpStagingDirs);
   }
 
   @VisibleForTesting
@@ -1067,6 +994,65 @@ public class JobManager implements JobOperationDispatcher 
{
     return newValue.orElse(currentValue);
   }
 
+  // Runs a scheduled task on every in-use metalake. Anything escaping a 
scheduled task, including
+  // an Error such as a LinkageError from a job executor plugin, would cancel 
all its later runs,
+  // so a failure is logged and only skips the metalake it affects.
+  private void forEachInUseMetalake(String task, Consumer<String> action) {
+    List<String> metalakes;
+    try {
+      metalakes = MetalakeManager.listInUseMetalakes(entityStore);
+    } catch (Throwable t) {
+      LOG.error("Failed to list the in-use metalakes for the {}", task, t);
+      return;
+    }
+
+    for (String metalake : metalakes) {
+      try {
+        action.accept(metalake);
+      } catch (MetalakeNotInUseException | NoSuchMetalakeException e) {
+        // The metalake was disabled or dropped after it was listed as in use, 
which is an expected
+        // race, so it is logged without the stack trace.
+        LOG.warn("Skipped the {} for metalake {}: {}", task, metalake, 
e.getMessage());
+      } catch (Throwable t) {
+        LOG.error("The {} failed for metalake {}", task, metalake, t);
+      }
+    }
+  }
+
+  private void pullAndUpdateJobStatus(String metalake) {
+    // This unnecessarily lists all the jobs, we need to improve the code to 
only list the
+    // active jobs.
+    List<JobEntity> activeJobs =
+        listJobs(metalake, Optional.empty()).stream()
+            .filter(
+                job ->
+                    job.status() == JobHandle.Status.QUEUED
+                        || job.status() == JobHandle.Status.STARTED
+                        || job.status() == JobHandle.Status.CANCELLING)
+            .toList();
+
+    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;
+            }
+            pullAndUpdateOwnedJobStatus(metalake, job);
+          } catch (Throwable t) {
+            LOG.error(
+                "Failed to update the status of job {} with execution id {} 
under metalake {}",
+                job.name(),
+                job.jobExecutionId(),
+                metalake,
+                t);
+          }
+        });
+  }
+
   private void pullAndUpdateOwnedJobStatus(String metalake, JobEntity job) {
     JobExecutionInfo observed;
     try {
@@ -1371,6 +1357,53 @@ public class JobManager implements 
JobOperationDispatcher {
     }
   }
 
+  private void cleanUpStagingDirs(String metalake) {
+    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 {
+        // 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 {
+          if (jobExecutor.isJobStateNodeLocal() && isStaleActiveJob(job, now)) 
{
+            expireStaleActiveJob(metalake, job, now);
+          }
+        } catch (Throwable t) {
+          LOG.error("Failed to expire job {} under metalake {}", job.name(), 
metalake, t);
+        }
+      }
+    }
+
+    expiredJobs.forEach(
+        job -> {
+          try {
+            entityStore.delete(
+                NameIdentifierUtil.ofJob(metalake, job.name()), 
Entity.EntityType.JOB);
+            deleteJobStagingDir(job);
+          } catch (OptimisticLockException e) {
+            // Keep files when deletion conflicts or times out on a lock. The 
next cleanup run
+            // re-reads the job and checks retention eligibility again; other 
jobs in this batch
+            // can still be processed.
+            LOG.info(
+                "Job {} under metalake {} could not be deleted because of a 
concurrent operation; deferring cleanup",
+                job.name(),
+                metalake);
+          } catch (Throwable t) {
+            LOG.error(
+                "Failed to delete job and staging directory for job {} under 
metalake {}",
+                job.name(),
+                metalake,
+                t);
+          }
+        });
+  }
+
   private void expireStaleActiveJob(String metalake, JobEntity job, long now) {
     AtomicBoolean expired = new AtomicBoolean(false);
     updateJobEntity(
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 1b2d7f79fa..0699490a4a 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -72,6 +72,7 @@ import org.apache.gravitino.dto.job.ShellJobTemplateDTO;
 import org.apache.gravitino.exceptions.InUseException;
 import org.apache.gravitino.exceptions.JobTemplateAlreadyExistsException;
 import org.apache.gravitino.exceptions.MetalakeInUseException;
+import org.apache.gravitino.exceptions.MetalakeNotInUseException;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchJobException;
 import org.apache.gravitino.exceptions.NoSuchJobTemplateException;
@@ -2005,6 +2006,227 @@ public class TestJobManager {
     verify(entityStore, times(1)).delete(otherIdent, Entity.EntityType.JOB);
   }
 
+  /** A failure to list the metalakes is logged and does not stop later status 
polls. */
+  @Test
+  public void testPullJobStatusContinuesAfterFailingToListMetalakes() throws 
IOException {
+    JobEntity job = newJobEntity("shell_job", JobHandle.Status.QUEUED);
+    mockedMetalake
+        .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+        .thenThrow(new RuntimeException("Failed to list in-use metalakes"))
+        .thenReturn(ImmutableList.of(metalake));
+    doReturn(ImmutableList.of(job)).when(jobManager).listJobs(metalake, 
Optional.empty());
+    when(jobExecutor.getJobExecutionInfo(job.jobExecutionId()))
+        .thenReturn(JobExecutionInfo.of(JobHandle.Status.SUCCEEDED));
+    stubEntityStoreUpdateToApply(job);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+    verify(jobExecutor, never()).getJobExecutionInfo(any());
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
captureUpdatedJobEntity(job).status());
+  }
+
+  /** A metalake disabled after being listed as in use only skips the status 
poll of itself. */
+  @Test
+  public void testPullJobStatusContinuesAfterFailingOnOneMetalake() throws 
IOException {
+    String disabledMetalake = "disabled_metalake";
+    JobEntity job = newJobEntity("shell_job", JobHandle.Status.QUEUED);
+    mockedMetalake
+        .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+        .thenReturn(ImmutableList.of(disabledMetalake, metalake));
+    doThrow(new MetalakeNotInUseException("Metalake %s is not in use", 
disabledMetalake))
+        .when(jobManager)
+        .listJobs(disabledMetalake, Optional.empty());
+    doReturn(ImmutableList.of(job)).when(jobManager).listJobs(metalake, 
Optional.empty());
+    when(jobExecutor.getJobExecutionInfo(job.jobExecutionId()))
+        .thenReturn(JobExecutionInfo.of(JobHandle.Status.SUCCEEDED));
+    stubEntityStoreUpdateToApply(job, job);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
captureUpdatedJobEntity(job, job).status());
+  }
+
+  /** An Error on one metalake, such as a LinkageError from a plugin, only 
skips that metalake. */
+  @Test
+  public void testPullJobStatusContinuesAfterErrorOnOneMetalake() throws 
IOException {
+    String brokenMetalake = "broken_metalake";
+    JobEntity job = newJobEntity("shell_job", JobHandle.Status.QUEUED);
+    mockedMetalake
+        .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+        .thenReturn(ImmutableList.of(brokenMetalake, metalake));
+    doThrow(new NoClassDefFoundError("org/example/MissingClass"))
+        .when(jobManager)
+        .listJobs(brokenMetalake, Optional.empty());
+    doReturn(ImmutableList.of(job)).when(jobManager).listJobs(metalake, 
Optional.empty());
+    when(jobExecutor.getJobExecutionInfo(job.jobExecutionId()))
+        .thenReturn(JobExecutionInfo.of(JobHandle.Status.SUCCEEDED));
+    stubEntityStoreUpdateToApply(job, job);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    Assertions.assertEquals(JobHandle.Status.SUCCEEDED, 
captureUpdatedJobEntity(job, job).status());
+  }
+
+  /** A failure checking the owner of one job does not stop the status pull of 
the others. */
+  @Test
+  public void testPullJobStatusContinuesAfterFailingToCheckJobOwner() throws 
IOException {
+    JobEntity failingJob = newJobEntity("shell_job", JobHandle.Status.QUEUED);
+    JobEntity otherJob = newJobEntity("shell_job", JobHandle.Status.QUEUED);
+    mockListActiveJobs(failingJob, otherJob);
+    when(jobExecutor.ownsJob(failingJob.jobExecutionId()))
+        .thenThrow(new AbstractMethodError("ownsJob"));
+    when(jobExecutor.getJobExecutionInfo(otherJob.jobExecutionId()))
+        .thenReturn(JobExecutionInfo.of(JobHandle.Status.SUCCEEDED));
+    stubEntityStoreUpdateToApply(otherJob, otherJob);
+
+    Assertions.assertDoesNotThrow(() -> jobManager.pullAndUpdateJobStatus());
+
+    verify(jobExecutor, 
never()).getJobExecutionInfo(failingJob.jobExecutionId());
+    Assertions.assertEquals(
+        JobHandle.Status.SUCCEEDED, captureUpdatedJobEntity(otherJob, 
otherJob).status());
+  }
+
+  /** An Error expiring a stale job still lets the expired jobs found before 
it be deleted. */
+  @Test
+  public void testCleanUpStagingDirsContinuesAfterErrorExpiringJob() throws 
IOException {
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity expired = expiredJob();
+    JobEntity stale = newJobEntity("local-job-gone-1", 
JobHandle.Status.STARTED, longAgo, longAgo);
+    mockListActiveJobs(expired, stale);
+    when(jobExecutor.isJobStateNodeLocal()).thenReturn(true);
+    when(entityStore.update(
+            eq(NameIdentifierUtil.ofJob(metalake, stale.name())),
+            eq(JobEntity.class),
+            eq(Entity.EntityType.JOB),
+            any()))
+        .thenThrow(new NoClassDefFoundError("org/example/MissingClass"));
+    NameIdentifier expiredIdent = NameIdentifierUtil.ofJob(metalake, 
expired.name());
+    when(entityStore.delete(expiredIdent, 
Entity.EntityType.JOB)).thenReturn(true);
+    File expiredDir = jobManager.jobStagingDir(expired.id());
+    Assertions.assertTrue(expiredDir.mkdirs());
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    Assertions.assertFalse(expiredDir.exists());
+    verify(entityStore, times(1)).delete(expiredIdent, Entity.EntityType.JOB);
+  }
+
+  /** A failure checking the job executor still lets the expired jobs found 
before be deleted. */
+  @Test
+  public void testCleanUpStagingDirsContinuesAfterFailingToCheckJobExecutor() 
throws IOException {
+    Instant longAgo = Instant.now().minus(30, ChronoUnit.DAYS);
+    JobEntity expired = expiredJob();
+    JobEntity active = newJobEntity("local-job-gone-1", 
JobHandle.Status.STARTED, longAgo, longAgo);
+    mockListActiveJobs(expired, active);
+    when(jobExecutor.isJobStateNodeLocal())
+        .thenThrow(new AbstractMethodError("isJobStateNodeLocal"));
+    NameIdentifier expiredIdent = NameIdentifierUtil.ofJob(metalake, 
expired.name());
+    when(entityStore.delete(expiredIdent, 
Entity.EntityType.JOB)).thenReturn(true);
+    File expiredDir = jobManager.jobStagingDir(expired.id());
+    Assertions.assertTrue(expiredDir.mkdirs());
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    Assertions.assertFalse(expiredDir.exists());
+    verify(entityStore, times(1)).delete(expiredIdent, Entity.EntityType.JOB);
+  }
+
+  /** An Error deleting one expired job does not stop the cleanup of the 
others. */
+  @Test
+  public void testCleanUpStagingDirsContinuesAfterErrorDeletingJob() throws 
IOException {
+    JobEntity failing = expiredJob();
+    JobEntity other = expiredJob();
+    mockListActiveJobs(failing, other);
+    NameIdentifier failingIdent = NameIdentifierUtil.ofJob(metalake, 
failing.name());
+    NameIdentifier otherIdent = NameIdentifierUtil.ofJob(metalake, 
other.name());
+    when(entityStore.delete(failingIdent, Entity.EntityType.JOB))
+        .thenThrow(new NoClassDefFoundError("org/example/MissingClass"));
+    when(entityStore.delete(otherIdent, 
Entity.EntityType.JOB)).thenReturn(true);
+    File otherDir = jobManager.jobStagingDir(other.id());
+    Assertions.assertTrue(otherDir.mkdirs());
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    Assertions.assertFalse(otherDir.exists());
+    verify(entityStore, times(1)).delete(otherIdent, Entity.EntityType.JOB);
+  }
+
+  /** A failure to list the metalakes is logged and does not stop later 
cleanup runs. */
+  @Test
+  public void testCleanUpStagingDirsContinuesAfterFailingToListMetalakes() 
throws IOException {
+    JobEntity job = expiredJob();
+    mockedMetalake
+        .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+        .thenThrow(new RuntimeException("Failed to list in-use metalakes"))
+        .thenReturn(ImmutableList.of(metalake));
+    doReturn(ImmutableList.of(job)).when(jobManager).listJobs(metalake, 
Optional.empty());
+    NameIdentifier jobIdent = NameIdentifierUtil.ofJob(metalake, job.name());
+    when(entityStore.delete(jobIdent, Entity.EntityType.JOB)).thenReturn(true);
+    File jobDir = jobManager.jobStagingDir(job.id());
+    Assertions.assertTrue(jobDir.mkdirs());
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+    Assertions.assertTrue(jobDir.exists());
+    verify(entityStore, never()).delete(any(), any());
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+    Assertions.assertFalse(jobDir.exists());
+    verify(entityStore, times(1)).delete(jobIdent, Entity.EntityType.JOB);
+  }
+
+  /** A store failure while listing the jobs of one metalake only skips the 
cleanup of itself. */
+  @Test
+  public void testCleanUpStagingDirsContinuesAfterFailingOnOneMetalake() 
throws IOException {
+    String failingMetalake = "failing_metalake";
+    JobEntity job = expiredJob();
+    mockedMetalake
+        .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+        .thenReturn(ImmutableList.of(failingMetalake, metalake));
+    doThrow(new RuntimeException(new IOException("store error")))
+        .when(jobManager)
+        .listJobs(failingMetalake, Optional.empty());
+    doReturn(ImmutableList.of(job)).when(jobManager).listJobs(metalake, 
Optional.empty());
+    NameIdentifier jobIdent = NameIdentifierUtil.ofJob(metalake, job.name());
+    when(entityStore.delete(jobIdent, Entity.EntityType.JOB)).thenReturn(true);
+    File jobDir = jobManager.jobStagingDir(job.id());
+    Assertions.assertTrue(jobDir.mkdirs());
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    Assertions.assertFalse(jobDir.exists());
+    verify(entityStore, times(1)).delete(jobIdent, Entity.EntityType.JOB);
+  }
+
+  /** An unexpected failure deleting one expired job does not stop the cleanup 
of the others. */
+  @Test
+  public void testCleanUpStagingDirsContinuesAfterFailingToDeleteJob() throws 
IOException {
+    JobEntity failing = expiredJob();
+    JobEntity other = expiredJob();
+    doReturn(ImmutableList.of(failing, other))
+        .when(jobManager)
+        .listJobs(metalake, Optional.empty());
+    mockedMetalake
+        .when(() -> MetalakeManager.listInUseMetalakes(entityStore))
+        .thenReturn(ImmutableList.of(metalake));
+    NameIdentifier failingIdent = NameIdentifierUtil.ofJob(metalake, 
failing.name());
+    NameIdentifier otherIdent = NameIdentifierUtil.ofJob(metalake, 
other.name());
+    when(entityStore.delete(failingIdent, Entity.EntityType.JOB))
+        .thenThrow(new RuntimeException("store error"));
+    when(entityStore.delete(otherIdent, 
Entity.EntityType.JOB)).thenReturn(true);
+    File failingDir = jobManager.jobStagingDir(failing.id());
+    File otherDir = jobManager.jobStagingDir(other.id());
+    Assertions.assertTrue(failingDir.mkdirs());
+    Assertions.assertTrue(otherDir.mkdirs());
+
+    Assertions.assertDoesNotThrow(() -> jobManager.cleanUpStagingDirs());
+
+    Assertions.assertTrue(failingDir.exists());
+    Assertions.assertFalse(otherDir.exists());
+    verify(entityStore, times(1)).delete(failingIdent, Entity.EntityType.JOB);
+    verify(entityStore, times(1)).delete(otherIdent, Entity.EntityType.JOB);
+  }
+
   @Test
   public void testCleanUpStagingDirsDeletesLegacyStagingDirOfRenamedTemplate() 
throws IOException {
     // The job ran from template "shell_job" before an upgrade, in the legacy 
layout, and the

Reply via email to