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

roryqi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git

commit 02c746141ec6c4a19119472503dee2ce2d3a4787
Author: remilu <[email protected]>
AuthorDate: Mon Oct 26 07:55:09 2026 +0800

    [#14216] feat(tms): Build enqueue-time jobConf specs for cron
    
    Fixes #14216.
    
    Squashed from fork #75 (B5).
---
 .../maintenance/policy/MaintenanceAuthConfigs.java | 127 +++++++-
 .../policy/TestMaintenanceAuthConfigs.java         |  49 +++
 .../maintenance/policy/PolicyExpandRegistrar.java  |  17 +-
 .../scheduler/AutocommitDataSources.java           | 129 ++++++++
 .../maintenance/scheduler/MaintenanceJobSpec.java  | 349 ++++++++++++++++++++
 .../scheduler/MaintenanceJobSpecFactory.java       | 285 +++++++++++++++++
 .../job/cron/MaintenanceJobConfBuilder.java        | 356 +++++++++++++++++++++
 .../job/cron/MaintenanceJobTemplates.java          |  96 ++++++
 .../scheduler/job/cron/MaintenanceTableRefs.java   | 169 ++++++++++
 .../scheduler/policy/expand/PolicyExpandCrons.java |  32 +-
 .../scheduler/TestMaintenanceJobSpecFactory.java   | 180 +++++++++++
 .../job/cron/TestMaintenanceJobConfBuilder.java    | 260 +++++++++++++++
 .../job/cron/TestMaintenanceJobTemplates.java      |  58 ++++
 13 files changed, 2067 insertions(+), 40 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/maintenance/policy/MaintenanceAuthConfigs.java
 
b/core/src/main/java/org/apache/gravitino/maintenance/policy/MaintenanceAuthConfigs.java
index a90451bbd3..d963c74506 100644
--- 
a/core/src/main/java/org/apache/gravitino/maintenance/policy/MaintenanceAuthConfigs.java
+++ 
b/core/src/main/java/org/apache/gravitino/maintenance/policy/MaintenanceAuthConfigs.java
@@ -36,15 +36,19 @@ import org.apache.gravitino.config.ConfigEntry;
  * Cluster-static TMS client auth in {@code gravitino.conf}.
  *
  * <p>{@link #GRAVITINO_AUTH_PREFIX} is a <strong>fixed</strong> camelCase bag 
in {@code
- * gravitino.conf} ({@code authType}, {@code oauthServerUri}, …). {@link 
#loadGravitinoAuth} maps
- * those suffixes onto built-in updater short names ({@code auth_type}, {@code 
oauth_server_uri}, …)
- * before overlaying onto policy {@code updaterOptions}. Unknown or blank 
suffixes are rejected, and
- * {@code authType} plus mode-required fields are validated at load time.
+ * gravitino.conf} ({@code uri}, {@code authType}, {@code oauthServerUri}, …), 
symmetric with {@link
+ * #IRC_AUTH_PREFIX}. {@link #loadGravitinoAuth} maps those suffixes onto 
built-in updater short
+ * names ({@code gravitino_uri}, {@code auth_type}, {@code oauth_server_uri}, 
…) before overlaying
+ * onto policy {@code updaterOptions}. Unknown or blank suffixes are rejected, 
and {@code authType}
+ * plus mode-required fields are validated at load time ({@code uri} alone is 
allowed without {@code
+ * authType}).
  *
  * <p>{@link #IRC_AUTH_PREFIX} is an <strong>open</strong> passthrough for 
Spark / Iceberg REST
  * properties (for example {@code rest.auth.type}); any non-empty suffix under 
the prefix is loaded
- * and overlaid onto policy {@code jobOptions}. Auth wins on duplicate keys. 
Live {@code runJob}
- * wiring is a follow-up.
+ * and overlaid onto policy {@code jobOptions} with suffixes unchanged (for 
example {@code
+ * rest.auth.type}). Cron {@code runJob} later expands those short names into 
{@code spark_conf}
+ * under {@code spark.sql.catalog.<catalog>.*}; Spark resource keys such as 
{@code
+ * spark.executor.memory} stay as Spark settings, not catalog properties.
  */
 public final class MaintenanceAuthConfigs {
 
@@ -58,6 +62,12 @@ public final class MaintenanceAuthConfigs {
    */
   public static final String IRC_AUTH_PREFIX = 
"gravitino.maintenance.ircAuth.";
 
+  /**
+   * Base URI for Spark → Gravitino TMS callbacks ({@code 
updater_options.gravitino_uri}). Under
+   * {@link #GRAVITINO_AUTH_PREFIX} for symmetry with {@link #IRC_AUTH_PREFIX}.
+   */
+  public static final String URI_SUFFIX = "uri";
+
   /** Auth mode: {@code none}, {@code simple}, {@code basic}, or {@code 
oauth}. */
   public static final String AUTH_TYPE_SUFFIX = "authType";
 
@@ -93,6 +103,7 @@ public final class MaintenanceAuthConfigs {
    */
   private static final Map<String, String> GRAVITINO_AUTH_TO_UPDATER_OPTIONS =
       ImmutableMap.<String, String>builder()
+          .put(URI_SUFFIX, "gravitino_uri")
           .put(AUTH_TYPE_SUFFIX, "auth_type")
           .put(USERNAME_SUFFIX, "username")
           .put(PASSWORD_SUFFIX, "password")
@@ -102,6 +113,24 @@ public final class MaintenanceAuthConfigs {
           .put(OAUTH_SCOPE_SUFFIX, "oauth_scope")
           .build();
 
+  /**
+   * Updater-option short names that belong to the Gravitino auth bag (after 
camelCase → short-name
+   * remap).
+   */
+  public static final Set<String> GRAVITINO_AUTH_UPDATER_OPTION_KEYS =
+      ImmutableSet.copyOf(GRAVITINO_AUTH_TO_UPDATER_OPTIONS.values());
+
+  /**
+   * Cluster-static base URI for TMS sample/finish callbacks. Policy {@code 
updaterOptions} may
+   * override via {@code gravitino_uri}.
+   */
+  public static final ConfigEntry<Optional<String>> 
GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_URI =
+      optionalAuth(
+          GRAVITINO_AUTH_PREFIX,
+          URI_SUFFIX,
+          "Base URI for Spark → Gravitino TMS callbacks (sample/finish). 
Injected into cron "
+              + "updater_options when the policy does not set gravitino_uri.");
+
   public static final ConfigEntry<Optional<String>> 
GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_TYPE =
       optionalAuth(
           GRAVITINO_AUTH_PREFIX,
@@ -165,6 +194,30 @@ public final class MaintenanceAuthConfigs {
     return ImmutableMap.copyOf(updaterOptions);
   }
 
+  /**
+   * Like {@link #loadGravitinoAuth} but values are the full {@code 
gravitino.conf} keys (not secret
+   * values). For enqueue {@code task_data}; resolve with {@link 
Config#getRawString(String)} at
+   * submit.
+   *
+   * @param config gravitino server config
+   * @return updater short name → conf key; empty when unset
+   */
+  public static Map<String, String> loadGravitinoAuthAsConfigKeys(Config 
config) {
+    Map<String, String> loaded = loadAuthBag(config, GRAVITINO_AUTH_PREFIX, 
/*fixedSchema=*/ true);
+    if (loaded.isEmpty()) {
+      return Collections.emptyMap();
+    }
+    validateGravitinoAuth(loaded);
+    Map<String, String> keys = new LinkedHashMap<>();
+    GRAVITINO_AUTH_TO_UPDATER_OPTIONS.forEach(
+        (suffix, updateKey) -> {
+          if (loaded.containsKey(suffix)) {
+            keys.put(updateKey, GRAVITINO_AUTH_PREFIX + suffix);
+          }
+        });
+    return ImmutableMap.copyOf(keys);
+  }
+
   /**
    * Loads arbitrary IRC / Spark property suffixes under {@link 
#IRC_AUTH_PREFIX}.
    *
@@ -175,6 +228,42 @@ public final class MaintenanceAuthConfigs {
     return loadAuthBag(config, IRC_AUTH_PREFIX, /*fixedSchema=*/ false);
   }
 
+  /**
+   * Like {@link #loadIrcAuth} but values are full conf keys under {@link 
#IRC_AUTH_PREFIX}.
+   *
+   * @param config gravitino server config
+   * @return IRC short suffix → conf key; empty when unset
+   */
+  public static Map<String, String> loadIrcAuthAsConfigKeys(Config config) {
+    Map<String, String> loaded = loadAuthBag(config, IRC_AUTH_PREFIX, 
/*fixedSchema=*/ false);
+    if (loaded.isEmpty()) {
+      return Collections.emptyMap();
+    }
+    Map<String, String> keys = new LinkedHashMap<>();
+    loaded.forEach((suffix, ignored) -> keys.put(suffix, IRC_AUTH_PREFIX + 
suffix));
+    return ImmutableMap.copyOf(keys);
+  }
+
+  /**
+   * Resolves a value that may be a {@code gravitino.maintenance.*} conf key 
into the live config
+   * value. Non-key literals are returned unchanged.
+   *
+   * @param config server config
+   * @param value literal or conf key
+   * @return resolved value
+   */
+  public static String resolveConfigReference(Config config, @Nullable String 
value) {
+    if (value == null || !value.startsWith("gravitino.maintenance.")) {
+      return value;
+    }
+    String raw = config.getRawString(value);
+    if (raw == null || raw.trim().isEmpty()) {
+      throw new IllegalArgumentException(
+          "Missing gravitino.conf value for referenced key: " + value);
+    }
+    return raw.trim();
+  }
+
   /**
    * Merges policy {@code jobOptions} with IRC auth passthrough; auth wins on 
duplicate keys.
    *
@@ -188,7 +277,8 @@ public final class MaintenanceAuthConfigs {
   }
 
   /**
-   * Merges policy {@code updaterOptions} with Gravitino auth; auth wins on 
duplicate keys.
+   * Merges policy {@code updaterOptions} with Gravitino auth. Auth wins on 
credential keys; policy
+   * {@code gravitino_uri} wins over cluster {@code gravitinoAuth.uri} when 
both are set.
    *
    * @param policyUpdaterOptions options from the effective policy (may be 
null)
    * @param gravitinoAuth updater-short-name Gravitino auth bag (may be null)
@@ -197,7 +287,24 @@ public final class MaintenanceAuthConfigs {
   public static Map<String, String> overlayUpdaterOptions(
       @Nullable Map<String, String> policyUpdaterOptions,
       @Nullable Map<String, String> gravitinoAuth) {
-    return overlay(policyUpdaterOptions, gravitinoAuth);
+    if ((policyUpdaterOptions == null || policyUpdaterOptions.isEmpty())
+        && (gravitinoAuth == null || gravitinoAuth.isEmpty())) {
+      return Collections.emptyMap();
+    }
+    Map<String, String> merged = new LinkedHashMap<>();
+    if (policyUpdaterOptions != null) {
+      merged.putAll(policyUpdaterOptions);
+    }
+    if (gravitinoAuth != null) {
+      for (Map.Entry<String, String> entry : gravitinoAuth.entrySet()) {
+        String key = entry.getKey();
+        if ("gravitino_uri".equals(key) && 
merged.containsKey("gravitino_uri")) {
+          continue;
+        }
+        merged.put(key, entry.getValue());
+      }
+    }
+    return ImmutableMap.copyOf(merged);
   }
 
   /**
@@ -256,6 +363,10 @@ public final class MaintenanceAuthConfigs {
   private static void validateGravitinoAuth(Map<String, String> loaded) {
     String authType = loaded.get(AUTH_TYPE_SUFFIX);
     if (authType == null) {
+      // uri alone is connection target, not a credential mode — allow without 
authType.
+      if (loaded.keySet().stream().allMatch(URI_SUFFIX::equals)) {
+        return;
+      }
       throw new IllegalArgumentException(
           String.format(
               "Configuration %s%s is required when any Gravitino auth setting 
is present",
diff --git 
a/core/src/test/java/org/apache/gravitino/maintenance/policy/TestMaintenanceAuthConfigs.java
 
b/core/src/test/java/org/apache/gravitino/maintenance/policy/TestMaintenanceAuthConfigs.java
index fed3289e83..7e0e957a4d 100644
--- 
a/core/src/test/java/org/apache/gravitino/maintenance/policy/TestMaintenanceAuthConfigs.java
+++ 
b/core/src/test/java/org/apache/gravitino/maintenance/policy/TestMaintenanceAuthConfigs.java
@@ -41,6 +41,9 @@ class TestMaintenanceAuthConfigs {
     Assertions.assertEquals(
         "gravitino.maintenance.gravitinoAuth.oauthServerUri",
         
MaintenanceAuthConfigs.GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_OAUTH_SERVER_URI.getKey());
+    Assertions.assertEquals(
+        "gravitino.maintenance.gravitinoAuth.uri",
+        
MaintenanceAuthConfigs.GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_URI.getKey());
   }
 
   @Test
@@ -69,6 +72,8 @@ class TestMaintenanceAuthConfigs {
     Config config = new Config(false) {};
     config.loadFromMap(
         Map.of(
+            MaintenanceAuthConfigs.GRAVITINO_AUTH_PREFIX + "uri",
+            "http://gravitino.example:8090";,
             MaintenanceAuthConfigs.GRAVITINO_AUTH_PREFIX + "authType",
             "oauth",
             MaintenanceAuthConfigs.GRAVITINO_AUTH_PREFIX + "username",
@@ -87,6 +92,7 @@ class TestMaintenanceAuthConfigs {
 
     Assertions.assertEquals(
         ImmutableMap.<String, String>builder()
+            .put("gravitino_uri", "http://gravitino.example:8090";)
             .put("auth_type", "oauth")
             .put("username", "tms")
             .put("password", "secret")
@@ -98,6 +104,49 @@ class TestMaintenanceAuthConfigs {
         MaintenanceAuthConfigs.loadGravitinoAuth(config));
   }
 
+  @Test
+  void loadGravitinoAuthAllowsUriWithoutAuthType() {
+    Config config = new Config(false) {};
+    config.loadFromMap(
+        Map.of(
+            
MaintenanceAuthConfigs.GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_URI.getKey(),
+            "http://gravitino.example:8090";),
+        key -> key.startsWith("gravitino."));
+
+    Assertions.assertEquals(
+        ImmutableMap.of("gravitino_uri", "http://gravitino.example:8090";),
+        MaintenanceAuthConfigs.loadGravitinoAuth(config));
+  }
+
+  @Test
+  void loadAuthAsConfigKeysStoresKeysNotValues() {
+    Config config = new Config(false) {};
+    config.loadFromMap(
+        Map.of(
+            
MaintenanceAuthConfigs.GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_URI.getKey(),
+            "http://gravitino.example:8090";,
+            MaintenanceAuthConfigs.GRAVITINO_AUTH_PREFIX + "authType",
+            "simple",
+            MaintenanceAuthConfigs.IRC_AUTH_PREFIX + "rest.auth.type",
+            "oauth2"),
+        key -> key.startsWith("gravitino."));
+
+    Assertions.assertEquals(
+        ImmutableMap.of(
+            "gravitino_uri",
+            "gravitino.maintenance.gravitinoAuth.uri",
+            "auth_type",
+            "gravitino.maintenance.gravitinoAuth.authType"),
+        MaintenanceAuthConfigs.loadGravitinoAuthAsConfigKeys(config));
+    Assertions.assertEquals(
+        ImmutableMap.of("rest.auth.type", 
"gravitino.maintenance.ircAuth.rest.auth.type"),
+        MaintenanceAuthConfigs.loadIrcAuthAsConfigKeys(config));
+    Assertions.assertEquals(
+        "http://gravitino.example:8090";,
+        MaintenanceAuthConfigs.resolveConfigReference(
+            config, "gravitino.maintenance.gravitinoAuth.uri"));
+  }
+
   @Test
   void loadIrcAuthPassesThroughArbitrarySparkKeys() {
     Config config = new Config(false) {};
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/policy/PolicyExpandRegistrar.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/policy/PolicyExpandRegistrar.java
index 80b766fd37..2ee07368f3 100644
--- 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/policy/PolicyExpandRegistrar.java
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/policy/PolicyExpandRegistrar.java
@@ -41,6 +41,7 @@ import java.util.Optional;
 import java.util.Set;
 import javax.annotation.Nullable;
 import javax.sql.DataSource;
+import org.apache.gravitino.maintenance.scheduler.AutocommitDataSources;
 import 
org.apache.gravitino.maintenance.scheduler.TableMaintenanceSchedulerPools;
 import org.apache.gravitino.storage.relational.session.SqlSessions;
 import org.slf4j.Logger;
@@ -90,13 +91,18 @@ public final class PolicyExpandRegistrar {
   }
 
   /**
-   * Opens a client that reads committed rows from the entity-store pool.
+   * Opens a client against the entity-store pool with JDBC autocommit forced 
on.
+   *
+   * <p>Used for post-commit reads and test-only writes such as {@link 
#scheduleAt}. The
+   * entity-store pool often disables autocommit; without this wrap, 
SchedulerClient mutations would
+   * not commit and the expand pool would never observe them.
    *
    * @param dataSource relational JDBC pool shared with the entity store
-   * @return registrar that can read expand rows after the policy transaction 
commits
+   * @return registrar that can read (and test-write) expand rows after the 
policy transaction
+   *     commits
    */
   public static PolicyExpandRegistrar forReads(DataSource dataSource) {
-    return new PolicyExpandRegistrar(dataSource);
+    return new PolicyExpandRegistrar(AutocommitDataSources.wrap(dataSource));
   }
 
   private SchedulerClient client() {
@@ -130,7 +136,7 @@ public final class PolicyExpandRegistrar {
    * @param due execution time to store
    */
   @VisibleForTesting
-  void scheduleAt(long policyId, Instant due) {
+  public void scheduleAt(long policyId, Instant due) {
     runDespiteExecution(() -> scheduleOnce(policyId, due));
   }
 
@@ -147,7 +153,8 @@ public final class PolicyExpandRegistrar {
    * @param policyId persisted policy id
    * @return next execution time of the expand row, or empty when the row is 
absent
    */
-  Optional<Instant> scheduledExecutionTime(long policyId) {
+  @VisibleForTesting
+  public Optional<Instant> scheduledExecutionTime(long policyId) {
     return client()
         .getScheduledExecution(instanceId(policyId))
         .map(execution -> execution.getExecutionTime());
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/AutocommitDataSources.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/AutocommitDataSources.java
new file mode 100644
index 0000000000..c8d12e22bb
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/AutocommitDataSources.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler;
+
+import java.io.PrintWriter;
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Proxy;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import javax.sql.DataSource;
+
+/**
+ * Wraps the entity-store {@link DataSource} so db-scheduler {@code 
SchedulerClient} calls commit
+ * even when the pool disables JDBC autocommit.
+ */
+public final class AutocommitDataSources {
+
+  private AutocommitDataSources() {}
+
+  /**
+   * @param delegate entity-store pool
+   * @return proxy that forces {@code autoCommit=true} on borrowed connections
+   */
+  public static DataSource wrap(DataSource delegate) {
+    return new AutocommitDataSource(delegate);
+  }
+
+  private static final class AutocommitDataSource implements DataSource {
+
+    private final DataSource delegate;
+
+    AutocommitDataSource(DataSource delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    public Connection getConnection() throws SQLException {
+      return withAutocommit(delegate.getConnection());
+    }
+
+    @Override
+    public Connection getConnection(String username, String password) throws 
SQLException {
+      return withAutocommit(delegate.getConnection(username, password));
+    }
+
+    @Override
+    public PrintWriter getLogWriter() throws SQLException {
+      return delegate.getLogWriter();
+    }
+
+    @Override
+    public void setLogWriter(PrintWriter out) throws SQLException {
+      delegate.setLogWriter(out);
+    }
+
+    @Override
+    public void setLoginTimeout(int seconds) throws SQLException {
+      delegate.setLoginTimeout(seconds);
+    }
+
+    @Override
+    public int getLoginTimeout() throws SQLException {
+      return delegate.getLoginTimeout();
+    }
+
+    @Override
+    public java.util.logging.Logger getParentLogger() throws 
SQLFeatureNotSupportedException {
+      return delegate.getParentLogger();
+    }
+
+    @Override
+    public <T> T unwrap(Class<T> iface) throws SQLException {
+      if (iface.isInstance(this)) {
+        return iface.cast(this);
+      }
+      return delegate.unwrap(iface);
+    }
+
+    @Override
+    public boolean isWrapperFor(Class<?> iface) throws SQLException {
+      return iface.isInstance(this) || delegate.isWrapperFor(iface);
+    }
+
+    private static Connection withAutocommit(Connection connection) throws 
SQLException {
+      Connection proxy =
+          (Connection)
+              Proxy.newProxyInstance(
+                  Connection.class.getClassLoader(),
+                  new Class<?>[] {Connection.class},
+                  suppressingSetAutocommit(connection));
+      connection.setAutoCommit(true);
+      return proxy;
+    }
+
+    private static InvocationHandler suppressingSetAutocommit(Connection 
connection) {
+      return (proxy, method, args) -> {
+        if ("setAutoCommit".equals(method.getName())) {
+          return null;
+        }
+        if ("getAutoCommit".equals(method.getName())) {
+          return true;
+        }
+        try {
+          return method.invoke(connection, args);
+        } catch (InvocationTargetException e) {
+          throw e.getCause();
+        }
+      };
+    }
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/MaintenanceJobSpec.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/MaintenanceJobSpec.java
new file mode 100644
index 0000000000..516d70b3ea
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/MaintenanceJobSpec.java
@@ -0,0 +1,349 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+
+/**
+ * Enqueue-time spec stored as JSON in db-scheduler {@code task_data} for 
{@code cron-job} and
+ * {@code commit-job} rows.
+ *
+ * <p>Thin envelope only: {@link #jobConf} is the same {@code 
Map<String,String>} passed to {@code
+ * runJob} (including {@code updater_options} as a JSON string). Tables carry 
{@code tableId} plus
+ * NameIdentifier levels (metalake first); operations carry {@code policyId} 
plus the wire
+ * maintenance type. Metalake for {@code runJob} is derived from {@link 
#tables}.
+ */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public final class MaintenanceJobSpec {
+
+  private List<TableTarget> tables = new ArrayList<>();
+  private List<Operation> operations = new ArrayList<>();
+  private Map<String, String> jobConf = new LinkedHashMap<>();
+
+  /** Jackson / empty constructor. */
+  public MaintenanceJobSpec() {}
+
+  /**
+   * @param tables table targets (order preserved)
+   * @param operations maintenance operations (order preserved; cron: one; 
commit: chain)
+   * @param jobConf runJob configuration map
+   */
+  public MaintenanceJobSpec(
+      List<TableTarget> tables, List<Operation> operations, Map<String, 
String> jobConf) {
+    this.tables = new ArrayList<>(Preconditions.checkNotNull(tables, 
"tables"));
+    this.operations = new ArrayList<>(Preconditions.checkNotNull(operations, 
"operations"));
+    this.jobConf = new LinkedHashMap<>(Preconditions.checkNotNull(jobConf, 
"jobConf"));
+  }
+
+  /**
+   * @return table targets
+   */
+  public List<TableTarget> getTables() {
+    return tables;
+  }
+
+  /**
+   * @param tables table targets
+   */
+  public void setTables(List<TableTarget> tables) {
+    this.tables = tables == null ? new ArrayList<>() : new ArrayList<>(tables);
+  }
+
+  /**
+   * @return maintenance operations
+   */
+  public List<Operation> getOperations() {
+    return operations;
+  }
+
+  /**
+   * @param operations maintenance operations
+   */
+  public void setOperations(List<Operation> operations) {
+    this.operations = operations == null ? new ArrayList<>() : new 
ArrayList<>(operations);
+  }
+
+  /**
+   * @return runJob configuration
+   */
+  public Map<String, String> getJobConf() {
+    return jobConf;
+  }
+
+  /**
+   * @param jobConf runJob configuration
+   */
+  public void setJobConf(Map<String, String> jobConf) {
+    this.jobConf = jobConf == null ? new LinkedHashMap<>() : new 
LinkedHashMap<>(jobConf);
+  }
+
+  /**
+   * @return unmodifiable table entity ids in spec order
+   */
+  @JsonIgnore
+  public List<Long> tableIds() {
+    if (tables == null || tables.isEmpty()) {
+      return List.of();
+    }
+    return Collections.unmodifiableList(
+        
tables.stream().map(TableTarget::getTableId).collect(Collectors.toList()));
+  }
+
+  /**
+   * Metalake for {@code runJob} / cancel, taken from the first table's 
NameIdentifier.
+   *
+   * @return metalake name, or {@code null} when tables are missing / 
incomplete
+   */
+  @JsonIgnore
+  @Nullable
+  public String metalake() {
+    if (tables == null || tables.isEmpty()) {
+      return null;
+    }
+    List<String> identifier = tables.get(0).getIdentifier();
+    if (identifier == null || identifier.isEmpty()) {
+      return null;
+    }
+    String name = identifier.get(0);
+    return name == null || name.isBlank() ? null : name;
+  }
+
+  /**
+   * @return true when tables, operations, and jobConf look usable for submit
+   */
+  @JsonIgnore
+  public boolean isValid() {
+    return metalake() != null
+        && tables != null
+        && !tables.isEmpty()
+        && operations != null
+        && !operations.isEmpty()
+        && jobConf != null
+        && !jobConf.isEmpty()
+        && operations.get(0).getPolicyId() > 0
+        && tables.stream().allMatch(TableTarget::isValid)
+        && operations.stream().allMatch(Operation::isValid);
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof MaintenanceJobSpec)) {
+      return false;
+    }
+    MaintenanceJobSpec that = (MaintenanceJobSpec) o;
+    return Objects.equals(tables, that.tables)
+        && Objects.equals(operations, that.operations)
+        && Objects.equals(jobConf, that.jobConf);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(tables, operations, jobConf);
+  }
+
+  @Override
+  public String toString() {
+    return "MaintenanceJobSpec{tables="
+        + tableIds()
+        + ", operations="
+        + (operations == null ? 0 : operations.size())
+        + ", jobConfKeys="
+        + (jobConf == null ? 0 : jobConf.size())
+        + '}';
+  }
+
+  /** One table covered by this spec. */
+  @JsonIgnoreProperties(ignoreUnknown = true)
+  public static final class TableTarget {
+
+    private long tableId;
+    private List<String> identifier = new ArrayList<>();
+
+    /** Jackson constructor. */
+    public TableTarget() {}
+
+    /**
+     * @param tableId table entity id
+     * @param identifier NameIdentifier levels (metalake, catalog, …, table)
+     */
+    public TableTarget(long tableId, List<String> identifier) {
+      Preconditions.checkArgument(tableId > 0, "tableId must be positive");
+      this.tableId = tableId;
+      this.identifier = new ArrayList<>(Preconditions.checkNotNull(identifier, 
"identifier"));
+    }
+
+    /**
+     * @return table entity id
+     */
+    public long getTableId() {
+      return tableId;
+    }
+
+    /**
+     * @param tableId table entity id
+     */
+    public void setTableId(long tableId) {
+      this.tableId = tableId;
+    }
+
+    /**
+     * @return NameIdentifier levels
+     */
+    public List<String> getIdentifier() {
+      return identifier;
+    }
+
+    /**
+     * @param identifier NameIdentifier levels
+     */
+    public void setIdentifier(List<String> identifier) {
+      this.identifier = identifier == null ? new ArrayList<>() : new 
ArrayList<>(identifier);
+    }
+
+    /**
+     * @return Spark catalog name ({@code identifier[1]})
+     */
+    @JsonIgnore
+    @Nullable
+    public String catalogName() {
+      return identifier != null && identifier.size() >= 2 ? identifier.get(1) 
: null;
+    }
+
+    /**
+     * @return {@code schema.table} (or nested) Spark table identifier
+     */
+    @JsonIgnore
+    @Nullable
+    public String sparkTableIdentifier() {
+      if (identifier == null || identifier.size() < 3) {
+        return null;
+      }
+      return String.join(".", identifier.subList(2, identifier.size()));
+    }
+
+    @JsonIgnore
+    boolean isValid() {
+      return tableId > 0
+          && identifier != null
+          && identifier.size() >= 3
+          && identifier.stream().noneMatch(part -> part == null || 
part.isBlank());
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof TableTarget)) {
+        return false;
+      }
+      TableTarget that = (TableTarget) o;
+      return tableId == that.tableId && Objects.equals(identifier, 
that.identifier);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(tableId, identifier);
+    }
+  }
+
+  /** One maintenance operation (cron: single type; commit: chain member). */
+  @JsonIgnoreProperties(ignoreUnknown = true)
+  public static final class Operation {
+
+    private long policyId;
+    private String maintenanceType;
+
+    /** Jackson constructor. */
+    public Operation() {}
+
+    /**
+     * @param policyId policy entity id
+     * @param maintenanceType Spark wire name
+     */
+    public Operation(long policyId, String maintenanceType) {
+      Preconditions.checkArgument(policyId > 0, "policyId must be positive");
+      this.policyId = policyId;
+      this.maintenanceType = Preconditions.checkNotNull(maintenanceType, 
"maintenanceType");
+    }
+
+    /**
+     * @return policy entity id
+     */
+    public long getPolicyId() {
+      return policyId;
+    }
+
+    /**
+     * @param policyId policy entity id
+     */
+    public void setPolicyId(long policyId) {
+      this.policyId = policyId;
+    }
+
+    /**
+     * @return Spark wire maintenance type
+     */
+    public String getMaintenanceType() {
+      return maintenanceType;
+    }
+
+    /**
+     * @param maintenanceType Spark wire maintenance type
+     */
+    public void setMaintenanceType(String maintenanceType) {
+      this.maintenanceType = maintenanceType;
+    }
+
+    @JsonIgnore
+    boolean isValid() {
+      return policyId > 0 && maintenanceType != null && 
!maintenanceType.isBlank();
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof Operation)) {
+        return false;
+      }
+      Operation that = (Operation) o;
+      return policyId == that.policyId && Objects.equals(maintenanceType, 
that.maintenanceType);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(policyId, maintenanceType);
+    }
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/MaintenanceJobSpecFactory.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/MaintenanceJobSpecFactory.java
new file mode 100644
index 0000000000..37c55059e0
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/MaintenanceJobSpecFactory.java
@@ -0,0 +1,285 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.Operation;
+import 
org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.TableTarget;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobConfBuilder;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobTemplates;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceTableRefs.ResolvedTableRef;
+import org.apache.gravitino.meta.PolicyEntity;
+
+/**
+ * Builds enqueue-time {@link MaintenanceJobSpec} values for the cron path and 
shared jobConf
+ * helpers used by cron pick / commit merge.
+ */
+public final class MaintenanceJobSpecFactory {
+
+  private static final Pattern RESOURCE_NUMBER = Pattern.compile("^(\\d+)");
+  private static final Pattern MEMORY_VALUE =
+      Pattern.compile("^(\\d+(?:\\.\\d+)?)\\s*([kmgt]?i?b?)$", 
Pattern.CASE_INSENSITIVE);
+
+  private final MaintenanceJobConfBuilder jobConfBuilder;
+
+  /**
+   * @param gravitinoConfig server config for auth overlay and gravitino URI
+   */
+  public MaintenanceJobSpecFactory(Config gravitinoConfig) {
+    this.jobConfBuilder =
+        new 
MaintenanceJobConfBuilder(Preconditions.checkNotNull(gravitinoConfig));
+  }
+
+  /**
+   * Cron spec for one or more tables under a single policy. Shared {@code 
jobConf} omits per-table
+   * keys; pick overlays {@code catalog_name} / {@code table_identifier} for 
each {@link
+   * TableTarget}.
+   *
+   * <p>All tables must share the same Spark catalog name so catalog-scoped 
{@code spark_conf} keys
+   * stay correct for every member of a batch.
+   *
+   * @param policy effective maintenance policy
+   * @param tables resolved table refs (same order as enqueue)
+   * @return spec ready for {@code task_data}
+   */
+  public MaintenanceJobSpec forCron(PolicyEntity policy, 
List<ResolvedTableRef> tables) {
+    Preconditions.checkNotNull(policy, "policy");
+    Preconditions.checkArgument(tables != null && !tables.isEmpty(), "tables 
must not be empty");
+    Optional<String> wire = 
MaintenanceJobTemplates.maintenanceType(policy.policyType());
+    Preconditions.checkArgument(
+        wire.isPresent(), "unsupported policy type %s", policy.policyType());
+
+    String catalogName = tables.get(0).catalogName();
+    for (ResolvedTableRef ref : tables) {
+      Preconditions.checkArgument(
+          catalogName.equals(ref.catalogName()),
+          "cron batch tables must share catalogName; got %s and %s",
+          catalogName,
+          ref.catalogName());
+    }
+
+    String metalake = policy.namespace().level(0);
+    Map<String, String> jobConf = jobConfBuilder.build(policy, catalogName);
+    // Per-table keys are applied at pick so one batch spec can cover many 
tables.
+    jobConf.remove("catalog_name");
+    jobConf.remove("table_identifier");
+
+    List<TableTarget> targets = new ArrayList<>(tables.size());
+    for (ResolvedTableRef ref : tables) {
+      targets.add(toTarget(metalake, ref));
+    }
+    List<Operation> operations = List.of(new Operation(policy.id(), 
wire.get()));
+    return new MaintenanceJobSpec(targets, operations, jobConf);
+  }
+
+  /**
+   * Copies {@code shared} and overlays catalog plus a one-element {@code 
tables} JSON for submit.
+   *
+   * <p>Entity {@code table_id} stays inside {@code tables} (system-filled). 
It is not a top-level
+   * template placeholder — users submitting the built-in template do not know 
entity ids.
+   *
+   * @param shared jobConf
+   * @param target table to submit
+   * @return mutable per-table jobConf
+   */
+  public static Map<String, String> jobConfForTable(
+      Map<String, String> shared, TableTarget target) {
+    Preconditions.checkNotNull(shared, "shared");
+    Preconditions.checkNotNull(target, "target");
+    String catalogName = target.catalogName();
+    String tableIdentifier = target.sparkTableIdentifier();
+    Preconditions.checkArgument(
+        catalogName != null && tableIdentifier != null,
+        "table target %s is missing catalog/table identifier levels",
+        target.getTableId());
+    Map<String, String> jobConf = new LinkedHashMap<>(shared);
+    jobConf.put("catalog_name", catalogName);
+    jobConf.put("tables", toTablesJson(List.of(target)));
+    jobConf.remove("table_identifier");
+    jobConf.remove("table_id");
+    return jobConf;
+  }
+
+  private static String toTablesJson(List<TableTarget> tables) {
+    List<Map<String, Object>> payload = new ArrayList<>(tables.size());
+    for (TableTarget target : tables) {
+      String catalogName = target.catalogName();
+      String tableIdentifier = target.sparkTableIdentifier();
+      Preconditions.checkArgument(
+          catalogName != null && tableIdentifier != null,
+          "table target %s is missing catalog/table identifier levels",
+          target.getTableId());
+      Map<String, Object> entry = new LinkedHashMap<>();
+      entry.put("table_id", target.getTableId());
+      entry.put("catalog_name", catalogName);
+      entry.put("table_identifier", tableIdentifier);
+      payload.add(entry);
+    }
+    try {
+      return JsonUtils.anyFieldMapper().writeValueAsString(payload);
+    } catch (JsonProcessingException e) {
+      throw new IllegalStateException("Failed to serialize tables for runJob", 
e);
+    }
+  }
+
+  /**
+   * For resource-shaped keys ({@code *memory*}, {@code *cores*}, {@code 
*instances*}), keep the
+   * larger value. Memory keys compare normalized byte sizes ({@code 1g} &gt; 
{@code 512m}); cores /
+   * instances compare the leading integer. Other keys from the first policy 
are left unchanged.
+   *
+   * @param into baseline jobConf (mutated)
+   * @param from candidate jobConf
+   */
+  public static void mergeResourceKeys(Map<String, String> into, Map<String, 
String> from) {
+    for (Map.Entry<String, String> entry : from.entrySet()) {
+      String key = entry.getKey();
+      String value = entry.getValue();
+      if (key == null || value == null || !isResourceKey(key)) {
+        continue;
+      }
+      String existing = into.get(key);
+      if (existing == null) {
+        into.put(key, value);
+        continue;
+      }
+      Optional<Long> existingNum = resourceMagnitude(key, existing);
+      Optional<Long> candidateNum = resourceMagnitude(key, value);
+      if (existingNum.isPresent()
+          && candidateNum.isPresent()
+          && candidateNum.get() > existingNum.get()) {
+        into.put(key, value);
+      }
+    }
+  }
+
+  /**
+   * @param metalake metalake name
+   * @param ref resolved Spark table ref
+   * @return table target with NameIdentifier levels
+   */
+  public static TableTarget toTarget(String metalake, ResolvedTableRef ref) {
+    List<String> levels = new ArrayList<>();
+    levels.add(metalake);
+    levels.add(ref.catalogName());
+    levels.addAll(Arrays.asList(ref.tableIdentifier().split("\\.")));
+    return new TableTarget(ref.tableId(), levels);
+  }
+
+  private static boolean isResourceKey(String key) {
+    String lower = key.toLowerCase(Locale.ROOT);
+    return lower.contains("memory") || lower.contains("cores") || 
lower.contains("instances");
+  }
+
+  private static Optional<Long> resourceMagnitude(String key, String value) {
+    if (key.toLowerCase(Locale.ROOT).contains("memory")) {
+      return parseMemoryBytes(value);
+    }
+    return leadingLong(value);
+  }
+
+  /**
+   * Parses Spark-style memory strings ({@code 512m}, {@code 1g}, {@code 
2048}) into bytes for max
+   * merge.
+   */
+  @VisibleForTesting
+  static Optional<Long> parseMemoryBytes(String value) {
+    if (value == null) {
+      return Optional.empty();
+    }
+    Matcher matcher = MEMORY_VALUE.matcher(value.trim());
+    if (!matcher.matches()) {
+      return leadingLong(value);
+    }
+    try {
+      double amount = Double.parseDouble(matcher.group(1));
+      String unit = matcher.group(2) == null ? "" : 
matcher.group(2).toLowerCase(Locale.ROOT);
+      long multiplier;
+      switch (unit) {
+        case "":
+        case "b":
+          multiplier = 1L;
+          break;
+        case "k":
+        case "kb":
+        case "ki":
+        case "kib":
+          multiplier = 1024L;
+          break;
+        case "m":
+        case "mb":
+        case "mi":
+        case "mib":
+          multiplier = 1024L * 1024L;
+          break;
+        case "g":
+        case "gb":
+        case "gi":
+        case "gib":
+          multiplier = 1024L * 1024L * 1024L;
+          break;
+        case "t":
+        case "tb":
+        case "ti":
+        case "tib":
+          multiplier = 1024L * 1024L * 1024L * 1024L;
+          break;
+        default:
+          return Optional.empty();
+      }
+      return Optional.of(Math.round(amount * multiplier));
+    } catch (NumberFormatException e) {
+      return Optional.empty();
+    }
+  }
+
+  private static Optional<Long> leadingLong(String value) {
+    Matcher matcher = RESOURCE_NUMBER.matcher(value.trim());
+    if (!matcher.find()) {
+      return Optional.empty();
+    }
+    try {
+      return Optional.of(Long.parseLong(matcher.group(1)));
+    } catch (NumberFormatException e) {
+      return Optional.empty();
+    }
+  }
+
+  /** Visible for tests that assert resource merge. */
+  @VisibleForTesting
+  public static Map<String, String> newLinkedMap(String... kv) {
+    Map<String, String> map = new LinkedHashMap<>();
+    for (int i = 0; i + 1 < kv.length; i += 2) {
+      map.put(kv[i], kv[i + 1]);
+    }
+    return map;
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobConfBuilder.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobConfBuilder.java
new file mode 100644
index 0000000000..1fc53dd5b9
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobConfBuilder.java
@@ -0,0 +1,356 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler.job.cron;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import java.time.Clock;
+import java.time.DateTimeException;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import javax.annotation.Nullable;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.json.JsonUtils;
+import 
org.apache.gravitino.maintenance.optimizer.common.util.IcebergSparkConfigUtils;
+import org.apache.gravitino.maintenance.policy.MaintenanceAuthConfigs;
+import org.apache.gravitino.meta.PolicyEntity;
+import org.apache.gravitino.policy.ExecutablePolicyContent;
+import org.apache.gravitino.policy.IcebergDataCompactionContent;
+import org.apache.gravitino.policy.IcebergOrphanFileRemovalContent;
+import org.apache.gravitino.policy.PolicyContent;
+import org.apache.gravitino.policy.PolicyExecutionSpec;
+
+/**
+ * Builds the {@code runJob} configuration map for TMS Spark submits.
+ *
+ * <p>Shape matches Iceberg update-stats submit: flatten template placeholders 
into the top-level
+ * map, pack {@code updater_options} as one JSON string, and pack extra Spark 
/ IRC settings into
+ * {@code spark_conf} as another JSON string. Policy {@code jobOptions} 
(including overlay from
+ * {@code gravitino.maintenance.ircAuth.*}) keep short IRC keys such as {@code 
rest.auth.type};
+ * those are expanded to {@code spark.sql.catalog.<catalog>.*} only when 
writing {@code spark_conf}.
+ * Dot-form Spark resource keys ({@code spark.executor.memory}) become the 
matching underscore
+ * placeholders ({@code spark_executor_memory}) instead of catalog properties.
+ *
+ * <p>{@link #build} is for enqueue {@code task_data}: cluster {@code 
gravitinoAuth} / {@code
+ * ircAuth} are stored as conf <em>keys</em> (not secret values). Call {@link 
#resolveForSubmit}
+ * immediately before {@code runJob} to expand those keys from the live {@code 
gravitino.conf}.
+ */
+public final class MaintenanceJobConfBuilder {
+
+  private static final DateTimeFormatter ORPHAN_CUTOFF =
+      DateTimeFormatter.ofPattern("uuuu-MM-dd 
HH:mm:ssXXX").withZone(ZoneOffset.UTC);
+
+  private static final String SPARK_SQL_CATALOG_PREFIX = "spark.sql.catalog.";
+
+  /** Top-level {@code runJob} placeholders consumed by Iceberg / TMS Spark 
templates. */
+  private static final Set<String> FLAT_PLACEHOLDER_KEYS =
+      ImmutableSet.of(
+          "catalog_name",
+          "table_identifier",
+          "catalog_type",
+          "catalog_uri",
+          "warehouse_location",
+          "spark_master",
+          "spark_executor_instances",
+          "spark_executor_cores",
+          "spark_executor_memory",
+          "spark_driver_memory",
+          "strategy",
+          "sort_order",
+          "where_clause",
+          "options",
+          "use_caching",
+          "spec_id",
+          "older_than",
+          "retain_last",
+          "stream_results",
+          "location",
+          "dry_run",
+          MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY,
+          MaintenanceJobTemplates.RULES_KEY,
+          "gravitino_job_id");
+
+  /**
+   * Spark resource / session keys that map onto flat template placeholders 
(same as update-stats).
+   */
+  private static final Map<String, String> SPARK_KEY_TO_PLACEHOLDER =
+      ImmutableMap.of(
+          "spark.master",
+          "spark_master",
+          "spark.executor.instances",
+          "spark_executor_instances",
+          "spark.executor.cores",
+          "spark_executor_cores",
+          "spark.executor.memory",
+          "spark_executor_memory",
+          "spark.driver.memory",
+          "spark_driver_memory");
+
+  private final Config gravitinoConfig;
+  private final Clock clock;
+
+  /**
+   * @param gravitinoConfig server config for auth overlay and gravitino URI
+   */
+  public MaintenanceJobConfBuilder(Config gravitinoConfig) {
+    this(gravitinoConfig, Clock.systemUTC());
+  }
+
+  MaintenanceJobConfBuilder(Config gravitinoConfig, Clock clock) {
+    this.gravitinoConfig = gravitinoConfig;
+    this.clock = clock;
+  }
+
+  /**
+   * Enqueue-time jobConf for db-scheduler {@code task_data}. Cluster auth 
overlays store conf keys
+   * (for example {@code gravitino.maintenance.gravitinoAuth.uri}), not secret 
values.
+   *
+   * @param policy effective maintenance policy
+   * @param catalogName Spark catalog alias for IRC property scoping
+   * @return mutable jobConf (per-table keys applied later by {@code 
jobConfForTable})
+   */
+  public Map<String, String> build(PolicyEntity policy, String catalogName) {
+    PolicyContent content = policy.content();
+    PolicyExecutionSpec spec = executionSpec(content);
+    // ircAuth suffixes stay short-named; values are conf keys until 
resolveForSubmit.
+    Map<String, String> jobOptions =
+        MaintenanceAuthConfigs.overlayJobOptions(
+            spec.jobOptions(), 
MaintenanceAuthConfigs.loadIrcAuthAsConfigKeys(gravitinoConfig));
+    Map<String, String> updaterOptions =
+        ensureUpdaterCallbackOptions(
+            policy,
+            MaintenanceAuthConfigs.overlayUpdaterOptions(
+                spec.updaterOptions(),
+                
MaintenanceAuthConfigs.loadGravitinoAuthAsConfigKeys(gravitinoConfig)));
+
+    Map<String, String> jobConf = new LinkedHashMap<>();
+    Map<String, String> sparkConf = new LinkedHashMap<>();
+
+    String existingSparkConfJson = jobOptions.get("spark_conf");
+    if (existingSparkConfJson != null && 
!existingSparkConfJson.trim().isEmpty()) {
+      sparkConf.putAll(
+          IcebergSparkConfigUtils.parseFlatJsonMap(existingSparkConfJson, 
"spark_conf"));
+    }
+
+    for (Map.Entry<String, String> entry : jobOptions.entrySet()) {
+      String key = entry.getKey();
+      String value = entry.getValue();
+      if (key == null
+          || value == null
+          || "spark_conf".equals(key)
+          || "updater_options".equals(key)) {
+        continue;
+      }
+      routeJobOption(jobConf, sparkConf, catalogName, key, value);
+    }
+
+    fillPlaceholdersFromSparkConf(jobConf, sparkConf, catalogName);
+    putTypedProcedureParams(jobConf, content);
+
+    Optional<String> maintenanceType = 
MaintenanceJobTemplates.maintenanceType(policy.policyType());
+    maintenanceType.ifPresent(
+        type -> {
+          jobConf.put(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY, type);
+          jobConf.put(MaintenanceJobTemplates.POLICY_IDS_KEY, 
Long.toString(policy.id()));
+        });
+    if (content != null && maintenanceType.isPresent()) {
+      Map<String, Map<String, Object>> rulesByType = new LinkedHashMap<>();
+      rulesByType.put(maintenanceType.get(), new 
LinkedHashMap<>(content.rules()));
+      jobConf.put(
+          MaintenanceJobTemplates.RULES_KEY,
+          writeJson(rulesByType, MaintenanceJobTemplates.RULES_KEY));
+    }
+
+    jobConf.put(
+        MaintenanceJobTemplates.UPDATER_OPTIONS_JOB_CONF_KEY,
+        writeJson(updaterOptions, "updater_options"));
+    if (!sparkConf.isEmpty()) {
+      jobConf.put("spark_conf", writeJson(new TreeMap<>(sparkConf), 
"spark_conf"));
+    }
+    return jobConf;
+  }
+
+  /**
+   * Expands {@code gravitino.maintenance.*} conf-key references in an enqueue 
snapshot into live
+   * values from {@code gravitino.conf}. Call immediately before {@code 
runJob}.
+   *
+   * @param enqueueJobConf jobConf from {@link #build} / {@code task_data}
+   * @return new map with secrets and URIs resolved
+   */
+  public Map<String, String> resolveForSubmit(Map<String, String> 
enqueueJobConf) {
+    Map<String, String> resolved = new LinkedHashMap<>();
+    for (Map.Entry<String, String> entry : enqueueJobConf.entrySet()) {
+      String key = entry.getKey();
+      String value = entry.getValue();
+      if (key == null || value == null) {
+        continue;
+      }
+      if (MaintenanceJobTemplates.UPDATER_OPTIONS_JOB_CONF_KEY.equals(key)
+          || "spark_conf".equals(key)) {
+        resolved.put(key, resolveJsonMapValues(value, key));
+      } else {
+        resolved.put(key, 
MaintenanceAuthConfigs.resolveConfigReference(gravitinoConfig, value));
+      }
+    }
+    return resolved;
+  }
+
+  private String resolveJsonMapValues(String json, String field) {
+    Map<String, String> parsed = 
IcebergSparkConfigUtils.parseFlatJsonMap(json, field);
+    Map<String, String> out = new TreeMap<>();
+    for (Map.Entry<String, String> entry : parsed.entrySet()) {
+      out.put(
+          entry.getKey(),
+          MaintenanceAuthConfigs.resolveConfigReference(gravitinoConfig, 
entry.getValue()));
+    }
+    return writeJson(out, field);
+  }
+
+  private static void routeJobOption(
+      Map<String, String> jobConf,
+      Map<String, String> sparkConf,
+      String catalogName,
+      String key,
+      String value) {
+    if (FLAT_PLACEHOLDER_KEYS.contains(key)) {
+      jobConf.put(key, value);
+      return;
+    }
+    String placeholder = SPARK_KEY_TO_PLACEHOLDER.get(key);
+    if (placeholder != null) {
+      jobConf.put(placeholder, value);
+      sparkConf.put(key, value);
+      return;
+    }
+    if (key.startsWith("spark.")) {
+      // Spark session / SQL settings stay as full keys in spark_conf (never 
catalog-prefixed).
+      sparkConf.put(key, value);
+      return;
+    }
+    if (key.startsWith(SPARK_SQL_CATALOG_PREFIX)) {
+      sparkConf.put(key, value);
+      return;
+    }
+    // IRC / Iceberg catalog props from jobOptions / ircAuth (short names).
+    sparkConf.put(SPARK_SQL_CATALOG_PREFIX + catalogName + "." + key, value);
+  }
+
+  private static void fillPlaceholdersFromSparkConf(
+      Map<String, String> jobConf, Map<String, String> sparkConf, String 
catalogName) {
+    for (Map.Entry<String, String> entry : 
SPARK_KEY_TO_PLACEHOLDER.entrySet()) {
+      String sparkKey = entry.getKey();
+      String placeholder = entry.getValue();
+      if (!jobConf.containsKey(placeholder) && 
sparkConf.containsKey(sparkKey)) {
+        jobConf.put(placeholder, sparkConf.get(sparkKey));
+      }
+    }
+    String catalogPrefix = SPARK_SQL_CATALOG_PREFIX + catalogName;
+    putIfAbsentFromSpark(jobConf, sparkConf, "catalog_type", catalogPrefix + 
".type");
+    putIfAbsentFromSpark(jobConf, sparkConf, "catalog_uri", catalogPrefix + 
".uri");
+    putIfAbsentFromSpark(jobConf, sparkConf, "warehouse_location", 
catalogPrefix + ".warehouse");
+  }
+
+  private static void putIfAbsentFromSpark(
+      Map<String, String> jobConf,
+      Map<String, String> sparkConf,
+      String placeholder,
+      String sparkKey) {
+    if (!jobConf.containsKey(placeholder) && sparkConf.containsKey(sparkKey)) {
+      jobConf.put(placeholder, sparkConf.get(sparkKey));
+    }
+  }
+
+  /**
+   * Ensures Spark → Gravitino sample/finish callbacks have {@code metalake} 
(from the policy
+   * namespace) and {@code gravitino_uri} (from {@code 
gravitino.maintenance.gravitinoAuth.uri} via
+   * auth overlay, overridable by policy updaterOptions).
+   */
+  private Map<String, String> ensureUpdaterCallbackOptions(
+      PolicyEntity policy, Map<String, String> overlaid) {
+    Map<String, String> updaterOptions = new LinkedHashMap<>();
+    if (overlaid != null) {
+      updaterOptions.putAll(overlaid);
+    }
+    String metalake = policy.namespace().level(0);
+    updaterOptions.put("metalake", metalake);
+
+    String gravitinoUri = updaterOptions.get("gravitino_uri");
+    if (gravitinoUri == null || gravitinoUri.trim().isEmpty()) {
+      throw new IllegalArgumentException(
+          "updater_options.gravitino_uri is required for TMS Spark submit; set 
policy "
+              + "updaterOptions.gravitino_uri or 
gravitino.maintenance.gravitinoAuth.uri");
+    }
+    return updaterOptions;
+  }
+
+  private void putTypedProcedureParams(
+      Map<String, String> jobConf, @Nullable PolicyContent content) {
+    if (content instanceof IcebergDataCompactionContent) {
+      IcebergDataCompactionContent compaction = (IcebergDataCompactionContent) 
content;
+      jobConf.put("strategy", compaction.rewriteStrategy());
+      if (compaction.sortOrder() != null && !compaction.sortOrder().isEmpty()) 
{
+        jobConf.put("sort_order", compaction.sortOrder());
+      }
+      if (!compaction.rewriteOptions().isEmpty()) {
+        jobConf.put("options", writeJson(new 
TreeMap<>(compaction.rewriteOptions()), "options"));
+      }
+      return;
+    }
+    if (content instanceof IcebergOrphanFileRemovalContent) {
+      IcebergOrphanFileRemovalContent orphan = 
(IcebergOrphanFileRemovalContent) content;
+      
IcebergOrphanFileRemovalContent.validateOlderThanDays(orphan.olderThanDays());
+      jobConf.put("older_than", orphanCutoff(orphan.olderThanDays()));
+      jobConf.put("dry_run", Boolean.toString(orphan.dryRun()));
+      if (orphan.location() != null) {
+        jobConf.put("location", orphan.location());
+      }
+    }
+  }
+
+  private String orphanCutoff(long days) {
+    try {
+      return ORPHAN_CUTOFF.format(clock.instant().minus(days, 
ChronoUnit.DAYS));
+    } catch (DateTimeException | ArithmeticException e) {
+      throw new IllegalArgumentException(
+          "olderThanDays cannot be represented as a cleanup timestamp", e);
+    }
+  }
+
+  private static PolicyExecutionSpec executionSpec(@Nullable PolicyContent 
content) {
+    if (content instanceof ExecutablePolicyContent) {
+      return ((ExecutablePolicyContent) content).executionSpec();
+    }
+    return PolicyExecutionSpec.empty();
+  }
+
+  private static String writeJson(Map<String, ?> map, String field) {
+    try {
+      return JsonUtils.anyFieldMapper().writeValueAsString(map);
+    } catch (JsonProcessingException e) {
+      throw new IllegalStateException("Failed to serialize " + field + " for 
runJob", e);
+    }
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobTemplates.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobTemplates.java
new file mode 100644
index 0000000000..07d116cef6
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceJobTemplates.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler.job.cron;
+
+import java.util.Optional;
+import org.apache.gravitino.job.JobTemplateProvider;
+import org.apache.gravitino.policy.Policy;
+
+/**
+ * Maps maintenance {@link Policy.BuiltInType} to the TMS cron Spark job 
template and procedure
+ * kind.
+ *
+ * <p>Cron always submits {@link #TMS_ICEBERG_MAINTENANCE_TEMPLATE}; 
standalone {@code
+ * builtin-iceberg-*} templates remain for CLI / optimizer.
+ */
+public final class MaintenanceJobTemplates {
+
+  /**
+   * Built-in template name for the TMS Iceberg maintenance Spark job. Must 
match {@code
+   * IcebergMaintenanceJob.NAME} ({@code builtin-iceberg-maintenance-task}).
+   */
+  public static final String TMS_ICEBERG_MAINTENANCE_TEMPLATE =
+      JobTemplateProvider.BUILTIN_NAME_PREFIX + "iceberg-maintenance-task";
+
+  /**
+   * JobConf key for procedure dispatch inside the TMS template. One wire 
name, or a comma-separated
+   * commit chain. Required by {@link #TMS_ICEBERG_MAINTENANCE_TEMPLATE}.
+   */
+  public static final String MAINTENANCE_TYPES_KEY = "maintenance_types";
+
+  /**
+   * JobConf key for policy entity ids parallel to {@link 
#MAINTENANCE_TYPES_KEY} (comma-separated).
+   * Required so Spark callbacks can attribute soft slots by {@code policyId}.
+   */
+  public static final String POLICY_IDS_KEY = "policy_ids";
+
+  /** JobConf key for overlaid updater options (JSON object string). */
+  public static final String UPDATER_OPTIONS_JOB_CONF_KEY = "updater_options";
+
+  /**
+   * JobConf key for enqueue-time policy {@code rules()}, JSON map of wire 
maintenance type → rules
+   * object. Consumed in-Spark for trigger-expr recommend (no Gravitino 
round-trip).
+   */
+  public static final String RULES_KEY = "rules";
+
+  private MaintenanceJobTemplates() {}
+
+  /**
+   * @param builtInType maintenance policy type
+   * @return TMS cron job template name when known
+   */
+  public static Optional<String> templateName(Policy.BuiltInType builtInType) {
+    if (maintenanceType(builtInType).isEmpty()) {
+      return Optional.empty();
+    }
+    return Optional.of(TMS_ICEBERG_MAINTENANCE_TEMPLATE);
+  }
+
+  /**
+   * @param builtInType maintenance policy type
+   * @return wire value for {@link #MAINTENANCE_TYPES_KEY}
+   */
+  public static Optional<String> maintenanceType(Policy.BuiltInType 
builtInType) {
+    if (builtInType == null) {
+      return Optional.empty();
+    }
+    switch (builtInType) {
+      case ICEBERG_COMPACTION:
+        return Optional.of("rewrite_data_files");
+      case ICEBERG_REWRITE_MANIFESTS:
+        return Optional.of("rewrite_manifests");
+      case ICEBERG_SNAPSHOT_EXPIRATION:
+        return Optional.of("expire_snapshots");
+      case ICEBERG_ORPHAN_FILE_REMOVAL:
+        return Optional.of("remove_orphan_files");
+      default:
+        return Optional.empty();
+    }
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceTableRefs.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceTableRefs.java
new file mode 100644
index 0000000000..6b855c50b1
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/cron/MaintenanceTableRefs.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler.job.cron;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import javax.annotation.Nullable;
+import org.apache.gravitino.storage.relational.mapper.CatalogMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.TableMetaMapper;
+import org.apache.gravitino.storage.relational.po.CatalogPO;
+import org.apache.gravitino.storage.relational.po.SchemaPO;
+import org.apache.gravitino.storage.relational.po.TablePO;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+
+/** Resolves table entity ids to catalog + {@code schema.table} identifiers 
for Spark jobConf. */
+public final class MaintenanceTableRefs {
+
+  private MaintenanceTableRefs() {}
+
+  /**
+   * @param tableIds table entity ids (order preserved for known ids)
+   * @return resolved refs; unknown / incomplete rows are omitted
+   */
+  public static List<ResolvedTableRef> resolve(List<Long> tableIds) {
+    if (tableIds == null || tableIds.isEmpty()) {
+      return List.of();
+    }
+    List<TablePO> tables =
+        SessionUtils.getWithoutCommit(
+            TableMetaMapper.class, mapper -> 
mapper.listTablePOsByTableIds(tableIds));
+    if (tables == null || tables.isEmpty()) {
+      return List.of();
+    }
+    Map<Long, TablePO> byId = new LinkedHashMap<>();
+    for (TablePO table : tables) {
+      if (table != null && table.getTableId() != null) {
+        byId.put(table.getTableId(), table);
+      }
+    }
+
+    Map<Long, String> catalogNames = new LinkedHashMap<>();
+    Map<Long, String> schemaNames = new LinkedHashMap<>();
+    List<ResolvedTableRef> resolved = new ArrayList<>();
+    for (Long tableId : tableIds) {
+      TablePO table = byId.get(tableId);
+      if (table == null || table.getTableName() == null) {
+        continue;
+      }
+      String catalogName = catalogName(table.getCatalogId(), catalogNames);
+      String schemaName = schemaName(table.getSchemaId(), schemaNames);
+      if (catalogName == null || schemaName == null) {
+        continue;
+      }
+      resolved.add(
+          new ResolvedTableRef(tableId, catalogName, schemaName + "." + 
table.getTableName()));
+    }
+    return resolved;
+  }
+
+  @Nullable
+  private static String catalogName(Long catalogId, Map<Long, String> cache) {
+    if (catalogId == null) {
+      return null;
+    }
+    if (cache.containsKey(catalogId)) {
+      return cache.get(catalogId);
+    }
+    CatalogPO catalog =
+        SessionUtils.getWithoutCommit(
+            CatalogMetaMapper.class, mapper -> 
mapper.selectCatalogMetaById(catalogId));
+    String name = catalog == null ? null : catalog.getCatalogName();
+    cache.put(catalogId, name);
+    return name;
+  }
+
+  @Nullable
+  private static String schemaName(Long schemaId, Map<Long, String> cache) {
+    if (schemaId == null) {
+      return null;
+    }
+    if (cache.containsKey(schemaId)) {
+      return cache.get(schemaId);
+    }
+    SchemaPO schema =
+        SessionUtils.getWithoutCommit(
+            SchemaMetaMapper.class, mapper -> 
mapper.selectSchemaMetaById(schemaId));
+    String name = schema == null ? null : schema.getSchemaName();
+    cache.put(schemaId, name);
+    return name;
+  }
+
+  /** One table ready for Spark expire-snapshots jobConf. */
+  public static final class ResolvedTableRef {
+    private final long tableId;
+    private final String catalogName;
+    private final String tableIdentifier;
+
+    /**
+     * @param tableId table entity id
+     * @param catalogName catalog name
+     * @param tableIdentifier {@code schema.table} Spark identifier
+     */
+    public ResolvedTableRef(long tableId, String catalogName, String 
tableIdentifier) {
+      this.tableId = tableId;
+      this.catalogName = catalogName;
+      this.tableIdentifier = tableIdentifier;
+    }
+
+    /**
+     * @return table entity id
+     */
+    public long tableId() {
+      return tableId;
+    }
+
+    /**
+     * @return catalog name
+     */
+    public String catalogName() {
+      return catalogName;
+    }
+
+    /**
+     * @return {@code schema.table} (or nested-namespace) Spark identifier
+     */
+    public String tableIdentifier() {
+      return tableIdentifier;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (this == o) {
+        return true;
+      }
+      if (!(o instanceof ResolvedTableRef)) {
+        return false;
+      }
+      ResolvedTableRef that = (ResolvedTableRef) o;
+      return tableId == that.tableId
+          && Objects.equals(catalogName, that.catalogName)
+          && Objects.equals(tableIdentifier, that.tableIdentifier);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(tableId, catalogName, tableIdentifier);
+    }
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/policy/expand/PolicyExpandCrons.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/policy/expand/PolicyExpandCrons.java
index 2efbde275e..4a6fedbfce 100644
--- 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/policy/expand/PolicyExpandCrons.java
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/policy/expand/PolicyExpandCrons.java
@@ -22,45 +22,23 @@ import 
com.github.kagkarlsson.scheduler.task.ExecutionComplete;
 import com.github.kagkarlsson.scheduler.task.schedule.CronSchedule;
 import com.github.kagkarlsson.scheduler.task.schedule.CronStyle;
 import com.github.kagkarlsson.scheduler.task.schedule.Schedule;
-import com.google.common.annotations.VisibleForTesting;
 import java.time.Instant;
 import java.time.ZoneId;
-import java.time.ZoneOffset;
 
-/**
- * Quartz cron helpers for {@code policy-expand} next-due calculation.
- *
- * <p>Always evaluates cron expressions in UTC so every scheduler node 
computes the same next-due
- * instant for a given {@code timeTrigger}, regardless of the JVM default time 
zone.
- */
+/** Quartz cron helpers for {@code policy-expand} next-due calculation. */
 final class PolicyExpandCrons {
 
-  /** Zone used for all policy-expand cron evaluations. */
-  static final ZoneId SCHEDULER_ZONE = ZoneOffset.UTC;
-
   private PolicyExpandCrons() {}
 
   /**
-   * Next fire time for a Quartz cron, strictly after now, in {@link 
#SCHEDULER_ZONE}.
+   * Next fire time for a Quartz cron, strictly after now.
    *
    * @param timeTrigger six-field Quartz cron expression
-   * @return next execution instant
+   * @return next execution instant in the system default zone
    */
   static Instant nextExecution(String timeTrigger) {
-    return nextExecution(timeTrigger, Instant.now());
-  }
-
-  /**
-   * Next fire time for a Quartz cron, strictly after {@code after}, in {@link 
#SCHEDULER_ZONE}.
-   *
-   * @param timeTrigger six-field Quartz cron expression
-   * @param after reference instant
-   * @return next execution instant
-   */
-  @VisibleForTesting
-  static Instant nextExecution(String timeTrigger, Instant after) {
-    CronSchedule schedule = new CronSchedule(timeTrigger, SCHEDULER_ZONE, 
CronStyle.QUARTZ);
-    Instant next = 
schedule.getNextExecutionTime(ExecutionComplete.simulatedSuccess(after));
+    CronSchedule schedule = new CronSchedule(timeTrigger, 
ZoneId.systemDefault(), CronStyle.QUARTZ);
+    Instant next = 
schedule.getNextExecutionTime(ExecutionComplete.simulatedSuccess(Instant.now()));
     if (next == null || Schedule.NEVER.equals(next)) {
       throw new IllegalArgumentException("timeTrigger has no next execution: " 
+ timeTrigger);
     }
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestMaintenanceJobSpecFactory.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestMaintenanceJobSpecFactory.java
new file mode 100644
index 0000000000..74027d4b9e
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/TestMaintenanceJobSpecFactory.java
@@ -0,0 +1,180 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler;
+
+import com.github.kagkarlsson.scheduler.serializer.JacksonSerializer;
+import com.github.kagkarlsson.scheduler.serializer.Serializer;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.maintenance.policy.MaintenanceAuthConfigs;
+import 
org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.TableTarget;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceTableRefs.ResolvedTableRef;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.PolicyEntity;
+import org.apache.gravitino.policy.IcebergDataCompactionContent;
+import org.apache.gravitino.policy.Policy;
+import org.apache.gravitino.policy.PolicyContents;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestMaintenanceJobSpecFactory {
+
+  private static final String GRAVITINO_URI = "http://localhost:8090";;
+
+  @Test
+  void testForCronBuildsSharedJobConfAndIdentifierLevels() {
+    MaintenanceJobSpecFactory factory = new 
MaintenanceJobSpecFactory(configWithUri());
+    PolicyEntity policy = compaction(202L);
+    MaintenanceJobSpec spec =
+        factory.forCron(
+            policy,
+            List.of(
+                new ResolvedTableRef(10L, "iceberg", "db.t1"),
+                new ResolvedTableRef(11L, "iceberg", "db.t2")));
+
+    Assertions.assertTrue(spec.isValid());
+    Assertions.assertEquals("ml", spec.metalake());
+    Assertions.assertEquals(List.of(10L, 11L), spec.tableIds());
+    Assertions.assertEquals(1, spec.getOperations().size());
+    Assertions.assertEquals(202L, spec.getOperations().get(0).getPolicyId());
+    Assertions.assertEquals("rewrite_data_files", 
spec.getOperations().get(0).getMaintenanceType());
+    Assertions.assertNull(spec.getJobConf().get("catalog_name"));
+    Assertions.assertNull(spec.getJobConf().get("table_identifier"));
+    Assertions.assertFalse(spec.getJobConf().containsKey("tms_table_id"));
+    Assertions.assertFalse(spec.getJobConf().containsKey("tms_policy_id"));
+    Assertions.assertTrue(
+        spec.getJobConf()
+            .get("updater_options")
+            .contains("gravitino.maintenance.gravitinoAuth.uri"));
+    
Assertions.assertFalse(spec.getJobConf().get("updater_options").contains(GRAVITINO_URI));
+
+    TableTarget first = spec.getTables().get(0);
+    Assertions.assertEquals(List.of("ml", "iceberg", "db", "t1"), 
first.getIdentifier());
+    Map<String, String> perTable =
+        MaintenanceJobSpecFactory.jobConfForTable(spec.getJobConf(), first);
+    Assertions.assertEquals("iceberg", perTable.get("catalog_name"));
+    Assertions.assertNull(perTable.get("table_identifier"));
+    Assertions.assertNull(perTable.get("table_id"));
+    Assertions.assertTrue(perTable.get("tables").contains("\"table_id\":10"));
+    
Assertions.assertTrue(perTable.get("tables").contains("\"table_identifier\":\"db.t1\""));
+    Assertions.assertFalse(perTable.containsKey("tms_table_id"));
+  }
+
+  @Test
+  void testForCronRejectsMixedCatalogs() {
+    MaintenanceJobSpecFactory factory = new 
MaintenanceJobSpecFactory(configWithUri());
+    Assertions.assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            factory.forCron(
+                compaction(1L),
+                List.of(
+                    new ResolvedTableRef(10L, "iceberg", "db.t1"),
+                    new ResolvedTableRef(11L, "other", "db.t2"))));
+  }
+
+  @Test
+  void testJacksonRoundTrip() {
+    MaintenanceJobSpecFactory factory = new 
MaintenanceJobSpecFactory(configWithUri());
+    MaintenanceJobSpec original =
+        factory.forCron(
+            compaction(1L), List.of(new ResolvedTableRef(9L, "iceberg", 
"schema.table")));
+    Serializer serializer = new JacksonSerializer(JsonUtils.anyFieldMapper());
+    byte[] bytes = serializer.serialize(original);
+    MaintenanceJobSpec restored = 
serializer.deserialize(MaintenanceJobSpec.class, bytes);
+    Assertions.assertEquals(original, restored);
+    String json = new String(bytes, StandardCharsets.UTF_8);
+    Assertions.assertFalse(json.contains("\"version\""));
+    Assertions.assertFalse(json.contains("\"metalake\""));
+    Assertions.assertTrue(json.contains("\"operations\""));
+    Assertions.assertTrue(json.contains("updater_options"));
+  }
+
+  @Test
+  void testMergeResourceKeysTakesMax() {
+    Map<String, String> into =
+        MaintenanceJobSpecFactory.newLinkedMap(
+            "spark_executor_memory", "4g", "spark_executor_cores", "2", 
"catalog_uri", "http://a";);
+    Map<String, String> from =
+        MaintenanceJobSpecFactory.newLinkedMap(
+            "spark_executor_memory", "7g", "spark_executor_cores", "1", 
"catalog_uri", "http://b";);
+    MaintenanceJobSpecFactory.mergeResourceKeys(into, from);
+    Assertions.assertEquals("7g", into.get("spark_executor_memory"));
+    Assertions.assertEquals("2", into.get("spark_executor_cores"));
+    Assertions.assertEquals("http://a";, into.get("catalog_uri"));
+  }
+
+  @Test
+  void testMergeResourceKeysComparesMemoryUnits() {
+    Map<String, String> into =
+        MaintenanceJobSpecFactory.newLinkedMap("spark_executor_memory", "1g");
+    Map<String, String> from =
+        MaintenanceJobSpecFactory.newLinkedMap("spark_executor_memory", 
"512m");
+    MaintenanceJobSpecFactory.mergeResourceKeys(into, from);
+    Assertions.assertEquals("1g", into.get("spark_executor_memory"));
+
+    Map<String, String> mixed =
+        MaintenanceJobSpecFactory.newLinkedMap("spark_executor_memory", 
"512m");
+    MaintenanceJobSpecFactory.mergeResourceKeys(
+        mixed, MaintenanceJobSpecFactory.newLinkedMap("spark_executor_memory", 
"1500m"));
+    Assertions.assertEquals("1500m", mixed.get("spark_executor_memory"));
+  }
+
+  private static Config configWithUri() {
+    Config config = new Config(false) {};
+    config.loadFromMap(
+        Map.of(
+            
MaintenanceAuthConfigs.GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_URI.getKey(),
+            GRAVITINO_URI),
+        t -> true);
+    return config;
+  }
+
+  private static PolicyEntity compaction(long id) {
+    return PolicyEntity.builder()
+        .withId(id)
+        .withName("p-" + id)
+        .withNamespace(NamespaceUtil.ofPolicy("ml"))
+        .withPolicyType(Policy.BuiltInType.ICEBERG_COMPACTION)
+        .withEnabled(true)
+        .withContent(
+            PolicyContents.icebergDataCompaction(
+                IcebergDataCompactionContent.DEFAULT_MIN_DATA_FILE_MSE,
+                IcebergDataCompactionContent.DEFAULT_MIN_DELETE_FILE_NUMBER,
+                IcebergDataCompactionContent.DEFAULT_DATA_FILE_MSE_WEIGHT,
+                IcebergDataCompactionContent.DEFAULT_DELETE_FILE_NUMBER_WEIGHT,
+                IcebergDataCompactionContent.DEFAULT_MAX_PARTITION_NUM,
+                "binpack",
+                "",
+                Map.of(),
+                null,
+                true,
+                null,
+                Map.of("spark_executor_memory", "4g"),
+                Map.of()))
+        .withAuditInfo(
+            
AuditInfo.builder().withCreator("test").withCreateTime(Instant.EPOCH).build())
+        .build();
+  }
+}
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceJobConfBuilder.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceJobConfBuilder.java
new file mode 100644
index 0000000000..b60bc5bd04
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceJobConfBuilder.java
@@ -0,0 +1,260 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler.job.cron;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Map;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.maintenance.policy.MaintenanceAuthConfigs;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.PolicyEntity;
+import org.apache.gravitino.policy.IcebergDataCompactionContent;
+import org.apache.gravitino.policy.Policy;
+import org.apache.gravitino.policy.PolicyContents;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestMaintenanceJobConfBuilder {
+
+  private static final long POLICY_ID = 9L;
+  private static final Instant FIXED = Instant.parse("2024-06-15T12:00:00Z");
+  private static final String GRAVITINO_URI = "http://localhost:8090";;
+
+  @Test
+  void testCompactionMapsTypedFieldsAndIrcIntoSparkConf() {
+    Config config = configWithUri();
+    config.loadFromMap(
+        Map.of(
+            
MaintenanceAuthConfigs.GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_URI.getKey(),
+            GRAVITINO_URI,
+            "gravitino.maintenance.ircAuth.rest.auth.type",
+            "basic"),
+        t -> true);
+
+    PolicyEntity policy =
+        PolicyEntity.builder()
+            .withId(POLICY_ID)
+            .withName("c")
+            .withNamespace(NamespaceUtil.ofPolicy("ml"))
+            .withPolicyType(Policy.BuiltInType.ICEBERG_COMPACTION)
+            .withEnabled(true)
+            .withContent(
+                PolicyContents.icebergDataCompaction(
+                    IcebergDataCompactionContent.DEFAULT_MIN_DATA_FILE_MSE,
+                    
IcebergDataCompactionContent.DEFAULT_MIN_DELETE_FILE_NUMBER,
+                    IcebergDataCompactionContent.DEFAULT_DATA_FILE_MSE_WEIGHT,
+                    
IcebergDataCompactionContent.DEFAULT_DELETE_FILE_NUMBER_WEIGHT,
+                    IcebergDataCompactionContent.DEFAULT_MAX_PARTITION_NUM,
+                    IcebergDataCompactionContent.REWRITE_STRATEGY_SORT,
+                    "id ASC NULLS LAST",
+                    Map.of("min-input-files", "4"),
+                    null,
+                    null,
+                    null,
+                    Map.of("catalog_type", "rest", "catalog_uri", 
"http://localhost:9001/iceberg";),
+                    Map.of()))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("t").withCreateTime(Instant.now()).build())
+            .build();
+
+    Map<String, String> conf =
+        new MaintenanceJobConfBuilder(config, Clock.fixed(FIXED, 
ZoneOffset.UTC))
+            .build(policy, "iceberg");
+
+    Assertions.assertEquals("sort", conf.get("strategy"));
+    Assertions.assertEquals("id ASC NULLS LAST", conf.get("sort_order"));
+    Assertions.assertTrue(conf.get("options").contains("min-input-files"));
+    Assertions.assertNull(conf.get("where_clause"));
+    Assertions.assertEquals("rest", conf.get("catalog_type"));
+    Assertions.assertEquals("rewrite_data_files", 
conf.get("maintenance_types"));
+    Assertions.assertEquals(Long.toString(policy.id()), 
conf.get("policy_ids"));
+    Assertions.assertTrue(
+        
conf.get(MaintenanceJobTemplates.RULES_KEY).contains("\"rewrite_data_files\""));
+    
Assertions.assertTrue(conf.get(MaintenanceJobTemplates.RULES_KEY).contains("trigger-expr"));
+    Assertions.assertTrue(
+        conf.get("spark_conf")
+            .contains(
+                
"\"spark.sql.catalog.iceberg.rest.auth.type\":\"gravitino.maintenance.ircAuth.rest.auth.type\""));
+    Assertions.assertFalse(conf.get("spark_conf").contains("\"basic\""));
+    Assertions.assertNull(conf.get("rest.auth.type"));
+    
Assertions.assertFalse(conf.get("spark_conf").contains("\"rest.auth.type\""));
+    
Assertions.assertTrue(conf.get("updater_options").contains("\"metalake\":\"ml\""));
+    Assertions.assertTrue(
+        conf.get("updater_options")
+            
.contains("\"gravitino_uri\":\"gravitino.maintenance.gravitinoAuth.uri\""));
+    
Assertions.assertFalse(conf.get("updater_options").contains(GRAVITINO_URI));
+
+    Map<String, String> resolved =
+        new MaintenanceJobConfBuilder(config, Clock.fixed(FIXED, 
ZoneOffset.UTC))
+            .resolveForSubmit(conf);
+    Assertions.assertTrue(
+        resolved.get("updater_options").contains("\"gravitino_uri\":\"" + 
GRAVITINO_URI + "\""));
+    Assertions.assertTrue(
+        resolved
+            .get("spark_conf")
+            
.contains("\"spark.sql.catalog.iceberg.rest.auth.type\":\"basic\""));
+  }
+
+  @Test
+  void testSparkResourceKeysFlattenToPlaceholdersNotCatalogProps() {
+    PolicyEntity policy =
+        PolicyEntity.builder()
+            .withId(POLICY_ID)
+            .withName("c")
+            .withNamespace(NamespaceUtil.ofPolicy("ml"))
+            .withPolicyType(Policy.BuiltInType.ICEBERG_COMPACTION)
+            .withEnabled(true)
+            .withContent(
+                PolicyContents.icebergDataCompaction(
+                    IcebergDataCompactionContent.DEFAULT_MIN_DATA_FILE_MSE,
+                    
IcebergDataCompactionContent.DEFAULT_MIN_DELETE_FILE_NUMBER,
+                    IcebergDataCompactionContent.DEFAULT_DATA_FILE_MSE_WEIGHT,
+                    
IcebergDataCompactionContent.DEFAULT_DELETE_FILE_NUMBER_WEIGHT,
+                    IcebergDataCompactionContent.DEFAULT_MAX_PARTITION_NUM,
+                    IcebergDataCompactionContent.DEFAULT_REWRITE_STRATEGY,
+                    IcebergDataCompactionContent.DEFAULT_SORT_ORDER,
+                    null,
+                    null,
+                    null,
+                    null,
+                    Map.of(
+                        "spark.executor.memory",
+                        "7g",
+                        "spark_executor_cores",
+                        "4",
+                        "catalog_type",
+                        "rest"),
+                    Map.of()))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("t").withCreateTime(Instant.now()).build())
+            .build();
+
+    Map<String, String> conf =
+        new MaintenanceJobConfBuilder(configWithUri(), Clock.fixed(FIXED, 
ZoneOffset.UTC))
+            .build(policy, "iceberg");
+
+    Assertions.assertEquals("7g", conf.get("spark_executor_memory"));
+    Assertions.assertEquals("4", conf.get("spark_executor_cores"));
+    Assertions.assertEquals("rest", conf.get("catalog_type"));
+    
Assertions.assertTrue(conf.get("spark_conf").contains("\"spark.executor.memory\":\"7g\""));
+    Assertions.assertFalse(
+        
conf.get("spark_conf").contains("spark.sql.catalog.iceberg.spark.executor.memory"));
+  }
+
+  @Test
+  void testOrphanMapsRetentionCutoff() {
+    PolicyEntity policy =
+        PolicyEntity.builder()
+            .withId(POLICY_ID)
+            .withName("o")
+            .withNamespace(NamespaceUtil.ofPolicy("ml"))
+            .withPolicyType(Policy.BuiltInType.ICEBERG_ORPHAN_FILE_REMOVAL)
+            .withEnabled(true)
+            .withContent(PolicyContents.icebergOrphanFileRemoval(7L, null, 
false))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("t").withCreateTime(Instant.now()).build())
+            .build();
+
+    Map<String, String> conf =
+        new MaintenanceJobConfBuilder(configWithUri(), Clock.fixed(FIXED, 
ZoneOffset.UTC))
+            .build(policy, "iceberg");
+
+    Assertions.assertEquals("remove_orphan_files", 
conf.get("maintenance_types"));
+    Assertions.assertEquals(Long.toString(policy.id()), 
conf.get("policy_ids"));
+    Assertions.assertEquals("false", conf.get("dry_run"));
+    Assertions.assertEquals("2024-06-08 12:00:00Z", conf.get("older_than"));
+  }
+
+  @Test
+  void testMissingGravitinoUriFailsClosed() {
+    PolicyEntity policy =
+        PolicyEntity.builder()
+            .withId(POLICY_ID)
+            .withName("c")
+            .withNamespace(NamespaceUtil.ofPolicy("ml"))
+            .withPolicyType(Policy.BuiltInType.ICEBERG_COMPACTION)
+            .withEnabled(true)
+            .withContent(PolicyContents.icebergDataCompaction())
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("t").withCreateTime(Instant.now()).build())
+            .build();
+
+    IllegalArgumentException thrown =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                new MaintenanceJobConfBuilder(
+                        new Config(false) {}, Clock.fixed(FIXED, 
ZoneOffset.UTC))
+                    .build(policy, "iceberg"));
+    Assertions.assertTrue(thrown.getMessage().contains("gravitino_uri"));
+  }
+
+  @Test
+  void testPolicyUpdaterOptionsOverrideClusterUriButNotMetalake() {
+    Config config = configWithUri();
+    PolicyEntity policy =
+        PolicyEntity.builder()
+            .withId(POLICY_ID)
+            .withName("c")
+            .withNamespace(NamespaceUtil.ofPolicy("ml"))
+            .withPolicyType(Policy.BuiltInType.ICEBERG_COMPACTION)
+            .withEnabled(true)
+            .withContent(
+                PolicyContents.icebergDataCompaction(
+                    IcebergDataCompactionContent.DEFAULT_MIN_DATA_FILE_MSE,
+                    
IcebergDataCompactionContent.DEFAULT_MIN_DELETE_FILE_NUMBER,
+                    IcebergDataCompactionContent.DEFAULT_DATA_FILE_MSE_WEIGHT,
+                    
IcebergDataCompactionContent.DEFAULT_DELETE_FILE_NUMBER_WEIGHT,
+                    IcebergDataCompactionContent.DEFAULT_MAX_PARTITION_NUM,
+                    IcebergDataCompactionContent.DEFAULT_REWRITE_STRATEGY,
+                    IcebergDataCompactionContent.DEFAULT_SORT_ORDER,
+                    null,
+                    null,
+                    null,
+                    null,
+                    Map.of(),
+                    Map.of("gravitino_uri", "http://policy-uri:8090";, 
"metalake", "policy-ml")))
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("t").withCreateTime(Instant.now()).build())
+            .build();
+
+    Map<String, String> conf =
+        new MaintenanceJobConfBuilder(config, Clock.fixed(FIXED, 
ZoneOffset.UTC))
+            .build(policy, "iceberg");
+
+    Assertions.assertTrue(
+        
conf.get("updater_options").contains("\"gravitino_uri\":\"http://policy-uri:8090\"";));
+    // Path metalake always comes from the policy namespace, never policy 
updaterOptions.
+    
Assertions.assertTrue(conf.get("updater_options").contains("\"metalake\":\"ml\""));
+    Assertions.assertFalse(conf.get("updater_options").contains("policy-ml"));
+  }
+
+  private static Config configWithUri() {
+    Config config = new Config(false) {};
+    config.loadFromMap(
+        Map.of(
+            
MaintenanceAuthConfigs.GRAVITINO_MAINTENANCE_GRAVITINO_AUTH_URI.getKey(),
+            GRAVITINO_URI),
+        t -> true);
+    return config;
+  }
+}
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceJobTemplates.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceJobTemplates.java
new file mode 100644
index 0000000000..123124f004
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/cron/TestMaintenanceJobTemplates.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.maintenance.scheduler.job.cron;
+
+import org.apache.gravitino.policy.Policy;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link MaintenanceJobTemplates}. */
+class TestMaintenanceJobTemplates {
+
+  @Test
+  void testAllFourTypesMapToSingleTmsTemplate() {
+    Assertions.assertEquals(
+        "builtin-iceberg-maintenance-task",
+        MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE);
+    for (Policy.BuiltInType type :
+        new Policy.BuiltInType[] {
+          Policy.BuiltInType.ICEBERG_COMPACTION,
+          Policy.BuiltInType.ICEBERG_REWRITE_MANIFESTS,
+          Policy.BuiltInType.ICEBERG_SNAPSHOT_EXPIRATION,
+          Policy.BuiltInType.ICEBERG_ORPHAN_FILE_REMOVAL
+        }) {
+      Assertions.assertEquals(
+          MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE,
+          MaintenanceJobTemplates.templateName(type).orElseThrow());
+      
Assertions.assertTrue(MaintenanceJobTemplates.maintenanceType(type).isPresent());
+    }
+  }
+
+  @Test
+  void testMaintenanceTypeWireValues() {
+    Assertions.assertEquals(
+        "rewrite_data_files",
+        
MaintenanceJobTemplates.maintenanceType(Policy.BuiltInType.ICEBERG_COMPACTION)
+            .orElseThrow());
+    Assertions.assertEquals(
+        "expire_snapshots",
+        
MaintenanceJobTemplates.maintenanceType(Policy.BuiltInType.ICEBERG_SNAPSHOT_EXPIRATION)
+            .orElseThrow());
+  }
+}

Reply via email to