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

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


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 8590234d70 [Cherry-pick to branch-1.3] [#13131] fix(core): Reject 
Spark jobs at submission when Spark is not available in the local job executor 
(#13132) (#13148)
8590234d70 is described below

commit 8590234d705a49fc09a26215b887c381d0882cf0
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Sep 14 20:20:40 2026 +0800

    [Cherry-pick to branch-1.3] [#13131] fix(core): Reject Spark jobs at 
submission when Spark is not available in the local job executor (#13132) 
(#13148)
    
    **Cherry-pick Information:**
    - Original commit: 0f2d22cd6b2fe34e8a0813b9571b2de506a639fe
    - Target branch: `branch-1.3`
    - Status: Conflicts resolved.
    
    The only conflict was in `TestJobManager`: the cherry-pick pulled in the
    neighboring `testRunJobPopulatesResolvedRuntimeJobTemplate` as context,
    which depends on the runtime job template feature that is not in 1.3.
    Kept the new `testRunJobPropagatesJobExecutorRejection` and dropped that
    test. All other files applied cleanly.
    
    Validation on `branch-1.3`: `TestLocalJobExecutor`,
    `TestSparkProcessBuilder`, `TestJobManager` and `JobIT` passed;
    `spotlessCheck` and `:docs:build` passed.
    
    ---------
    
    Co-authored-by: Jerry Shao <[email protected]>
    Co-authored-by: Claude Opus 5 <[email protected]>
    Co-authored-by: Jerry Shao <[email protected]>
---
 .../gravitino/client/integration/test/JobIT.java   | 53 ++++++++++++-
 .../java/org/apache/gravitino/job/JobManager.java  | 20 +++++
 .../gravitino/job/local/LocalJobExecutor.java      | 17 +++++
 .../gravitino/job/local/SparkProcessBuilder.java   | 38 ++++++++--
 .../org/apache/gravitino/job/TestJobManager.java   | 32 ++++++++
 .../gravitino/job/local/TestLocalJobExecutor.java  | 36 +++++++++
 .../job/local/TestSparkProcessBuilder.java         | 88 ++++++++++++++++++++++
 docs/manage-jobs-in-gravitino.md                   |  5 +-
 docs/open-api/jobs.yaml                            | 20 +++++
 9 files changed, 298 insertions(+), 11 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 9dfa11ba8a..8681e5cd25 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
@@ -39,6 +39,7 @@ 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.awaitility.Awaitility;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.AfterEach;
@@ -52,6 +53,7 @@ public class JobIT extends BaseIT {
   private static final String METALAKE_NAME = 
GravitinoITUtils.genRandomName("job_it_metalake");
 
   private File testStagingDir;
+  private File testSparkHome;
   private String testEntryScriptPath;
   private String testLibScriptPath;
   private ShellJobTemplate.Builder builder;
@@ -61,6 +63,9 @@ public class JobIT extends BaseIT {
   @Override
   public void startIntegrationTest() throws Exception {
     testStagingDir = Files.createTempDirectory("test_staging_dir").toFile();
+    // A Spark home without bin/spark-submit, so Spark jobs cannot be 
launched. The configuration
+    // takes precedence over the SPARK_HOME environment variable, keeping the 
test deterministic.
+    testSparkHome = Files.createTempDirectory("test_spark_home").toFile();
     testEntryScriptPath = generateTestEntryScript();
     testLibScriptPath = generateTestLibScript();
 
@@ -78,7 +83,9 @@ public class JobIT extends BaseIT {
             "gravitino.job.stagingDir",
             testStagingDir.getAbsolutePath(),
             "gravitino.job.statusPullIntervalInMs",
-            "3000");
+            "3000",
+            "gravitino.jobExecutor.local.sparkHome",
+            testSparkHome.getAbsolutePath());
     registerCustomConfigs(configs);
     super.startIntegrationTest();
   }
@@ -86,6 +93,7 @@ public class JobIT extends BaseIT {
   @AfterAll
   public void tearDown() throws Exception {
     FileUtils.deleteDirectory(testStagingDir);
+    FileUtils.deleteDirectory(testSparkHome);
   }
 
   @BeforeEach
@@ -326,6 +334,49 @@ public class JobIT extends BaseIT {
     Assertions.assertTrue(jobStatuses.contains(JobHandle.Status.SUCCEEDED));
   }
 
+  @Test
+  public void testRunSparkJobRejectedWhenSparkIsNotAvailable() {
+    SparkJobTemplate template =
+        SparkJobTemplate.builder()
+            .withName("test_run_spark_without_spark_submit")
+            .withComment("Test spark job template")
+            .withExecutable(testEntryScriptPath)
+            .withClassName("org.apache.gravitino.test.SparkJob")
+            .build();
+    Assertions.assertDoesNotThrow(() -> 
metalake.registerJobTemplate(template));
+
+    // The run request is rejected with the reason instead of being queued and 
failing later.
+    IllegalArgumentException e =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> metalake.runJob(template.name(), Collections.emptyMap()));
+    Assertions.assertTrue(
+        e.getMessage()
+            .contains(
+                "spark-submit is not found or not executable: "
+                    + testSparkHome.getAbsolutePath()
+                    + "/bin/spark-submit"),
+        e.getMessage());
+
+    // No job is created, and the staging directory of the rejected job is 
removed.
+    Assertions.assertTrue(metalake.listJobs(template.name()).isEmpty());
+    String[] jobStagingDirs =
+        new File(testStagingDir, METALAKE_NAME + File.separator + 
template.name()).list();
+    Assertions.assertTrue(jobStagingDirs == null || jobStagingDirs.length == 
0);
+
+    // Shell jobs are not affected by the missing Spark installation.
+    JobTemplate shellTemplate = 
builder.withName("test_run_shell_without_spark_submit").build();
+    Assertions.assertDoesNotThrow(() -> 
metalake.registerJobTemplate(shellTemplate));
+    JobHandle jobHandle =
+        metalake.runJob(
+            shellTemplate.name(),
+            ImmutableMap.of("arg1", "value1", "arg2", "success", "env_var", 
"value2"));
+    Assertions.assertEquals(JobHandle.Status.QUEUED, jobHandle.jobStatus());
+    Awaitility.await()
+        .atMost(3, TimeUnit.MINUTES)
+        .until(() -> metalake.getJob(jobHandle.jobId()).jobStatus() == 
JobHandle.Status.SUCCEEDED);
+  }
+
   @Test
   public void testRunAndGetJob() {
     JobTemplate template = builder.withName("test_run_get").build();
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 44d1bf2fde..501eb88a9c 100644
--- a/core/src/main/java/org/apache/gravitino/job/JobManager.java
+++ b/core/src/main/java/org/apache/gravitino/job/JobManager.java
@@ -447,7 +447,13 @@ public class JobManager implements JobOperationDispatcher {
     String jobExecutionId;
     try {
       jobExecutionId = jobExecutor.submitJob(jobTemplate);
+    } catch (IllegalArgumentException e) {
+      // The job executor rejects the job because it cannot be launched, for 
example, a required
+      // configuration is missing. Rethrow it as is so the caller gets the 
original reason.
+      deleteStagingDirOfUnsubmittedJob(jobStagingDir, jobId);
+      throw e;
     } catch (Exception e) {
+      deleteStagingDirOfUnsubmittedJob(jobStagingDir, jobId);
       throw new RuntimeException(
           String.format("Failed to submit job template %s for execution", 
jobTemplate), e);
     }
@@ -1018,6 +1024,20 @@ public class JobManager implements 
JobOperationDispatcher {
         .build();
   }
 
+  private void deleteStagingDirOfUnsubmittedJob(File jobStagingDir, long 
jobId) {
+    // The job is not tracked by any job entity, so the periodic cleanup will 
never remove its
+    // staging directory. A cleanup failure must not mask the original 
submission failure.
+    try {
+      FileUtils.deleteDirectory(jobStagingDir);
+    } catch (IOException e) {
+      LOG.warn(
+          "Failed to delete staging directory {} of job {} whose submission 
failed",
+          jobStagingDir,
+          jobId,
+          e);
+    }
+  }
+
   private <T> T updatedValue(T currentValue, Optional<T> newValue) {
     return newValue.orElse(currentValue);
   }
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 f3b6e5d631..295a0a8483 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
@@ -44,6 +44,7 @@ import org.apache.gravitino.connector.job.JobExecutor;
 import org.apache.gravitino.exceptions.NoSuchJobException;
 import org.apache.gravitino.job.JobHandle;
 import org.apache.gravitino.job.JobTemplate;
+import org.apache.gravitino.job.SparkJobTemplate;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -150,10 +151,26 @@ public class LocalJobExecutor implements JobExecutor {
         TimeUnit.MILLISECONDS);
 
     this.runningProcesses = Maps.newConcurrentMap();
+
+    // Spark is optional for the local job executor, so a missing Spark 
installation must not fail
+    // the server startup. Warn early instead; Spark jobs will be rejected at 
submission.
+    try {
+      SparkProcessBuilder.resolveSparkSubmit(configs);
+    } catch (IllegalArgumentException e) {
+      LOG.warn(
+          "Spark jobs cannot be run by the local job executor and will be 
rejected: {}",
+          e.getMessage());
+    }
   }
 
   @Override
   public String submitJob(JobTemplate jobTemplate) {
+    // Validate the job can be launched before queueing it, so that a 
misconfiguration is reported
+    // to the caller directly instead of only failing the job asynchronously 
in the worker thread.
+    if (jobTemplate instanceof SparkJobTemplate) {
+      SparkProcessBuilder.resolveSparkSubmit(configs);
+    }
+
     String newJobId = LOCAL_JOB_PREFIX + UUID.randomUUID();
     Pair<String, JobTemplate> jobPair = Pair.of(newJobId, jobTemplate);
 
diff --git 
a/core/src/main/java/org/apache/gravitino/job/local/SparkProcessBuilder.java 
b/core/src/main/java/org/apache/gravitino/job/local/SparkProcessBuilder.java
index 918c48f564..06d9cef8e5 100644
--- a/core/src/main/java/org/apache/gravitino/job/local/SparkProcessBuilder.java
+++ b/core/src/main/java/org/apache/gravitino/job/local/SparkProcessBuilder.java
@@ -21,14 +21,15 @@ package org.apache.gravitino.job.local;
 
 import static 
org.apache.gravitino.job.local.LocalJobExecutorConfigs.SPARK_HOME;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
 import com.google.common.collect.Lists;
 import java.io.File;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
-import org.apache.arrow.util.Preconditions;
-import org.apache.arrow.util.VisibleForTesting;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.job.SparkJobTemplate;
 import org.slf4j.Logger;
@@ -51,18 +52,39 @@ public class SparkProcessBuilder extends 
LocalProcessBuilder {
 
   protected SparkProcessBuilder(SparkJobTemplate sparkJobTemplate, Map<String, 
String> configs) {
     super(sparkJobTemplate, configs);
-    String sparkHome =
-        
Optional.ofNullable(configs.get(SPARK_HOME)).orElse(System.getenv(ENV_SPARK_HOME));
+    this.sparkSubmit = resolveSparkSubmit(configs);
+  }
+
+  /**
+   * Resolves the spark-submit executable from the local job executor 
configurations, falling back
+   * to the {@code SPARK_HOME} environment variable.
+   *
+   * @param configs The local job executor configurations.
+   * @return The absolute path of the spark-submit executable.
+   * @throws IllegalArgumentException If neither the Spark home configuration 
nor the {@code
+   *     SPARK_HOME} environment variable is set, or spark-submit is not found 
or not executable.
+   */
+  static String resolveSparkSubmit(Map<String, String> configs) {
+    return resolveSparkSubmit(configs, System.getenv(ENV_SPARK_HOME));
+  }
+
+  @VisibleForTesting
+  static String resolveSparkSubmit(Map<String, String> configs, @Nullable 
String envSparkHome) {
+    String sparkHome = 
Optional.ofNullable(configs.get(SPARK_HOME)).orElse(envSparkHome);
     Preconditions.checkArgument(
         StringUtils.isNotBlank(sparkHome),
         "gravitino.jobExecutor.local.sparkHome or SPARK_HOME environment 
variable must"
             + " be set for Spark jobs");
 
-    this.sparkSubmit = sparkHome + "/bin/spark-submit";
-    File sparkSubmitFile = new File(sparkSubmit);
+    // Resolve to an absolute path: the Spark process runs in the job staging 
directory, so a
+    // relative path validated against the server working directory would not 
be found there.
+    File sparkSubmitFile = new File(sparkHome, 
"bin/spark-submit").getAbsoluteFile();
+    // canExecute() alone is also true for a searchable directory, so require 
a regular file.
     Preconditions.checkArgument(
-        sparkSubmitFile.canExecute(),
-        "spark-submit is not found or not executable: " + sparkSubmit);
+        sparkSubmitFile.isFile() && sparkSubmitFile.canExecute(),
+        "spark-submit is not found or not executable: %s",
+        sparkSubmitFile);
+    return sparkSubmitFile.getPath();
   }
 
   @VisibleForTesting
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 017bc6c21d..6ffb8c2688 100644
--- a/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
+++ b/core/src/test/java/org/apache/gravitino/job/TestJobManager.java
@@ -519,6 +519,38 @@ public class TestJobManager {
         () -> jobManager.runJob(metalake, "shell_job", 
Collections.emptyMap()));
   }
 
+  @Test
+  public void testRunJobPropagatesJobExecutorRejection() throws IOException {
+    mockedMetalake
+        .when(() -> MetalakeManager.checkMetalake(metalakeIdent, entityStore))
+        .thenAnswer(a -> null);
+
+    JobTemplateEntity shellJobTemplate =
+        newShellJobTemplateEntity("shell_job", "A shell job template");
+    when(jobManager.getJobTemplate(metalake, 
shellJobTemplate.name())).thenReturn(shellJobTemplate);
+
+    IllegalArgumentException rejection =
+        new IllegalArgumentException(
+            "gravitino.jobExecutor.local.sparkHome or SPARK_HOME environment 
variable must"
+                + " be set for Spark jobs");
+    doThrow(rejection).when(jobExecutor).submitJob(any());
+
+    // The rejection must reach the caller as is, so the REST layer reports 
the original reason
+    // with a 400 instead of wrapping it into a generic 500 error.
+    IllegalArgumentException e =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> jobManager.runJob(metalake, "shell_job", 
Collections.emptyMap()));
+    Assertions.assertSame(rejection, e);
+
+    // No job entity is registered and the staging directory of the rejected 
job is removed.
+    verify(entityStore, never()).put(any(JobEntity.class), anyBoolean());
+    File templateStagingDir =
+        new File(testStagingDir, metalake + File.separator + 
shellJobTemplate.name());
+    String[] jobStagingDirs = templateStagingDir.list();
+    Assertions.assertTrue(jobStagingDirs == null || jobStagingDirs.length == 
0);
+  }
+
   @Test
   public void testRunJobSucceedsWhenStagingDirectoryAlreadyExists() throws 
Exception {
     mockedMetalake
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 1c7cbfae10..45b49e01e4 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
@@ -33,6 +33,7 @@ import org.apache.gravitino.connector.job.JobExecutor;
 import org.apache.gravitino.job.JobHandle;
 import org.apache.gravitino.job.JobManager;
 import org.apache.gravitino.job.JobTemplate;
+import org.apache.gravitino.job.SparkJobTemplate;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.JobTemplateEntity;
 import org.apache.gravitino.utils.NamespaceUtil;
@@ -159,6 +160,41 @@ public class TestLocalJobExecutor {
     Assertions.assertEquals(JobHandle.Status.FAILED, 
jobExecutor.getJobStatus(jobId));
   }
 
+  @Test
+  public void testSubmitSparkJobRejectedWhenSparkSubmitIsNotAvailable() throws 
IOException {
+    File sparkHome = new File(workingDir, "spark");
+    LocalJobExecutor exec = new LocalJobExecutor();
+    exec.initialize(
+        ImmutableMap.of(LocalJobExecutorConfigs.SPARK_HOME, 
sparkHome.getAbsolutePath()));
+
+    try {
+      SparkJobTemplate template =
+          SparkJobTemplate.builder()
+              .withName("spark-job")
+              .withExecutable(new File(workingDir, 
"spark-demo.jar").getAbsolutePath())
+              .withClassName("com.example.MainClass")
+              .build();
+
+      // spark-submit does not exist, the job is rejected at submission 
instead of being queued.
+      IllegalArgumentException e =
+          Assertions.assertThrows(IllegalArgumentException.class, () -> 
exec.submitJob(template));
+      Assertions.assertTrue(e.getMessage().contains("spark-submit is not found 
or not executable"));
+
+      // Once spark-submit is available, the same job is accepted.
+      File sparkSubmit = new File(sparkHome, "bin/spark-submit");
+      FileUtils.writeStringToFile(sparkSubmit, "#!/bin/sh\nexit 0\n", "UTF-8");
+      Assertions.assertTrue(sparkSubmit.setExecutable(true));
+
+      String jobId = exec.submitJob(template);
+      Assertions.assertNotNull(jobId);
+      Awaitility.await()
+          .atMost(1, TimeUnit.MINUTES)
+          .until(() -> exec.getJobStatus(jobId) == JobHandle.Status.SUCCEEDED);
+    } finally {
+      exec.close();
+    }
+  }
+
   @Test
   public void testCancelJob() throws InterruptedException {
     Map<String, String> jobConf =
diff --git 
a/core/src/test/java/org/apache/gravitino/job/local/TestSparkProcessBuilder.java
 
b/core/src/test/java/org/apache/gravitino/job/local/TestSparkProcessBuilder.java
index 12c2a69325..e3d8f4295d 100644
--- 
a/core/src/test/java/org/apache/gravitino/job/local/TestSparkProcessBuilder.java
+++ 
b/core/src/test/java/org/apache/gravitino/job/local/TestSparkProcessBuilder.java
@@ -22,8 +22,14 @@ package org.apache.gravitino.job.local;
 import com.google.common.base.Joiner;
 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.nio.file.Paths;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.apache.commons.io.FileUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.job.SparkJobTemplate;
 import org.junit.jupiter.api.Assertions;
@@ -144,6 +150,88 @@ public class TestSparkProcessBuilder {
     Assertions.assertTrue(command4.contains("arg2"));
   }
 
+  @Test
+  public void testResolveSparkSubmit() throws IOException {
+    File validSparkHome = 
Files.createTempDirectory("gravitino-test-spark-home").toFile();
+    File invalidSparkHome = 
Files.createTempDirectory("gravitino-test-no-spark-home").toFile();
+    try {
+      File sparkSubmit = new File(validSparkHome, "bin/spark-submit");
+      FileUtils.writeStringToFile(sparkSubmit, "#!/bin/sh\n", "UTF-8");
+      Assertions.assertTrue(sparkSubmit.setExecutable(true));
+      String validPath = validSparkHome.getAbsolutePath();
+      String invalidPath = invalidSparkHome.getAbsolutePath();
+
+      // Neither the configuration nor the environment variable is set.
+      IllegalArgumentException e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> 
SparkProcessBuilder.resolveSparkSubmit(Collections.emptyMap(), null));
+      Assertions.assertEquals(
+          "gravitino.jobExecutor.local.sparkHome or SPARK_HOME environment 
variable must"
+              + " be set for Spark jobs",
+          e.getMessage());
+
+      // Falls back to the environment variable when the configuration is not 
set.
+      Assertions.assertEquals(
+          sparkSubmit.getAbsolutePath(),
+          SparkProcessBuilder.resolveSparkSubmit(Collections.emptyMap(), 
validPath));
+
+      // The configuration takes precedence over the environment variable.
+      Assertions.assertEquals(
+          sparkSubmit.getAbsolutePath(),
+          SparkProcessBuilder.resolveSparkSubmit(
+              ImmutableMap.of(LocalJobExecutorConfigs.SPARK_HOME, validPath), 
invalidPath));
+      e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () ->
+                  SparkProcessBuilder.resolveSparkSubmit(
+                      ImmutableMap.of(LocalJobExecutorConfigs.SPARK_HOME, 
invalidPath), validPath));
+      Assertions.assertEquals(
+          "spark-submit is not found or not executable: " + invalidPath + 
"/bin/spark-submit",
+          e.getMessage());
+
+      // A directory at bin/spark-submit is not a valid executable, even if it 
is searchable.
+      File sparkSubmitDir = new File(invalidSparkHome, "bin/spark-submit");
+      Assertions.assertTrue(sparkSubmitDir.mkdirs());
+      Assertions.assertTrue(sparkSubmitDir.setExecutable(true));
+      e =
+          Assertions.assertThrows(
+              IllegalArgumentException.class,
+              () -> 
SparkProcessBuilder.resolveSparkSubmit(Collections.emptyMap(), invalidPath));
+      Assertions.assertEquals(
+          "spark-submit is not found or not executable: " + invalidPath + 
"/bin/spark-submit",
+          e.getMessage());
+    } finally {
+      FileUtils.deleteDirectory(validSparkHome);
+      FileUtils.deleteDirectory(invalidSparkHome);
+    }
+  }
+
+  @Test
+  public void testResolveSparkSubmitWithRelativeSparkHome() throws IOException 
{
+    // Created under the current working directory, so it is referenced by a 
relative path.
+    File relativeSparkHome =
+        Files.createTempDirectory(Paths.get(""), 
"gravitino-test-relative-spark-home").toFile();
+    try {
+      Assertions.assertFalse(relativeSparkHome.isAbsolute());
+      File sparkSubmit = new File(relativeSparkHome, "bin/spark-submit");
+      FileUtils.writeStringToFile(sparkSubmit, "#!/bin/sh\n", "UTF-8");
+      Assertions.assertTrue(sparkSubmit.setExecutable(true));
+
+      // The Spark process runs in the job staging directory, so the resolved 
path must be
+      // absolute rather than relative to the server working directory.
+      String resolved =
+          SparkProcessBuilder.resolveSparkSubmit(
+              ImmutableMap.of(LocalJobExecutorConfigs.SPARK_HOME, 
relativeSparkHome.getPath()),
+              null);
+      Assertions.assertTrue(new File(resolved).isAbsolute());
+      Assertions.assertEquals(sparkSubmit.getAbsolutePath(), resolved);
+    } finally {
+      FileUtils.deleteDirectory(relativeSparkHome);
+    }
+  }
+
   @Test
   public void testSparkEnvironmentInfoOutputWithoutValue() {
     SparkJobTemplate template =
diff --git a/docs/manage-jobs-in-gravitino.md b/docs/manage-jobs-in-gravitino.md
index 67fddc8901..c3e93de708 100644
--- a/docs/manage-jobs-in-gravitino.md
+++ b/docs/manage-jobs-in-gravitino.md
@@ -69,8 +69,9 @@ curl -X POST -H "Accept: application/vnd.gravitino.v1+json" \
 ### Register a Spark Template
 
 A Spark template submits an application. Running one with the local executor 
needs either
-`gravitino.jobExecutor.local.sparkHome` or `SPARK_HOME` set before the server 
starts, or the job
-fails to launch.
+`gravitino.jobExecutor.local.sparkHome` or `SPARK_HOME` set before the server 
starts, pointing to a
+Spark installation with an executable `bin/spark-submit`. Otherwise, the run 
request is rejected with
+an error that names the missing setting, and no job is created.
 
 ```json
 {
diff --git a/docs/open-api/jobs.yaml b/docs/open-api/jobs.yaml
index 70d3a80602..de7ff98671 100644
--- a/docs/open-api/jobs.yaml
+++ b/docs/open-api/jobs.yaml
@@ -250,6 +250,15 @@ paths:
               examples:
                 JobTemplateAlreadyExistsException:
                   $ref: "#/components/examples/NoSuchJobTemplateException"
+        "400":
+          description: Bad Request - The job cannot be launched by the job 
executor, for example, a required job executor configuration is missing
+          content:
+            application/vnd.gravitino.v1+json:
+              schema:
+                $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+              examples:
+                JobRunIllegalArgumentException:
+                  $ref: "#/components/examples/JobRunIllegalArgumentException"
         "5xx":
           $ref: "./openapi.yaml#/components/responses/ServerErrorResponse"
 
@@ -991,3 +1000,14 @@ components:
           "..."
         ]
       }
+
+    JobRunIllegalArgumentException:
+      value: {
+        "code": 1001,
+        "type": "IllegalArgumentException",
+        "message": "Failed to operate job(s) operation [RUN] under object 
[my_test_metalake], reason [gravitino.jobExecutor.local.sparkHome or SPARK_HOME 
environment variable must be set for Spark jobs]",
+        "stack": [
+          "java.lang.IllegalArgumentException: 
gravitino.jobExecutor.local.sparkHome or SPARK_HOME environment variable must 
be set for Spark jobs",
+          "..."
+        ]
+      }

Reply via email to