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


##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -947,17 +945,91 @@ 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 blank / unresolved optional template arguments after placeholder 
substitution.
+   *
+   * <p>Built-in templates always list optional flags as {@code --flag} + 
{@code {{placeholder}}}.
+   * When the job conf omits that key or supplies an empty value, leaving the 
flag in the command
+   * produces dangling arguments such as {@code --updater-options 
--spark-conf}. This method
+   * removes:
+   *
+   * <ul>
+   *   <li>blank tokens
+   *   <li>tokens that are still an entire unresolved {@code {{placeholder}}}
+   *   <li>{@code --flag} pairs whose following value is blank or an 
unresolved placeholder
+   * </ul>
+   *
+   * @param arguments arguments after {@link #replacePlaceholder(String, Map)}
+   * @return compacted argument list suitable for process execution
+   */
+  @VisibleForTesting
+  static List<String> omitEmptyArguments(List<String> arguments) {
+    if (arguments == null || 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 (isUnresolvedOptionalValue(arg)) {

Review Comment:
   **correctness (high)**: `omitEmptyArguments` applies 
`isUnresolvedOptionalValue` to *every* argument position uniformly, not just 
the value half of a `--flag`/value pair — so it silently drops a 
bare/positional argument that never had a `--flag` prefix.
   
   This is reachable today, not just theoretical: `SparkPiJob.jobTemplate()` 
builds its arguments as `Collections.singletonList("{{slices}}")` (a single 
bare positional argument), and the existing test `TestJobManager.java:675` 
builds a `ShellJobTemplate` the same way with 
`withArguments(Lists.newArrayList("{{greeting}}"))`. If the caller omits that 
jobConf key, the argument used to arrive as a visibly-broken literal 
`"{{slices}}"`/`"{{greeting}}"` (loud, diagnosable) — now it's deleted 
entirely, silently shrinking `args.length` for any executable expecting a fixed 
argument count.
   
   ---
   
   **correctness**: Separately, the flag/value pairing heuristic a few lines 
below (`arg.startsWith("--") && ... isUnresolvedOptionalValue(next)`) assumes 
any `--flag` token immediately followed by a blank/unresolved token is that 
flag's own droppable value — with no way to tell that apart from an unrelated, 
independent optional-flag placeholder that just happens to sit next in the 
list. `omitEmptyArguments(List.of("--verbose", "{{unset_flag}}"))` returns `[]` 
instead of `["--verbose"]`. It doesn't trigger in the three shipped Iceberg 
templates only because every fixed flag there happens to be followed by its own 
value slot first — but it's a real hazard for any other 
`SparkJobTemplate`/`ShellJobTemplate` built the same way.
   
   ---
   
   **conventions**: Also, this method explicitly guards `arguments == null` and 
can return `null`, so null is a real in-contract input/output, but the 
parameter isn't annotated `@Nullable` per CLAUDE.md's "Use `@Nullable` 
annotations" rule (this is fresh logic — `JobManager.java` has zero 
pre-existing `@Nullable` usages to inherit from).



##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -846,15 +848,11 @@ public static JobTemplate createRuntimeJobTemplate(
             replacePlaceholder(content.executable(), jobConf), stagingDir, 
TIMEOUT_IN_MS);
 
     List<String> args =
-        content.arguments().stream()
-            .map(arg -> replacePlaceholder(arg, jobConf))
-            .collect(Collectors.toList());
-    Map<String, String> environments =
-        content.environments().entrySet().stream()
-            .collect(
-                Collectors.toMap(
-                    entry -> replacePlaceholder(entry.getKey(), jobConf),
-                    entry -> replacePlaceholder(entry.getValue(), jobConf)));
+        omitEmptyArguments(
+            content.arguments().stream()
+                .map(arg -> replacePlaceholder(arg, jobConf))
+                .collect(Collectors.toList()));
+    Map<String, String> environments = 
omitUnresolvedTemplateMap(content.environments(), jobConf);

Review Comment:
   **correctness**: `arguments`/`environments` get the new blank/placeholder 
filtering right here, but `SparkJobTemplate.configs` (built a few lines below 
at line ~910-915, the `--conf` map passed to `spark-submit`) is still built 
with plain `Collectors.toMap(...replacePlaceholder...)` and never routed 
through `omitUnresolvedTemplateMap`/`omitEmptyArguments`.
   
   All three Iceberg jobs call `.withConfigs(buildSparkConfigs())`, and 
`IcebergSparkConfigUtils.buildTemplateSparkConfigs()` fills optional keys like 
`spark.executor.instances` with `{{spark_executor_instances}}`. If a caller 
omits that optional jobConf key, the literal string 
`"{{spark_executor_instances}}"` stays in the resolved `configs` map and is 
emitted as `--conf spark.executor.instances={{spark_executor_instances}}` to 
`spark-submit` — exactly the "dangling unresolved placeholder" class of bug 
this PR claims to fix for arguments/environments, left open for Spark configs.



##########
maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergJobUtils.java:
##########
@@ -18,21 +18,69 @@
  */
 package org.apache.gravitino.maintenance.jobs.iceberg;
 
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.HashMap;
 import java.util.Map;
+import 
org.apache.gravitino.maintenance.optimizer.common.util.IcebergSparkConfigUtils;
 
 /**
  * Shared utility methods for Iceberg maintenance jobs.
  *
- * <p>Provides SQL escaping, argument parsing, and Spark configuration 
utilities used by both {@link
- * IcebergRewriteDataFilesJob} and {@link IcebergExpireSnapshotsJob}.
+ * <p>Provides SQL escaping, argument parsing, Spark configuration utilities, 
and classpath checks
+ * used by built-in Iceberg Spark jobs.
  */
 public final class IcebergJobUtils {
 
+  private static final String ICEBERG_SPARK_CATALOG = 
"org.apache.iceberg.spark.SparkCatalog";
+  private static final String OPTION_SPARK_CONF = "spark-conf";
+
   private IcebergJobUtils() {}
 
+  /**
+   * Ensures the Iceberg Spark runtime is on the current classpath.
+   *
+   * <p>Built-in templates configure {@code IcebergSparkSessionExtensions} and 
{@code SparkCatalog},
+   * but Spark only warns when those classes are missing and continues without 
Iceberg support. Call
+   * this after {@code SparkSession} creation (so {@code spark.jars} from 
{@code spark_conf} is
+   * visible) and fail the job when the runtime is absent.
+   *
+   * @throws IllegalStateException when required Iceberg Spark classes cannot 
be loaded
+   */
+  public static void requireIcebergSparkRuntime() {
+    requireClass(
+        IcebergSparkConfigUtils.ICEBERG_SPARK_EXTENSIONS, "Iceberg Spark 
session extensions");
+    requireClass(ICEBERG_SPARK_CATALOG, "Iceberg Spark catalog");
+  }
+
+  /** Visible for unit tests that assert the missing-class error message. */
+  static void requireClassForTest(String className, String description) {

Review Comment:
   **conventions**: The new package-private `requireClassForTest` and private 
`requireClass` are inserted directly after the constructor, between it and the 
class's pre-existing public methods (`escapeSqlString`, `escapeSqlIdentifier`, 
`parseArguments`, `parseCustomSparkConfigs`), breaking the visibility ordering 
the class had before this diff.
   
   CLAUDE.md's Class Member Ordering rule states: "Methods (Group by 
visibility, putting `private` methods at the end)." This insertion point 
produces public -> package-private -> private -> public -> public -> public -> 
public, sandwiching private/package-private methods in the middle instead of at 
the end. This ordering was clean before this PR.



##########
maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergJobUtils.java:
##########
@@ -18,21 +18,69 @@
  */
 package org.apache.gravitino.maintenance.jobs.iceberg;
 
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.HashMap;
 import java.util.Map;
+import 
org.apache.gravitino.maintenance.optimizer.common.util.IcebergSparkConfigUtils;
 
 /**
  * Shared utility methods for Iceberg maintenance jobs.
  *
- * <p>Provides SQL escaping, argument parsing, and Spark configuration 
utilities used by both {@link
- * IcebergRewriteDataFilesJob} and {@link IcebergExpireSnapshotsJob}.
+ * <p>Provides SQL escaping, argument parsing, Spark configuration utilities, 
and classpath checks
+ * used by built-in Iceberg Spark jobs.
  */
 public final class IcebergJobUtils {
 
+  private static final String ICEBERG_SPARK_CATALOG = 
"org.apache.iceberg.spark.SparkCatalog";
+  private static final String OPTION_SPARK_CONF = "spark-conf";
+
   private IcebergJobUtils() {}
 
+  /**
+   * Ensures the Iceberg Spark runtime is on the current classpath.
+   *
+   * <p>Built-in templates configure {@code IcebergSparkSessionExtensions} and 
{@code SparkCatalog},
+   * but Spark only warns when those classes are missing and continues without 
Iceberg support. Call
+   * this after {@code SparkSession} creation (so {@code spark.jars} from 
{@code spark_conf} is
+   * visible) and fail the job when the runtime is absent.
+   *
+   * @throws IllegalStateException when required Iceberg Spark classes cannot 
be loaded
+   */
+  public static void requireIcebergSparkRuntime() {
+    requireClass(
+        IcebergSparkConfigUtils.ICEBERG_SPARK_EXTENSIONS, "Iceberg Spark 
session extensions");
+    requireClass(ICEBERG_SPARK_CATALOG, "Iceberg Spark catalog");
+  }
+
+  /** Visible for unit tests that assert the missing-class error message. */
+  static void requireClassForTest(String className, String description) {
+    requireClass(className, description);
+  }
+
+  private static void requireClass(String className, String description) {
+    ClassLoader contextLoader = Thread.currentThread().getContextClassLoader();
+    ClassLoader fallbackLoader = IcebergJobUtils.class.getClassLoader();
+    try {
+      Class.forName(className, true, contextLoader != null ? contextLoader : 
fallbackLoader);

Review Comment:
   **efficiency**: `requireClass` calls `Class.forName(className, true, 
loader)` with `initialize=true`, forcing the target class's static initializer 
to run just to check it's on the classpath. Every job launch pays the cost of 
running `<clinit>` for `IcebergSparkSessionExtensions`/`SparkCatalog` just to 
confirm they're loadable — work Spark will redundantly repeat moments later 
when it actually uses them.
   
   Worse: if `iceberg-spark-runtime` is correctly on the classpath but 
`SparkCatalog`'s static init fails for an unrelated reason (e.g. a transitive 
dependency version conflict), the resulting `LinkageError` subtype is caught by 
this same generic handler and the job exits with "Missing Iceberg Spark catalog 
... Match the artifact to your Spark/Scala/Iceberg versions" even though the 
runtime jar is present and the real cause is a different classpath conflict — 
misdirecting operators. `Class.forName(className, false, loader)` would avoid 
both the wasted init cost and this misdiagnosis.



##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -947,17 +945,91 @@ 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 blank / unresolved optional template arguments after placeholder 
substitution.
+   *
+   * <p>Built-in templates always list optional flags as {@code --flag} + 
{@code {{placeholder}}}.
+   * When the job conf omits that key or supplies an empty value, leaving the 
flag in the command
+   * produces dangling arguments such as {@code --updater-options 
--spark-conf}. This method
+   * removes:
+   *
+   * <ul>
+   *   <li>blank tokens
+   *   <li>tokens that are still an entire unresolved {@code {{placeholder}}}
+   *   <li>{@code --flag} pairs whose following value is blank or an 
unresolved placeholder
+   * </ul>
+   *
+   * @param arguments arguments after {@link #replacePlaceholder(String, Map)}
+   * @return compacted argument list suitable for process execution
+   */
+  @VisibleForTesting
+  static List<String> omitEmptyArguments(List<String> arguments) {
+    if (arguments == null || 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 (isUnresolvedOptionalValue(arg)) {
+        continue;
+      }
+
+      if (arg.startsWith("--") && i + 1 < arguments.size()) {
+        String next = arguments.get(i + 1);
+        if (!next.startsWith("--") && isUnresolvedOptionalValue(next)) {
+          i++;
+          continue;
+        }
+      }
+
+      result.add(arg);
+    }
+    return result;
+  }
+
+  /**
+   * Resolves optional template maps such as {@code environments}. Entries 
whose keys or values are
+   * blank or still an unresolved {@code {{placeholder}}} after substitution 
are dropped so
+   * unauthenticated / optional credentials do not become literal placeholder 
strings.
+   *
+   * <p>{@code arguments} use {@link #omitEmptyArguments(List)}; {@code 
customFields} still keep
+   * unresolved placeholders as literal text.
+   *
+   * @param source template map before substitution
+   * @param jobConf replacement values
+   * @return resolved map without blank or unresolved optional entries
+   */
+  private static Map<String, String> omitUnresolvedTemplateMap(
+      Map<String, String> source, Map<String, String> jobConf) {
+    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 (isUnresolvedOptionalValue(key) || isUnresolvedOptionalValue(value)) {
+        continue;
+      }
+      resolved.put(key, value);

Review Comment:
   **correctness (high)**: `omitUnresolvedTemplateMap` replaced the old 
`Collectors.toMap(...)`-based resolution (which threw `IllegalStateException` 
on a duplicate-key collision after placeholder substitution) with this 
hand-rolled loop doing `resolved.put(key, value)` — silently keeping only the 
last value on a collision instead of failing loudly.
   
   A template's `environments = {"{{A}}": "v1", "{{B}}": "v2"}` submitted with 
`jobConf = {A: "SAME", B: "SAME"}` previously failed fast at job-creation time 
with a clear "Duplicate key" error. Now it silently produces `{"SAME": "v2"}`, 
dropping `v1` with no indication to the caller — a regression from fail-fast 
validation to silent data loss.



##########
core/src/main/java/org/apache/gravitino/job/JobManager.java:
##########
@@ -947,17 +945,91 @@ 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 blank / unresolved optional template arguments after placeholder 
substitution.
+   *
+   * <p>Built-in templates always list optional flags as {@code --flag} + 
{@code {{placeholder}}}.
+   * When the job conf omits that key or supplies an empty value, leaving the 
flag in the command
+   * produces dangling arguments such as {@code --updater-options 
--spark-conf}. This method
+   * removes:
+   *
+   * <ul>
+   *   <li>blank tokens
+   *   <li>tokens that are still an entire unresolved {@code {{placeholder}}}
+   *   <li>{@code --flag} pairs whose following value is blank or an 
unresolved placeholder
+   * </ul>
+   *
+   * @param arguments arguments after {@link #replacePlaceholder(String, Map)}
+   * @return compacted argument list suitable for process execution
+   */
+  @VisibleForTesting
+  static List<String> omitEmptyArguments(List<String> arguments) {
+    if (arguments == null || 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 (isUnresolvedOptionalValue(arg)) {
+        continue;
+      }
+
+      if (arg.startsWith("--") && i + 1 < arguments.size()) {
+        String next = arguments.get(i + 1);
+        if (!next.startsWith("--") && isUnresolvedOptionalValue(next)) {
+          i++;
+          continue;
+        }
+      }
+
+      result.add(arg);
+    }
+    return result;
+  }
+
+  /**
+   * Resolves optional template maps such as {@code environments}. Entries 
whose keys or values are
+   * blank or still an unresolved {@code {{placeholder}}} after substitution 
are dropped so
+   * unauthenticated / optional credentials do not become literal placeholder 
strings.
+   *
+   * <p>{@code arguments} use {@link #omitEmptyArguments(List)}; {@code 
customFields} still keep
+   * unresolved placeholders as literal text.
+   *
+   * @param source template map before substitution
+   * @param jobConf replacement values
+   * @return resolved map without blank or unresolved optional entries
+   */
+  private static Map<String, String> omitUnresolvedTemplateMap(
+      Map<String, String> source, Map<String, String> jobConf) {
+    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 (isUnresolvedOptionalValue(key) || isUnresolvedOptionalValue(value)) {
+        continue;
+      }
+      resolved.put(key, value);
+    }
+    return resolved;
+  }
+
+  @VisibleForTesting
+  static boolean isUnresolvedOptionalValue(String value) {
+    return StringUtils.isBlank(value) || 
PLACEHOLDER_PATTERN.matcher(value).matches();

Review Comment:
   **correctness**: `isUnresolvedOptionalValue` only detects a token that IS 
ENTIRELY one unresolved placeholder 
(`PLACEHOLDER_PATTERN.matcher(value).matches()`, a full-string match). A 
composite value with a literal prefix/suffix around an unresolved placeholder — 
or multiple concatenated placeholders where only one resolves — is never 
classified as unresolved and passes through with literal `{{...}}` text still 
embedded.
   
   An argument or environment value built as `"prefix-{{missing_key}}"` or 
`"{{a}}.{{b}}"` (only one of `a`/`b` supplied) resolves via 
`replacePlaceholder` to e.g. `"prefix-{{missing_key}}"`, which is not blank and 
does not fully match the placeholder pattern, so it's kept verbatim and handed 
to the job process — reproducing the exact dangling-literal-placeholder defect 
(a fake-looking credential, a broken SQL/JSON fragment) this PR's own 
description says it eliminates, just for composite strings instead of 
whole-value placeholders.



##########
maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergJobUtils.java:
##########
@@ -18,21 +18,69 @@
  */
 package org.apache.gravitino.maintenance.jobs.iceberg;
 
-import com.fasterxml.jackson.core.type.TypeReference;
-import com.fasterxml.jackson.databind.ObjectMapper;
 import java.util.HashMap;
 import java.util.Map;
+import 
org.apache.gravitino.maintenance.optimizer.common.util.IcebergSparkConfigUtils;
 
 /**
  * Shared utility methods for Iceberg maintenance jobs.
  *
- * <p>Provides SQL escaping, argument parsing, and Spark configuration 
utilities used by both {@link
- * IcebergRewriteDataFilesJob} and {@link IcebergExpireSnapshotsJob}.
+ * <p>Provides SQL escaping, argument parsing, Spark configuration utilities, 
and classpath checks
+ * used by built-in Iceberg Spark jobs.
  */
 public final class IcebergJobUtils {
 
+  private static final String ICEBERG_SPARK_CATALOG = 
"org.apache.iceberg.spark.SparkCatalog";
+  private static final String OPTION_SPARK_CONF = "spark-conf";

Review Comment:
   **correctness**: This PR's stated goal is fixing wrong/hardcoded CLI flag 
names in JSON parse errors, and `IcebergUpdateStatsAndMetricsJob` does this 
correctly end-to-end via shared `OPTION_UPDATER_OPTIONS`/`OPTION_SPARK_CONF` 
constants used both for `argMap.get(...)` and the error message. But 
`OPTION_SPARK_CONF` here is private to this file, and 
`IcebergExpireSnapshotsJob.java:120` / 
`IcebergRewriteDataFilesJob.java:130-131` still call 
`argMap.get("spark-conf")`/`argMap.get("options")` with hardcoded string 
literals, disconnected from this constant and from 
`IcebergRewriteDataFilesJob`'s own `OPTION_OPTIONS` (which only feeds its error 
message).
   
   In those two classes, the string actually used to read the CLI flag and the 
constant used to name that flag in the error message remain two independent 
sources of truth. If either flag is renamed later, it's easy to update one and 
miss the other — reproducing the exact "error message names a flag that doesn't 
match the real CLI parsing" bug this PR claims to have fixed, just in these two 
classes instead of all three.



##########
maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergExpireSnapshotsJob.java:
##########
@@ -148,6 +148,13 @@ public static void main(String[] args) {
     }
 
     SparkSession spark = sparkBuilder.getOrCreate();
+    try {

Review Comment:
   **reuse**: The fail-fast block (`try { 
IcebergJobUtils.requireIcebergSparkRuntime(); } catch (IllegalStateException e) 
{ System.err.println(...); spark.stop(); System.exit(1); }`) is copy-pasted 
verbatim into all three job `main()` methods (also 
`IcebergRewriteDataFilesJob.java:162` and 
`IcebergUpdateStatsAndMetricsJob.java:116`) instead of being one shared helper.
   
   A future change to the fail-fast contract (log format, exit code, a 
metric/hook before exit, or wrapping `spark.stop()` in its own try/catch to 
avoid masking the original error) requires editing three files in lockstep; a 
maintainer who updates two and misses the third silently reintroduces 
inconsistent behavior. A one-line static helper (e.g. 
`IcebergJobUtils.requireIcebergSparkRuntimeOrExit(SparkSession spark)`) would 
collapse this to one call site per job.



-- 
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