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 2d63db89d9 [#13678] fix(core): make a cancelled local job end as
CANCELLED (#13679)
2d63db89d9 is described below
commit 2d63db89d926b104b03abbb71918ec2a7cbf5aa0
Author: YangJie <[email protected]>
AuthorDate: Wed Oct 7 23:34:52 2026 -0400
[#13678] fix(core): make a cancelled local job end as CANCELLED (#13679)
### What changes were proposed in this pull request?
`LocalJobExecutor.cancelJob` still sends SIGTERM first, and now also
schedules a check that kills the process forcibly if it is still alive
after `gravitino.jobExecutor.local.cancelForceKillDelayInMs` (30 seconds
by default). `runJob` now checks whether the job was being cancelled
before it looks at the exit code, so a cancelled job ends CANCELLED
whatever code its process exits with. The new setting is added to the
local executor configuration table in
`docs/manage-jobs-in-gravitino.md`.
Like the existing SIGTERM, the forced kill reaches only the job's own
process, not processes it started.
### Why are the changes needed?
A cancelled job whose process ignored SIGTERM never exited: it stayed
CANCELLING and kept its worker thread blocked in `waitFor()`. The worker
pool is fixed at `maxRunningJobs`, so each such job took a slot for
good, and once all slots were held new jobs stayed queued until a
restart. Separately, a cancelled job whose process trapped SIGTERM and
exited 0 was reported SUCCEEDED, because the exit code was checked
first.
Fix: #13678
### Does this PR introduce _any_ user-facing change?
Yes. A new setting,
`gravitino.jobExecutor.local.cancelForceKillDelayInMs` (default
`30000`), controls how long a cancelled job's process may keep running
before it is killed forcibly. A cancelled job that exits 0 is now
reported CANCELLED instead of SUCCEEDED.
### How was this patch tested?
Added `testCancelJobEscalatesToForcibleKill`, which cancels a job that
ignores SIGTERM and asserts it reaches CANCELLED once the force-kill
delay passes, and `testCancelledJobExitingZeroReportsCancelled`, which
cancels a job that traps SIGTERM and exits 0 and asserts CANCELLED. Each
script prints a marker after installing its trap, and the test waits for
it before cancelling so the signal cannot arrive first. Both tests fail
against the code before this change.
---
.../gravitino/job/local/LocalJobExecutor.java | 54 ++++++++---
.../job/local/LocalJobExecutorConfigs.java | 9 ++
.../gravitino/job/local/TestLocalJobExecutor.java | 103 +++++++++++++++++++++
docs/manage-jobs-in-gravitino.md | 13 +--
4 files changed, 159 insertions(+), 20 deletions(-)
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 a8f83702c8..9c42bad643 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
@@ -19,6 +19,8 @@
package org.apache.gravitino.job.local;
+import static
org.apache.gravitino.job.local.LocalJobExecutorConfigs.CANCEL_FORCE_KILL_DELAY_MS;
+import static
org.apache.gravitino.job.local.LocalJobExecutorConfigs.DEFAULT_CANCEL_FORCE_KILL_DELAY_MS;
import static
org.apache.gravitino.job.local.LocalJobExecutorConfigs.DEFAULT_JOB_STATUS_KEEP_TIME_MS;
import static
org.apache.gravitino.job.local.LocalJobExecutorConfigs.DEFAULT_MAX_RUNNING_JOBS;
import static
org.apache.gravitino.job.local.LocalJobExecutorConfigs.DEFAULT_WAITING_QUEUE_SIZE;
@@ -143,6 +145,8 @@ public class LocalJobExecutor implements JobExecutor {
private Map<String, JobExecutionInfo> jobInfos;
private final Object lock = new Object();
+ private long cancelForceKillDelayMs;
+
private long jobStatusKeepTimeInMs;
private ScheduledExecutorService jobStatusCleanupExecutor;
@@ -169,6 +173,14 @@ public class LocalJobExecutor implements JobExecutor {
@Override
public void initialize(Map<String, String> configs) {
this.configs = configs;
+ this.cancelForceKillDelayMs =
+ configs.containsKey(CANCEL_FORCE_KILL_DELAY_MS)
+ ? Long.parseLong(configs.get(CANCEL_FORCE_KILL_DELAY_MS))
+ : DEFAULT_CANCEL_FORCE_KILL_DELAY_MS;
+ Preconditions.checkArgument(
+ cancelForceKillDelayMs > 0,
+ "Cancel force-kill delay must be greater than 0, but got: %s",
+ cancelForceKillDelayMs);
this.executorId = String.format("%08x",
ThreadLocalRandom.current().nextInt());
this.ownedJobIdPrefix = LOCAL_JOB_PREFIX + executorId + "-";
LOG.info("Initializing local job executor with executor id {}",
executorId);
@@ -366,6 +378,20 @@ public class LocalJobExecutor implements JobExecutor {
Process process = runningProcesses.get(jobId);
if (process != null) {
process.destroy();
+ // A process that traps or ignores SIGTERM would otherwise stay in
+ // CANCELLING forever and permanently occupy one of the fixed worker
+ // threads; escalate to a hard kill after a grace period.
+ long delayMs = cancelForceKillDelayMs;
+ jobStatusCleanupExecutor.schedule(
+ () -> {
+ if (process.isAlive()) {
+ LOG.warn(
+ "Job {} still alive {} ms after cancel, destroying
forcibly", jobId, delayMs);
+ process.destroyForcibly();
+ }
+ },
+ delayMs,
+ TimeUnit.MILLISECONDS);
}
LOG.info("Job {} is cancelling while running", jobId);
jobInfos.put(jobId,
info.toBuilder().withStatus(JobHandle.Status.CANCELLING).build());
@@ -446,21 +472,21 @@ public class LocalJobExecutor implements JobExecutor {
LOG.info("Starting job: {}", jobId);
int exitCode = process.waitFor();
- if (exitCode == 0) {
- LOG.info("Job {} completed successfully", jobId);
- synchronized (lock) {
+ synchronized (lock) {
+ // The job may be missing if the executor is closed concurrently.
+ JobExecutionInfo current = jobInfos.get(jobId);
+ JobHandle.Status oldStatus = current == null ? null : current.status();
+ if (oldStatus == JobHandle.Status.CANCELLING) {
+ // A cancelled job whose process traps SIGTERM and still exits 0 was
cancelled, not
+ // successful; consult the cancel state before the exit code.
+ LOG.info("Job {} was cancelled while running with exit code: {}",
jobId, exitCode);
+ finishJob(jobId, JobHandle.Status.CANCELLED);
+ } else if (exitCode == 0) {
+ LOG.info("Job {} completed successfully", jobId);
finishJob(jobId, JobHandle.Status.SUCCEEDED);
- }
- } else {
- synchronized (lock) {
- JobHandle.Status oldStatus = jobInfos.get(jobId).status();
- if (oldStatus == JobHandle.Status.CANCELLING) {
- LOG.info("Job {} was cancelled while running with exit code: {}",
jobId, exitCode);
- finishJob(jobId, JobHandle.Status.CANCELLED);
- } else if (oldStatus == JobHandle.Status.STARTED) {
- LOG.warn("Job {} failed after starting with exit code: {}", jobId,
exitCode);
- finishJob(jobId, JobHandle.Status.FAILED);
- }
+ } else if (oldStatus == null || oldStatus == JobHandle.Status.STARTED)
{
+ LOG.warn("Job {} failed after starting with exit code: {}", jobId,
exitCode);
+ finishJob(jobId, JobHandle.Status.FAILED);
}
}
diff --git
a/core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutorConfigs.java
b/core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutorConfigs.java
index c02eddb902..10c055cb08 100644
---
a/core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutorConfigs.java
+++
b/core/src/main/java/org/apache/gravitino/job/local/LocalJobExecutorConfigs.java
@@ -36,6 +36,15 @@ public class LocalJobExecutorConfigs {
public static final String JOB_STATUS_KEEP_TIME_MS = "jobStatusKeepTimeInMs";
public static final long DEFAULT_JOB_STATUS_KEEP_TIME_MS = 60 * 60 * 1000;
// 1 hour
+ /**
+ * How long in milliseconds a cancelled job's process may keep running after
it is asked to stop
+ * before the executor kills it forcibly. Must be greater than 0.
+ */
+ public static final String CANCEL_FORCE_KILL_DELAY_MS =
"cancelForceKillDelayInMs";
+
+ /** The default value of {@link #CANCEL_FORCE_KILL_DELAY_MS}. */
+ public static final long DEFAULT_CANCEL_FORCE_KILL_DELAY_MS = 30 * 1000L; //
30 seconds
+
public static final String SPARK_HOME = "sparkHome";
/**
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 8f1676ef42..0fd29d9d46 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
@@ -639,6 +639,96 @@ public class TestLocalJobExecutor {
Assertions.assertEquals(JobHandle.Status.CANCELLED,
jobExecutor.getJobStatus(jobId));
}
+ @Test
+ public void testCancelJobEscalatesToForcibleKill() throws Exception {
+ // A process that ignores SIGTERM must still terminate (and free its worker
+ // thread) after the cancel grace period.
+ LocalJobExecutor executor = new LocalJobExecutor();
+ executor.initialize(
+ withStagingDir(
+
ImmutableMap.of(LocalJobExecutorConfigs.CANCEL_FORCE_KILL_DELAY_MS, "1000")));
+ try {
+ JobTemplateEntity.TemplateContent trapContent =
+ JobTemplateEntity.TemplateContent.builder()
+ .withExecutable("/bin/sh")
+ .withArguments(Lists.newArrayList("-c", "trap '' TERM; echo
ready; exec sleep 300"))
+ .withEnvironments(ImmutableMap.of())
+ .withJobType(JobTemplate.JobType.SHELL)
+ .withScripts(Lists.newArrayList())
+ .withCustomFields(ImmutableMap.of())
+ .build();
+ JobTemplateEntity trapTemplate =
+ JobTemplateEntity.builder()
+ .withId(2L)
+ .withName("trap-term-job-template")
+ .withNamespace(NamespaceUtil.ofJobTemplate("test"))
+ .withComment("test")
+ .withTemplateContent(trapContent)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build();
+ JobTemplate template =
+ new JobTemplateResolver(trapTemplate).resolve(ImmutableMap.of(),
workingDir);
+
+ String jobId = executor.submitJob(template);
+ awaitReadyMarker();
+ Assertions.assertEquals(JobHandle.Status.STARTED,
executor.getJobStatus(jobId));
+
+ executor.cancelJob(jobId);
+
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .until(() -> executor.getJobStatus(jobId) ==
JobHandle.Status.CANCELLED);
+ } finally {
+ executor.close();
+ }
+ }
+
+ @Test
+ public void testCancelledJobExitingZeroReportsCancelled() throws Exception {
+ // A cancelled job whose TERM handler exits 0 must report CANCELLED, not
SUCCEEDED: the user
+ // asked for it to stop, so a clean exit taken in response to the cancel
is still a
+ // cancellation.
+ LocalJobExecutor executor = new LocalJobExecutor();
+ executor.initialize(withStagingDir(Collections.emptyMap()));
+ try {
+ JobTemplateEntity.TemplateContent content =
+ JobTemplateEntity.TemplateContent.builder()
+ .withExecutable("/bin/sh")
+ .withArguments(
+ Lists.newArrayList(
+ "-c", "trap 'exit 0' TERM; echo ready; while :; do sleep
1; done"))
+ .withEnvironments(ImmutableMap.of())
+ .withJobType(JobTemplate.JobType.SHELL)
+ .withScripts(Lists.newArrayList())
+ .withCustomFields(ImmutableMap.of())
+ .build();
+ JobTemplateEntity templateEntity =
+ JobTemplateEntity.builder()
+ .withId(3L)
+ .withName("term-exit-zero-template")
+ .withNamespace(NamespaceUtil.ofJobTemplate("test"))
+ .withComment("test")
+ .withTemplateContent(content)
+ .withAuditInfo(AuditInfo.EMPTY)
+ .build();
+ JobTemplate template =
+ new JobTemplateResolver(templateEntity).resolve(ImmutableMap.of(),
workingDir);
+
+ String jobId = executor.submitJob(template);
+ awaitReadyMarker();
+ Assertions.assertEquals(JobHandle.Status.STARTED,
executor.getJobStatus(jobId));
+
+ executor.cancelJob(jobId);
+
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .until(() -> executor.getJobStatus(jobId) ==
JobHandle.Status.CANCELLED);
+ Assertions.assertEquals(JobHandle.Status.CANCELLED,
executor.getJobStatus(jobId));
+ } finally {
+ executor.close();
+ }
+ }
+
@Test
public void testCancelSucceededJob() {
// Cancelling a job that is already succeeded.
@@ -1135,6 +1225,19 @@ public class TestLocalJobExecutor {
Assertions.assertEquals(6, jobExecutor.getJobStdout(jobId, 100,
DEFAULT_TEST_MAX_BYTES).size());
}
+ // The job script prints "ready" only after installing its TERM trap, so a
cancel sent once this
+ // returns cannot reach the shell before the trap is in place.
+ private static void awaitReadyMarker() {
+ File output = new File(workingDir, "output.log");
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .until(
+ () ->
+ output.exists()
+ && FileUtils.readFileToString(output,
StandardCharsets.UTF_8)
+ .contains("ready"));
+ }
+
private static Map<String, String> withStagingDir(Map<String, String>
configs) {
return ImmutableMap.<String, String>builder()
.putAll(configs)
diff --git a/docs/manage-jobs-in-gravitino.md b/docs/manage-jobs-in-gravitino.md
index 139f48d619..21ba58a285 100644
--- a/docs/manage-jobs-in-gravitino.md
+++ b/docs/manage-jobs-in-gravitino.md
@@ -384,12 +384,13 @@ default configurations:
The local job executor is used for testing and development purposes, it runs
the job in the local process.
The following are the default configurations for the local job executor:
-| Property name | Description
| Default value
| Required |
-|-----------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------|----------|
-| `gravitino.jobExecutor.local.waitingQueueSize` | The size of the
waiting queue for queued jobs in the local job executor
| `100`
| No |
-| `gravitino.jobExecutor.local.maxRunningJobs` | The maximum number of
running jobs in the local job executor
| `max(1, min(available cores / 2,
10))` | No |
-| `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 |
+| Property name | Description
| Default value | Required |
+|--------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------|----------|
+| `gravitino.jobExecutor.local.waitingQueueSize` | The size of the
waiting queue for queued jobs in the local job executor
| `100` | No |
+| `gravitino.jobExecutor.local.maxRunningJobs` | The maximum number
of running jobs in the local job executor
| `max(1, min(available cores / 2, 10))` | No |
+| `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.cancelForceKillDelayInMs` | How long in
milliseconds a cancelled job's process may keep running after it is asked to
stop before the executor kills it forcibly. Raise it for jobs that need longer
to shut down cleanly | `30000` (30 seconds) | 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 always uses `gravitino.job.stagingDir` as its staging
directory, the same one
the job system stages jobs in. A `gravitino.jobExecutor.local.stagingDir`
setting is ignored.