jerryshao commented on code in PR #13134:
URL: https://github.com/apache/gravitino/pull/13134#discussion_r4015334111


##########
maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergRewriteDataFilesJob.java:
##########
@@ -407,19 +426,19 @@ static Map<String, String> parseOptionsJson(String 
optionsJson) {
    */
   private static List<String> buildArguments() {
     return Arrays.asList(
-        "--catalog",
+        "--" + IcebergJobUtils.OPTION_CATALOG,
         "{{catalog_name}}",
-        "--table",
+        "--" + IcebergJobUtils.OPTION_TABLE,
         "{{table_identifier}}",
-        "--strategy",
+        "--" + OPTION_STRATEGY,
         "{{strategy}}",
-        "--sort-order",
+        "--" + OPTION_SORT_ORDER,
         "{{sort_order}}",
-        "--where",
+        "--" + OPTION_WHERE,

Review Comment:
   **correctness (high) — regression introduced by this round's fix**: The 
optional `--where` flag (`OPTION_WHERE = "where"`) pairs with placeholder 
`{{where_clause}}` here — the names don't match after normalization, so 
`JobManager.isMatchingUnresolvedFlagValue` never recognizes this as a droppable 
optional pair. The *old* logic (`isUnresolvedOptionalValue`) only checked 
whether the whole token was a placeholder, with no name-matching requirement, 
so it correctly dropped `{{where_clause}}` when unresolved — the new 
name-matching requirement broke this specific pair.
   
   Submit `builtin-iceberg-rewrite-data-files` omitting `where_clause` (a 
completely normal, non-adversarial choice not to filter — the class's own 
Javadoc documents `jobConf.put("where_clause", ...)` as the expected key). 
`omitEmptyArguments` doesn't drop the pair (name mismatch), and 
`rejectEmbeddedUnresolvedPlaceholder` doesn't fire either since 
`{{where_clause}}` is a whole-token match, not an embedded one. The literal 
string `"{{where_clause}}"` is passed to the job process, parsed as 
`whereClause`, and embedded verbatim into the generated SQL as `where => 
'{{where_clause}}'` — producing a SQL failure instead of running unfiltered. No 
test exercises this: `TestIcebergRewriteDataFilesJob` only asserts the raw 
(unsubstituted) `buildArguments()` list, and none of the new `TestJobTemplate` 
tests use `where_clause`.
   
   ---
   
   **correctness**: The required `--catalog`/`{{catalog_name}}` and 
`--table`/`{{table_identifier}}` pairs a few lines above have the same name 
mismatch. This one is pre-existing (not introduced by this diff) but directly 
relevant to a PR titled "Harden built-in Iceberg jobs": if a caller omits 
`catalog_name`, the literal string `"{{catalog_name}}"` is non-null, so the 
job's own `catalogName == null` guard doesn't trigger, and Spark/Iceberg later 
fails with an opaque catalog-resolution error instead of the intended clear 
message.



##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -841,35 +844,41 @@ public static JobTemplate createRuntimeJobTemplate(
     String comment = jobTemplateEntity.comment();
 
     JobTemplateEntity.TemplateContent content = 
jobTemplateEntity.templateContent();
-    String executable =
-        fetchFileFromUri(
-            replacePlaceholder(content.executable(), jobConf), stagingDir, 
TIMEOUT_IN_MS);
+    String executableUri = replacePlaceholder(content.executable(), jobConf);
+    rejectEmbeddedUnresolvedPlaceholder(executableUri, "executable");
+    String executable = fetchFileFromUri(executableUri, stagingDir, 
TIMEOUT_IN_MS);
 
     List<String> args =

Review Comment:
   **correctness**: This PR's javadoc/tests advertise that an explicit empty 
string in `jobConf` is now kept rather than dropped. But the downstream CLI 
parser used by all three built-in Iceberg jobs 
(`IcebergJobUtils.parseArguments`, untouched by this PR) unconditionally drops 
any `--flag`/empty-value pair via `if (value != null && 
!value.trim().isEmpty())`. The two layers are out of sync, so this advertised 
behavior has no observable effect for any built-in Iceberg job: a caller 
setting `jobConf.put("updater_options", "")` to explicitly clear/override the 
default gets `"--updater-options" ""` kept here, but 
`argMap.get("updater-options")` is still `null` once the job process parses it 
— identical to the key never having been supplied.



##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -947,17 +960,143 @@ static String replacePlaceholder(String inputString, 
Map<String, String> replace
       String key = matcher.group(1);
       String replacement = replacements.get(key);
       if (replacement != null) {
-        matcher.appendReplacement(result, replacement);
+        matcher.appendReplacement(result, 
Matcher.quoteReplacement(replacement));
       } else {
         // If no replacement is found, keep the placeholder as is
-        matcher.appendReplacement(result, matcher.group(0));
+        matcher.appendReplacement(result, 
Matcher.quoteReplacement(matcher.group(0)));
       }
     }
     matcher.appendTail(result);
 
     return result.toString();
   }
 
+  /**
+   * Drop unresolved optional {@code --flag}/value pairs after placeholder 
substitution.
+   *
+   * <p>Built-in templates list optional flags as {@code --flag} + {@code 
{{placeholder}}} with
+   * matching names (for example {@code --updater-options} / {@code 
{{updater_options}}}). When the
+   * job conf omits that key, leaving the unresolved placeholder produces 
dangling arguments such as
+   * {@code --updater-options {{updater_options}}}. This method removes a pair 
only when the
+   * following token is an entire unresolved placeholder whose name matches 
the flag (after
+   * normalizing {@code -} / {@code _} and case).
+   *
+   * <p>A boolean flag followed by an unrelated placeholder (for example 
{@code --verbose} / {@code
+   * {{unset_flag}}}) is not treated as a pair. Bare / positional unresolved 
placeholders (for
+   * example {@code {{slices}}} in SparkPi) are kept so a missing required 
argument stays visible.
+   * An explicit empty string in {@code jobConf} is also kept.
+   *
+   * @param arguments arguments after {@link #replacePlaceholder(String, 
Map)}; must not be null
+   * @return compacted argument list suitable for process execution; never null
+   */
+  @VisibleForTesting
+  static List<String> omitEmptyArguments(List<String> arguments) {
+    Preconditions.checkNotNull(arguments, "arguments");
+    if (arguments.isEmpty()) {
+      return arguments;
+    }
+
+    List<String> result = new ArrayList<>(arguments.size());
+    for (int i = 0; i < arguments.size(); i++) {
+      String arg = arguments.get(i);
+      if (arg.startsWith("--") && i + 1 < arguments.size()) {
+        String next = arguments.get(i + 1);
+        if (!next.startsWith("--") && isMatchingUnresolvedFlagValue(arg, 
next)) {
+          i++;
+          continue;
+        }
+      }
+
+      result.add(arg);
+    }
+    return result;
+  }
+
+  /**
+   * Whether {@code value} is an unresolved {@code {{placeholder}}} whose name 
matches {@code
+   * flagArg} (for example {@code --updater-options} matches {@code 
{{updater_options}}}).
+   */
+  @VisibleForTesting
+  static boolean isMatchingUnresolvedFlagValue(String flagArg, String value) {
+    if (!flagArg.startsWith("--") || !isUnresolvedPlaceholder(value)) {
+      return false;
+    }
+    Matcher matcher = PLACEHOLDER_PATTERN.matcher(value);
+    if (!matcher.matches()) {
+      return false;
+    }
+    String normalizedFlag = flagArg.substring(2).replace('-', 
'_').toLowerCase(Locale.ROOT);
+    String normalizedPlaceholder = matcher.group(1).replace('-', 
'_').toLowerCase(Locale.ROOT);
+    return normalizedFlag.equals(normalizedPlaceholder);
+  }
+
+  /**
+   * Resolves optional template maps such as {@code environments} and Spark 
{@code configs}. Entries
+   * whose keys or values are still an unresolved {@code {{placeholder}}} 
after substitution are
+   * dropped so optional credentials / Spark confs do not become literal 
placeholder strings.
+   *
+   * <p>Explicit empty strings from {@code jobConf} are kept. {@code 
arguments} use {@link
+   * #omitEmptyArguments(List)}; {@code customFields} still keep unresolved 
placeholders as literal
+   * text when the entire token is one placeholder.
+   *
+   * <p>If two entries resolve to the same key after placeholder substitution, 
this method fails
+   * fast with {@link IllegalStateException}, matching {@link 
Collectors#toMap}.
+   *
+   * <p>Composite values that still embed an unresolved placeholder (for 
example {@code
+   * prefix-{{missing}}} or {@code {{a}}.{{b}}} with only one key supplied) 
fail fast with {@link
+   * IllegalArgumentException} instead of being passed through as literal text.
+   *
+   * @param source template map before substitution
+   * @param jobConf replacement values
+   * @param context label used in error messages (for example {@code 
environment} or {@code
+   *     configs})
+   * @return resolved map without unresolved optional entries
+   */
+  private static Map<String, String> omitUnresolvedTemplateMap(

Review Comment:
   **conventions**: This new `private` `omitUnresolvedTemplateMap` is inserted 
between two package-private `@VisibleForTesting` methods 
(`isMatchingUnresolvedFlagValue` above it, 
`isUnresolvedPlaceholder`/`rejectEmbeddedUnresolvedPlaceholder` below it), 
breaking the visibility ordering CLAUDE.md requires — a new violation 
introduced by this round's fixes (the previously-reported ordering issue was in 
`IcebergJobUtils.java` and is correctly fixed there).
   
   CLAUDE.md's Class Member Ordering rule: "Methods (Group by visibility, 
putting `private` methods at the end)."



##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -947,17 +960,143 @@ static String replacePlaceholder(String inputString, 
Map<String, String> replace
       String key = matcher.group(1);
       String replacement = replacements.get(key);
       if (replacement != null) {
-        matcher.appendReplacement(result, replacement);
+        matcher.appendReplacement(result, 
Matcher.quoteReplacement(replacement));
       } else {
         // If no replacement is found, keep the placeholder as is
-        matcher.appendReplacement(result, matcher.group(0));
+        matcher.appendReplacement(result, 
Matcher.quoteReplacement(matcher.group(0)));
       }
     }
     matcher.appendTail(result);
 
     return result.toString();
   }
 
+  /**
+   * Drop unresolved optional {@code --flag}/value pairs after placeholder 
substitution.
+   *
+   * <p>Built-in templates list optional flags as {@code --flag} + {@code 
{{placeholder}}} with
+   * matching names (for example {@code --updater-options} / {@code 
{{updater_options}}}). When the
+   * job conf omits that key, leaving the unresolved placeholder produces 
dangling arguments such as
+   * {@code --updater-options {{updater_options}}}. This method removes a pair 
only when the
+   * following token is an entire unresolved placeholder whose name matches 
the flag (after
+   * normalizing {@code -} / {@code _} and case).
+   *
+   * <p>A boolean flag followed by an unrelated placeholder (for example 
{@code --verbose} / {@code
+   * {{unset_flag}}}) is not treated as a pair. Bare / positional unresolved 
placeholders (for
+   * example {@code {{slices}}} in SparkPi) are kept so a missing required 
argument stays visible.
+   * An explicit empty string in {@code jobConf} is also kept.
+   *
+   * @param arguments arguments after {@link #replacePlaceholder(String, 
Map)}; must not be null
+   * @return compacted argument list suitable for process execution; never null
+   */
+  @VisibleForTesting
+  static List<String> omitEmptyArguments(List<String> arguments) {
+    Preconditions.checkNotNull(arguments, "arguments");
+    if (arguments.isEmpty()) {
+      return arguments;
+    }
+
+    List<String> result = new ArrayList<>(arguments.size());
+    for (int i = 0; i < arguments.size(); i++) {
+      String arg = arguments.get(i);
+      if (arg.startsWith("--") && i + 1 < arguments.size()) {
+        String next = arguments.get(i + 1);
+        if (!next.startsWith("--") && isMatchingUnresolvedFlagValue(arg, 
next)) {
+          i++;
+          continue;
+        }
+      }
+
+      result.add(arg);
+    }
+    return result;
+  }
+
+  /**
+   * Whether {@code value} is an unresolved {@code {{placeholder}}} whose name 
matches {@code
+   * flagArg} (for example {@code --updater-options} matches {@code 
{{updater_options}}}).
+   */
+  @VisibleForTesting
+  static boolean isMatchingUnresolvedFlagValue(String flagArg, String value) {
+    if (!flagArg.startsWith("--") || !isUnresolvedPlaceholder(value)) {
+      return false;
+    }
+    Matcher matcher = PLACEHOLDER_PATTERN.matcher(value);
+    if (!matcher.matches()) {
+      return false;
+    }
+    String normalizedFlag = flagArg.substring(2).replace('-', 
'_').toLowerCase(Locale.ROOT);
+    String normalizedPlaceholder = matcher.group(1).replace('-', 
'_').toLowerCase(Locale.ROOT);
+    return normalizedFlag.equals(normalizedPlaceholder);
+  }
+
+  /**
+   * Resolves optional template maps such as {@code environments} and Spark 
{@code configs}. Entries
+   * whose keys or values are still an unresolved {@code {{placeholder}}} 
after substitution are
+   * dropped so optional credentials / Spark confs do not become literal 
placeholder strings.
+   *
+   * <p>Explicit empty strings from {@code jobConf} are kept. {@code 
arguments} use {@link
+   * #omitEmptyArguments(List)}; {@code customFields} still keep unresolved 
placeholders as literal
+   * text when the entire token is one placeholder.
+   *
+   * <p>If two entries resolve to the same key after placeholder substitution, 
this method fails
+   * fast with {@link IllegalStateException}, matching {@link 
Collectors#toMap}.
+   *
+   * <p>Composite values that still embed an unresolved placeholder (for 
example {@code
+   * prefix-{{missing}}} or {@code {{a}}.{{b}}} with only one key supplied) 
fail fast with {@link
+   * IllegalArgumentException} instead of being passed through as literal text.
+   *
+   * @param source template map before substitution
+   * @param jobConf replacement values
+   * @param context label used in error messages (for example {@code 
environment} or {@code
+   *     configs})
+   * @return resolved map without unresolved optional entries
+   */
+  private static Map<String, String> omitUnresolvedTemplateMap(
+      Map<String, String> source, Map<String, String> jobConf, String context) 
{
+    Map<String, String> resolved = new LinkedHashMap<>();
+    for (Map.Entry<String, String> entry : source.entrySet()) {
+      String key = replacePlaceholder(entry.getKey(), jobConf);
+      String value = replacePlaceholder(entry.getValue(), jobConf);
+      if (isUnresolvedPlaceholder(key) || isUnresolvedPlaceholder(value)) {
+        continue;
+      }
+      rejectEmbeddedUnresolvedPlaceholder(key, context + " key");
+      rejectEmbeddedUnresolvedPlaceholder(value, context + " value");
+      if (resolved.containsKey(key)) {
+        throw new IllegalStateException(
+            String.format(
+                "Duplicate key %s (attempted merging values %s and %s)",

Review Comment:
   **correctness**: This new `IllegalStateException` (thrown on a duplicate key 
after placeholder substitution) is not special-cased by `JobExceptionHandler` 
on the server side (which only handles 
`IllegalArgumentException`/`NotFoundException`/`NotInUseException`/`ForbiddenException`),
 so it falls through to the generic handler: HTTP 500 instead of 400, and it 
also pollutes `ServerHealth.recordFailure` as if it were an internal server 
fault.
   
   A user submitting a job via `POST .../jobs/runs` with a `jobConf` where two 
environment/config keys collapse to the same string after substitution (exactly 
this PR's own new test scenario) gets an unhelpful 500 for what is purely a 
client input error.



##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -947,17 +960,143 @@ static String replacePlaceholder(String inputString, 
Map<String, String> replace
       String key = matcher.group(1);
       String replacement = replacements.get(key);
       if (replacement != null) {
-        matcher.appendReplacement(result, replacement);
+        matcher.appendReplacement(result, 
Matcher.quoteReplacement(replacement));
       } else {
         // If no replacement is found, keep the placeholder as is
-        matcher.appendReplacement(result, matcher.group(0));
+        matcher.appendReplacement(result, 
Matcher.quoteReplacement(matcher.group(0)));
       }
     }
     matcher.appendTail(result);
 
     return result.toString();
   }
 
+  /**
+   * Drop unresolved optional {@code --flag}/value pairs after placeholder 
substitution.
+   *
+   * <p>Built-in templates list optional flags as {@code --flag} + {@code 
{{placeholder}}} with
+   * matching names (for example {@code --updater-options} / {@code 
{{updater_options}}}). When the
+   * job conf omits that key, leaving the unresolved placeholder produces 
dangling arguments such as
+   * {@code --updater-options {{updater_options}}}. This method removes a pair 
only when the
+   * following token is an entire unresolved placeholder whose name matches 
the flag (after
+   * normalizing {@code -} / {@code _} and case).
+   *
+   * <p>A boolean flag followed by an unrelated placeholder (for example 
{@code --verbose} / {@code
+   * {{unset_flag}}}) is not treated as a pair. Bare / positional unresolved 
placeholders (for
+   * example {@code {{slices}}} in SparkPi) are kept so a missing required 
argument stays visible.
+   * An explicit empty string in {@code jobConf} is also kept.
+   *
+   * @param arguments arguments after {@link #replacePlaceholder(String, 
Map)}; must not be null
+   * @return compacted argument list suitable for process execution; never null
+   */
+  @VisibleForTesting
+  static List<String> omitEmptyArguments(List<String> arguments) {
+    Preconditions.checkNotNull(arguments, "arguments");
+    if (arguments.isEmpty()) {
+      return arguments;
+    }
+
+    List<String> result = new ArrayList<>(arguments.size());
+    for (int i = 0; i < arguments.size(); i++) {
+      String arg = arguments.get(i);
+      if (arg.startsWith("--") && i + 1 < arguments.size()) {
+        String next = arguments.get(i + 1);
+        if (!next.startsWith("--") && isMatchingUnresolvedFlagValue(arg, 
next)) {
+          i++;
+          continue;
+        }
+      }
+
+      result.add(arg);
+    }
+    return result;
+  }
+
+  /**
+   * Whether {@code value} is an unresolved {@code {{placeholder}}} whose name 
matches {@code
+   * flagArg} (for example {@code --updater-options} matches {@code 
{{updater_options}}}).
+   */
+  @VisibleForTesting
+  static boolean isMatchingUnresolvedFlagValue(String flagArg, String value) {
+    if (!flagArg.startsWith("--") || !isUnresolvedPlaceholder(value)) {
+      return false;
+    }
+    Matcher matcher = PLACEHOLDER_PATTERN.matcher(value);
+    if (!matcher.matches()) {
+      return false;
+    }
+    String normalizedFlag = flagArg.substring(2).replace('-', 
'_').toLowerCase(Locale.ROOT);
+    String normalizedPlaceholder = matcher.group(1).replace('-', 
'_').toLowerCase(Locale.ROOT);
+    return normalizedFlag.equals(normalizedPlaceholder);
+  }
+
+  /**
+   * Resolves optional template maps such as {@code environments} and Spark 
{@code configs}. Entries
+   * whose keys or values are still an unresolved {@code {{placeholder}}} 
after substitution are
+   * dropped so optional credentials / Spark confs do not become literal 
placeholder strings.
+   *
+   * <p>Explicit empty strings from {@code jobConf} are kept. {@code 
arguments} use {@link
+   * #omitEmptyArguments(List)}; {@code customFields} still keep unresolved 
placeholders as literal
+   * text when the entire token is one placeholder.
+   *
+   * <p>If two entries resolve to the same key after placeholder substitution, 
this method fails
+   * fast with {@link IllegalStateException}, matching {@link 
Collectors#toMap}.
+   *
+   * <p>Composite values that still embed an unresolved placeholder (for 
example {@code
+   * prefix-{{missing}}} or {@code {{a}}.{{b}}} with only one key supplied) 
fail fast with {@link
+   * IllegalArgumentException} instead of being passed through as literal text.
+   *
+   * @param source template map before substitution
+   * @param jobConf replacement values
+   * @param context label used in error messages (for example {@code 
environment} or {@code
+   *     configs})
+   * @return resolved map without unresolved optional entries
+   */
+  private static Map<String, String> omitUnresolvedTemplateMap(
+      Map<String, String> source, Map<String, String> jobConf, String context) 
{
+    Map<String, String> resolved = new LinkedHashMap<>();
+    for (Map.Entry<String, String> entry : source.entrySet()) {
+      String key = replacePlaceholder(entry.getKey(), jobConf);
+      String value = replacePlaceholder(entry.getValue(), jobConf);
+      if (isUnresolvedPlaceholder(key) || isUnresolvedPlaceholder(value)) {
+        continue;
+      }
+      rejectEmbeddedUnresolvedPlaceholder(key, context + " key");
+      rejectEmbeddedUnresolvedPlaceholder(value, context + " value");
+      if (resolved.containsKey(key)) {
+        throw new IllegalStateException(
+            String.format(
+                "Duplicate key %s (attempted merging values %s and %s)",
+                key, resolved.get(key), value));
+      }
+      resolved.put(key, value);
+    }
+    return resolved;
+  }
+
+  /**
+   * Whether {@code value} is exactly one unresolved {@code {{placeholder}}} 
token.
+   *
+   * <p>Blank / empty strings are not unresolved placeholders: a present 
{@code jobConf} key with
+   * value {@code ""} means the caller explicitly supplied an empty value.
+   */
+  @VisibleForTesting
+  static boolean isUnresolvedPlaceholder(String value) {
+    return value != null && PLACEHOLDER_PATTERN.matcher(value).matches();
+  }
+
+  @VisibleForTesting
+  static void rejectEmbeddedUnresolvedPlaceholder(String value, String 
context) {

Review Comment:
   **conventions**: The earlier-round "missing `@Nullable`" finding wasn't 
actually fixed, just relocated — `rejectEmbeddedUnresolvedPlaceholder(String 
value, String context)` and `isUnresolvedPlaceholder(String value)` both safely 
accept and handle `null` (guarded internally, exercised with `null` in tests) 
but carry no `@Nullable` annotation, and the file has no such import at all.
   
   ---
   
   **efficiency (minor)**: `rejectEmbeddedUnresolvedPlaceholder` calls 
`isUnresolvedPlaceholder` (a full `.matches()` pass) then separately does 
`PLACEHOLDER_PATTERN.matcher(value).find()` — two full regex passes and two 
`Matcher` allocations per value, invoked up to ~9 times per job template 
resolution. `isMatchingUnresolvedFlagValue` and `omitUnresolvedTemplateMap` 
have the same doubled/tripled-pass pattern. Reusing one `Matcher` (match once, 
branch, then read the result) would remove the redundant passes — low severity 
since this isn't a hot loop, but an easy win.



##########
core/src/test/java/org/apache/gravitino/job/TestJobTemplate.java:
##########
@@ -458,4 +458,415 @@ public void 
testCreateSparkRuntimeJobTemplateWithReplacements() throws IOExcepti
                 .collect(Collectors.toList());
     Assertions.assertTrue(archiveNames.contains(archive1.getName()));
   }
+
+  @Test
+  public void testOmitEmptyArguments() {
+    // Explicit "" after --updater-options is kept; name-matched --options 
{{options}} is dropped;
+    // bare {{stream_results}} / {{slices}} are kept so missing positionals 
stay visible.
+    Assertions.assertEquals(
+        Lists.newArrayList(
+            "--catalog",
+            "iceberg",
+            "--table",
+            "db.t",
+            "--updater-options",
+            "",
+            "--spark-conf",
+            "{\"k\":\"v\"}",
+            "{{stream_results}}",
+            "  "),
+        JobManager.omitEmptyArguments(
+            Lists.newArrayList(
+                "--catalog",
+                "iceberg",
+                "--table",
+                "db.t",
+                "--updater-options",
+                "",
+                "--spark-conf",
+                "{\"k\":\"v\"}",
+                "--options",
+                "{{options}}",
+                "{{stream_results}}",
+                "  ")));
+
+    Assertions.assertEquals(
+        Lists.newArrayList("{{slices}}"),
+        JobManager.omitEmptyArguments(Lists.newArrayList("{{slices}}")));
+
+    // Boolean flag + unrelated placeholder must not be dropped as a pair.
+    Assertions.assertEquals(
+        Lists.newArrayList("--verbose", "{{unset_flag}}"),
+        JobManager.omitEmptyArguments(Lists.newArrayList("--verbose", 
"{{unset_flag}}")));
+
+    // Hyphen / underscore names still match for built-in optional flags.
+    Assertions.assertEquals(
+        Lists.newArrayList(),
+        JobManager.omitEmptyArguments(
+            Lists.newArrayList("--updater-options", "{{updater_options}}")));
+
+    Assertions.assertEquals(
+        Lists.newArrayList("--catalog", "iceberg", "--stream-results"),
+        JobManager.omitEmptyArguments(
+            Lists.newArrayList("--catalog", "iceberg", "--stream-results")));
+
+    Assertions.assertThrows(NullPointerException.class, () -> 
JobManager.omitEmptyArguments(null));
+    Assertions.assertEquals(
+        Lists.newArrayList(), 
JobManager.omitEmptyArguments(Lists.newArrayList()));
+  }
+
+  @Test
+  public void testCreateSparkRuntimeJobTemplateOmitsUnresolvedEnvironments() 
throws IOException {
+    File executable = Files.createTempFile(tempDir.toPath(), "testSparkJob", 
".jar").toFile();
+    SparkJobTemplate sparkJobTemplate =
+        SparkJobTemplate.builder()
+            .withName("testSparkJobAuthEnv")
+            .withExecutable(executable.toURI().toString())
+            .withClassName("org.apache.gravitino.TestSparkJob")
+            .withEnvironments(
+                ImmutableMap.of(
+                    "GRAVITINO_AUTH_TYPE",
+                    "{{gravitino_auth_type}}",
+                    "GRAVITINO_AUTH_USERNAME",
+                    "{{gravitino_auth_username}}",
+                    "KEEP_ME",
+                    "literal"))
+            .build();
+
+    JobTemplateEntity entity =
+        JobTemplateEntity.builder()
+            .withId(2L)
+            .withName(sparkJobTemplate.name())
+            .withNamespace(NamespaceUtil.ofJobTemplate("test"))
+            .withTemplateContent(
+                
JobTemplateEntity.TemplateContent.fromJobTemplate(sparkJobTemplate))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+            .build();
+
+    JobTemplate omitted =
+        JobManager.createRuntimeJobTemplate(entity, ImmutableMap.of(), 
tempStagingDir);
+    Assertions.assertEquals(ImmutableMap.of("KEEP_ME", "literal"), 
omitted.environments());
+
+    JobTemplate withEmptyAuthType =
+        JobManager.createRuntimeJobTemplate(
+            entity, ImmutableMap.of("gravitino_auth_type", ""), 
tempStagingDir);
+    Assertions.assertEquals(
+        ImmutableMap.of("GRAVITINO_AUTH_TYPE", "", "KEEP_ME", "literal"),
+        withEmptyAuthType.environments());
+
+    JobTemplate resolved =
+        JobManager.createRuntimeJobTemplate(
+            entity,
+            ImmutableMap.of(
+                "gravitino_auth_type", "basic",
+                "gravitino_auth_username", "admin"),
+            tempStagingDir);
+    Assertions.assertEquals(
+        ImmutableMap.of(
+            "GRAVITINO_AUTH_TYPE",
+            "basic",
+            "GRAVITINO_AUTH_USERNAME",
+            "admin",
+            "KEEP_ME",
+            "literal"),
+        resolved.environments());
+  }
+
+  @Test
+  public void testCreateSparkRuntimeJobTemplateOmitsUnresolvedConfigs() throws 
IOException {
+    File executable = Files.createTempFile(tempDir.toPath(), "testSparkJob", 
".jar").toFile();
+    SparkJobTemplate sparkJobTemplate =
+        SparkJobTemplate.builder()
+            .withName("testSparkJobConfigs")
+            .withExecutable(executable.toURI().toString())
+            .withClassName("org.apache.gravitino.TestSparkJob")
+            .withConfigs(
+                ImmutableMap.of(
+                    "spark.executor.instances",
+                    "{{spark_executor_instances}}",
+                    "spark.executor.memory",
+                    "{{spark_executor_memory}}",
+                    "spark.master",
+                    "local"))
+            .build();
+
+    JobTemplateEntity entity =
+        JobTemplateEntity.builder()
+            .withId(6L)
+            .withName(sparkJobTemplate.name())
+            .withNamespace(NamespaceUtil.ofJobTemplate("test"))
+            .withTemplateContent(
+                
JobTemplateEntity.TemplateContent.fromJobTemplate(sparkJobTemplate))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+            .build();
+
+    JobTemplate omitted =
+        JobManager.createRuntimeJobTemplate(entity, ImmutableMap.of(), 
tempStagingDir);
+    Assertions.assertEquals(
+        ImmutableMap.of("spark.master", "local"), ((SparkJobTemplate) 
omitted).configs());
+
+    JobTemplate withEmptyInstances =
+        JobManager.createRuntimeJobTemplate(
+            entity, ImmutableMap.of("spark_executor_instances", ""), 
tempStagingDir);
+    Assertions.assertEquals(
+        ImmutableMap.of("spark.executor.instances", "", "spark.master", 
"local"),
+        ((SparkJobTemplate) withEmptyInstances).configs());
+
+    JobTemplate resolved =
+        JobManager.createRuntimeJobTemplate(
+            entity,
+            ImmutableMap.of(
+                "spark_executor_instances", "2",
+                "spark_executor_memory", "1g"),
+            tempStagingDir);
+    Assertions.assertEquals(
+        ImmutableMap.of(
+            "spark.executor.instances",
+            "2",
+            "spark.executor.memory",
+            "1g",
+            "spark.master",
+            "local"),
+        ((SparkJobTemplate) resolved).configs());
+  }
+
+  @Test
+  public void testCreateRuntimeJobTemplateFailsOnEmbeddedUnresolvedConfig() 
throws IOException {
+    File executable = Files.createTempFile(tempDir.toPath(), "testSparkJob", 
".jar").toFile();
+    SparkJobTemplate sparkJobTemplate =
+        SparkJobTemplate.builder()
+            .withName("testSparkJobEmbeddedConfig")
+            .withExecutable(executable.toURI().toString())
+            .withClassName("org.apache.gravitino.TestSparkJob")
+            .withConfigs(ImmutableMap.of("spark.sql.extensions", 
"prefix-{{missing_ext}}"))
+            .build();
+
+    JobTemplateEntity entity =
+        JobTemplateEntity.builder()
+            .withId(7L)
+            .withName(sparkJobTemplate.name())
+            .withNamespace(NamespaceUtil.ofJobTemplate("test"))
+            .withTemplateContent(
+                
JobTemplateEntity.TemplateContent.fromJobTemplate(sparkJobTemplate))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+            .build();
+
+    IllegalArgumentException thrown =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () -> JobManager.createRuntimeJobTemplate(entity, 
ImmutableMap.of(), tempStagingDir));
+    
Assertions.assertTrue(thrown.getMessage().contains("prefix-{{missing_ext}}"));
+  }
+
+  @Test
+  public void testCreateRuntimeJobTemplateFailsOnDuplicateEnvironmentKeys() 
throws IOException {
+    File executable = Files.createTempFile(tempDir.toPath(), "testSparkJob", 
".jar").toFile();
+    SparkJobTemplate sparkJobTemplate =
+        SparkJobTemplate.builder()
+            .withName("testSparkJobDupEnv")
+            .withExecutable(executable.toURI().toString())
+            .withClassName("org.apache.gravitino.TestSparkJob")
+            .withEnvironments(ImmutableMap.of("{{A}}", "v1", "{{B}}", "v2"))
+            .build();
+
+    JobTemplateEntity entity =
+        JobTemplateEntity.builder()
+            .withId(3L)
+            .withName(sparkJobTemplate.name())
+            .withNamespace(NamespaceUtil.ofJobTemplate("test"))
+            .withTemplateContent(
+                
JobTemplateEntity.TemplateContent.fromJobTemplate(sparkJobTemplate))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+            .build();
+
+    IllegalStateException thrown =
+        Assertions.assertThrows(
+            IllegalStateException.class,
+            () ->
+                JobManager.createRuntimeJobTemplate(
+                    entity, ImmutableMap.of("A", "SAME", "B", "SAME"), 
tempStagingDir));
+    Assertions.assertTrue(thrown.getMessage().contains("Duplicate key SAME"));
+  }
+
+  @Test
+  public void testRejectEmbeddedUnresolvedPlaceholder() {

Review Comment:
   **test-coverage**: Of the ~9 `rejectEmbeddedUnresolvedPlaceholder` call 
sites added by the fix commits (`executable`, `argument`, `customFields` 
key/value, `script`, `className`, `jar`, `file`, `archive`, plus the map-based 
`environment`/`configs` sites), only `configs`, `environment`, and one 
composite `argument` case are exercised end-to-end through 
`createRuntimeJobTemplate`. 
`executable`/`script`/`className`/`jar`/`file`/`archive`/`customFields` have no 
failure-path test — `testRejectEmbeddedUnresolvedPlaceholder` here only 
unit-tests the helper directly with a generic `"test"` context, not these 
specific call sites.
   
   CLAUDE.md: "Write unit tests for ALL new logic. NO tests = NO merge."



##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -886,35 +895,39 @@ public static JobTemplate createRuntimeJobTemplate(
     // For Spark job template
     if (content.jobType() == JobTemplate.JobType.SPARK) {
       String className = replacePlaceholder(content.className(), jobConf);
+      rejectEmbeddedUnresolvedPlaceholder(className, "className");
       List<String> jars =
           content.jars().stream()
               .map(
-                  jar ->
-                      fetchFileFromUri(replacePlaceholder(jar, jobConf), 
stagingDir, TIMEOUT_IN_MS))
+                  jar -> {
+                    String resolved = replacePlaceholder(jar, jobConf);
+                    rejectEmbeddedUnresolvedPlaceholder(resolved, "jar");
+                    return fetchFileFromUri(resolved, stagingDir, 
TIMEOUT_IN_MS);
+                  })
               .collect(Collectors.toList());
 
       List<String> files =
           content.files().stream()
               .map(
-                  file ->
-                      fetchFileFromUri(
-                          replacePlaceholder(file, jobConf), stagingDir, 
TIMEOUT_IN_MS))
+                  file -> {
+                    String resolved = replacePlaceholder(file, jobConf);
+                    rejectEmbeddedUnresolvedPlaceholder(resolved, "file");
+                    return fetchFileFromUri(resolved, stagingDir, 
TIMEOUT_IN_MS);
+                  })
               .collect(Collectors.toList());
 
       List<String> archives =
           content.archives().stream()
               .map(
-                  archive ->
-                      fetchFileFromUri(
-                          replacePlaceholder(archive, jobConf), stagingDir, 
TIMEOUT_IN_MS))
+                  archive -> {
+                    String resolved = replacePlaceholder(archive, jobConf);
+                    rejectEmbeddedUnresolvedPlaceholder(resolved, "archive");
+                    return fetchFileFromUri(resolved, stagingDir, 
TIMEOUT_IN_MS);
+                  })
               .collect(Collectors.toList());
 
       Map<String, String> configs =
-          content.configs().entrySet().stream()
-              .collect(
-                  Collectors.toMap(
-                      entry -> replacePlaceholder(entry.getKey(), jobConf),
-                      entry -> replacePlaceholder(entry.getValue(), jobConf)));
+          omitUnresolvedTemplateMap(content.configs(), jobConf, "configs");

Review Comment:
   **correctness (high)**: 
`IcebergSparkConfigUtils.buildTemplateSparkConfigs()`, shared by all three 
built-in Iceberg jobs, produces Spark config map **keys** that are themselves 
composite strings (`SPARK_SQL_CATALOG_PREFIX + "{{catalog_name}}"`, plus 
`.type`/`.uri`/`.warehouse` variants) — exactly the pattern 
`rejectEmbeddedUnresolvedPlaceholder` (called via `omitUnresolvedTemplateMap` 
here) is designed to reject.
   
   If a caller omits `catalog_name` from `jobConf` (which, per the companion 
comment on `IcebergRewriteDataFilesJob.java`, doesn't get caught earlier 
either), this line resolves that key to a string with an embedded-but-not-whole 
unresolved placeholder, and throws `IllegalArgumentException` with a 
`JobManager`-level message ("Unresolved placeholder remains embedded in configs 
key after substitution: spark.sql.catalog.{{catalog_name}}") instead of the 
job's own clear "Error: --catalog and --table are required arguments" message. 
This affects all three Iceberg jobs identically, and no test runs any of the 
three jobs' actual `jobTemplate()` output through `createRuntimeJobTemplate` to 
catch it — the new embedded-placeholder tests only use synthetic 
`SparkJobTemplate` builders with placeholders in *values*, never in composite 
*keys* like the real templates produce.



##########
maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateStatsAndMetricsJob.java:
##########
@@ -391,15 +400,22 @@ static Map<String, String> parseArguments(String[] args) {
 
   @VisibleForTesting
   static Map<String, String> parseCustomSparkConfigs(String sparkConfJson) {

Review Comment:
   **reuse**: This class still hand-rolls its own argument parser 
(`parseArguments`, a few lines above this method) instead of calling the shared 
`IcebergJobUtils.parseArguments` (used directly by 
`IcebergExpireSnapshotsJob`), and the local copy has no boolean-flag branch — 
`IcebergJobUtils.parseArguments` treats a trailing `--flag` with no value as 
`"true"`; this local copy just drops it. The class also reimplements 
`escapeSqlIdentifier` locally instead of delegating to `IcebergJobUtils`, while 
`IcebergRewriteDataFilesJob` goes the opposite direction (pure delegation 
wrappers) — the three sibling job classes have landed on inconsistent 
relationships with the shared utility class.
   
   A future boolean-flag CLI option added to this job would silently behave 
differently than the same pattern in the other two Iceberg jobs, and a bug fix 
to `IcebergJobUtils.parseArguments`/`escapeSqlIdentifier` wouldn't propagate 
here since this class never calls them.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to