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 8d2c01bd11 [#13146] fix(core): Track local jobs only by their owning
executor in multi-node deployments (#13147)
8d2c01bd11 is described below
commit 8d2c01bd111676a4b03e54b00b3d2472756e5a3d
Author: Jerry Shao <[email protected]>
AuthorDate: Tue Sep 15 12:02:08 2026 +0800
[#13146] fix(core): Track local jobs only by their owning executor in
multi-node deployments (#13147)
### What changes were proposed in this pull request?
Make each server only track the local jobs it runs itself, so that
servers sharing the same metadata store no longer overwrite each other's
job statuses.
- `LocalJobExecutor`
- Generates a random 8-hex-digit executor id on initialization and
embeds it in the job execution ids: `local-job-<executorId>-<uuid>`.
- Implements the new `JobExecutor` methods `ownsJob(jobId)` and
`isJobStateNodeLocal()`.
- Fixes the thread pool to run up to `maxRunningJobs` jobs concurrently
(core size was 0 with an unbounded queue, so only one job ran at a
time).
- `JobExecutor`: adds the default methods `ownsJob` (default `true`) and
`isJobStateNodeLocal` (default `false`), so external job executors keep
their current behavior.
- `JobManager`
- The status pull only queries the jobs owned by this server's executor,
and skips the others.
- `cancelJob` on a job owned by another server only marks it as
`CANCELLING`. The owning server cancels it on its next status pull. This
only applies to node-local job state.
- For node-local job executors, `cleanUpStagingDirs` marks active jobs
not updated for `gravitino.job.stagingDirKeepTimeInMs` as `FAILED` (or
`CANCELLED` if cancelling), e.g. jobs left behind by a server that
exited. They are then kept for another keep time and cleaned up like
other finished jobs.
- Docs: describe the multi-node behavior of the local job executor and
its limitations.
### Why are the changes needed?
`LocalJobExecutor` keeps job status only in the memory of the server
that submitted the job, but every server pulls the status of all active
jobs from the shared store. A server that doesn't own a job gets
`NoSuchJobException` from its own executor and marks the job as
`FAILED`, even if the job succeeded on the other server. Cancelling a
job through a non-owning server also failed with a 500 error.
Fix: #13146
### Does this PR introduce _any_ user-facing change?
- Job execution ids of the local job executor change from
`local-job-<uuid>` to `local-job-<executorId>-<uuid>`.
- Cancelling a job through a server that doesn't run it now returns
`CANCELLING` instead of failing. The cancellation takes effect on the
owning server's next status pull (up to
`gravitino.job.statusPullIntervalInMs`).
- For the local job executor, `gravitino.job.stagingDirKeepTimeInMs` now
also expires active jobs whose status has not changed for that long.
Jobs of other job executors are not affected.
- `gravitino.jobExecutor.local.maxRunningJobs` now takes effect.
- New default methods on the `JobExecutor` developer API: `ownsJob` and
`isJobStateNodeLocal`.
Known limitations, documented in `manage-jobs-in-gravitino.md`:
- A local job running, or staying queued, longer than
`gravitino.job.stagingDirKeepTimeInMs` without a status change is marked
as `FAILED`, even if it is still running. A normal local job isn't
expected to run that long; a lease-based liveness check would be the
long-term solution.
- The executor id changes on every restart, so a restarted server
doesn't recognize the jobs it ran before. These jobs are only marked as
`FAILED` once they expire, up to about 7.7 days with the default config.
Active jobs created before upgrading, with the old id format, are
handled the same way.
- During a rolling upgrade, servers still running the old version may
mark jobs run by upgraded servers as `FAILED`.
### How was this patch tested?
- Unit tests
- `TestLocalJobExecutor`: job ownership by executor id, and running jobs
concurrently up to `maxRunningJobs`.
- `TestJobManager`: skipping jobs owned by other executors, cross-node
cancellation, not re-cancelling jobs of non-node-local executors, and
expiring stale active jobs in the cleanup (including concurrent updates
and failures).
- New `TestJobManagerMultiNode`: two `JobManager`s with real
`LocalJobExecutor`s sharing a real relational store (H2, plus
MySQL/PostgreSQL with `dockerTest=true`) cover owner-only tracking,
cancelling through the other node, and expiring jobs left by an exited
node.
- Integration test `JobIT` (embedded mode) simulates another server's
job in the metadata store: it is not marked as `FAILED`, cancelling it
returns `CANCELLING`, and a stale one is marked as `FAILED`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 <[email protected]>
---
.../gravitino/client/integration/test/JobIT.java | 101 +++++-
.../gravitino/connector/job/JobExecutor.java | 32 ++
.../java/org/apache/gravitino/job/JobManager.java | 319 ++++++++++++------
.../gravitino/job/local/LocalJobExecutor.java | 35 +-
.../org/apache/gravitino/job/TestJobManager.java | 369 +++++++++++++++++++++
.../gravitino/job/TestJobManagerMultiNode.java | 288 ++++++++++++++++
.../gravitino/job/local/TestLocalJobExecutor.java | 83 +++++
docs/manage-jobs-in-gravitino.md | 38 ++-
8 files changed, 1157 insertions(+), 108 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 a093fca5a8..380affdcde 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);
@@ -529,6 +551,83 @@ 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);
+ Assertions.assertNotNull(metalake.getJob(job.name()).finishedAt());
+ }
+
+ 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())
+ .withStartedAt(status == JobHandle.Status.QUEUED ? 0L :
createTime.toEpochMilli())
+ .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 44e287a6a4..a86e7d4d91 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -29,6 +29,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;
@@ -36,6 +37,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;
@@ -542,12 +545,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
@@ -638,93 +661,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
startedAt/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 (OptimisticLockException e) {
- // A later poll re-reads both executor state and metadata.
Never stop the scheduled
- // task or replay external submission/cancellation because a
metadata CAS lost.
- LOG.info(
- "Job {} under metalake {} changed concurrently; deferring
status update",
- job.name(),
- metalake);
- return;
- } 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);
}
});
}
@@ -802,17 +743,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 -> isFinishedStatus(job.status()))
- .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(
@@ -1121,4 +1072,166 @@ 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
startedAt/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 (OptimisticLockException e) {
+ // A later poll re-reads both executor state and metadata. Never stop
the scheduled
+ // task or replay external submission/cancellation because a metadata
CAS lost.
+ LOG.info(
+ "Job {} under metalake {} changed concurrently; deferring status
update",
+ job.name(),
+ metalake);
+ return Optional.empty();
+ } 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 1ca2f43e6a..b3605dd576 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;
@@ -72,6 +74,7 @@ import
org.apache.gravitino.exceptions.NoSuchJobTemplateException;
import org.apache.gravitino.exceptions.NoSuchMetalakeException;
import org.apache.gravitino.exceptions.NonEmptyEntityException;
import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.apache.gravitino.job.local.LocalJobExecutor;
import org.apache.gravitino.json.JsonUtils;
import org.apache.gravitino.lock.LockManager;
import org.apache.gravitino.meta.AuditInfo;
@@ -125,6 +128,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);
@@ -1290,6 +1295,303 @@ 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());
+ Assertions.assertEquals(startedJob.startedAt(),
expiredStartedJob.startedAt());
+ // 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());
+ }
+
/** Conflicts preserve files without stopping this cleanup batch or its next
scheduled run. */
@Test
public void testCleanUpStagingDirsContinuesAfterOccConflict() throws
IOException {
@@ -1692,6 +1994,45 @@ 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)
+ .withStartedAt(0L)
+ .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
@@ -1714,6 +2055,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..09bf25a3ca
--- /dev/null
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManagerMultiNode.java
@@ -0,0 +1,288 @@
+/*
+ * 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.AfterEach;
+import org.junit.jupiter.api.Assertions;
+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;
+
+ @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())
+ .withStartedAt(job.startedAt())
+ .withFinishedAt(job.finishedAt() > 0 ? job.finishedAt() -
offsetInMs : 0L)
+ .withRuntimeJobTemplate(job.runtimeJobTemplate())
+ .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: