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 3217b14f70d688eab15d15a4c3d834c1d10a1736
Author: remilu <[email protected]>
AuthorDate: Mon Oct 26 07:55:09 2026 +0800

    [#14219] feat(tms): Enqueue commit-job specs from IRC updates
    
    Fixes #14219.
    
    Squashed from fork #80 (B10).
    
    Co-authored-by: Cursor <[email protected]>
---
 conf/gravitino.conf.template                       |   8 +
 design-docs/table-maintenance-service-design.md    |   6 +-
 .../listener/PostCommitMaintenanceListener.java    | 171 +++++++++++++++++
 .../scheduler/TableMaintenanceSchedulerPools.java  |  37 +++-
 .../scheduler/job/commit/CommitJobScheduler.java   |  39 ++++
 .../scheduler/job/commit/CommitJobSpecFactory.java | 200 ++++++++++++++++++++
 .../commit/MaintenanceCommitSubmitPipeline.java    | 192 ++++++++++++++++++++
 .../job/commit/RelationalCommitJobScheduler.java   | 202 +++++++++++++++++++++
 .../mapper/TableMaintenanceActionMapper.java       |   3 +
 .../TableMaintenanceActionSQLProviderFactory.java  |  11 ++
 .../TableMaintenanceActionBaseSQLProvider.java     |  24 +++
 .../service/TableMaintenanceActionMetaService.java | 134 ++++++++++++++
 .../TestPostCommitMaintenanceListener.java         | 114 ++++++++++++
 .../job/commit/TestCommitJobSpecFactory.java       | 172 ++++++++++++++++++
 .../TestMaintenanceCommitSubmitPipeline.java       | 185 +++++++++++++++++++
 .../commit/TestRelationalCommitJobScheduler.java   | 190 +++++++++++++++++++
 .../TestTableMaintenanceActionMetaService.java     |  88 ++++++++-
 17 files changed, 1765 insertions(+), 11 deletions(-)

diff --git a/conf/gravitino.conf.template b/conf/gravitino.conf.template
index 66554eb606..f465f72f21 100644
--- a/conf/gravitino.conf.template
+++ b/conf/gravitino.conf.template
@@ -154,6 +154,14 @@ gravitino.lance-rest.namespace-backend = gravitino
 # gravitino.maintenance.snapshot-expiry.minIntervalMs = 3600000
 # gravitino.maintenance.orphan-cleanup.minIntervalMs = 86400000
 
+# Base URI Spark jobs use for TMS sample/finish callbacks 
(updater_options.gravitino_uri).
+# Required for cron Spark submit when the policy does not set 
updaterOptions.gravitino_uri.
+# gravitino.maintenance.gravitinoUri = http://localhost:8090
+
+# IRC → post-commit maintenance listener (§5.2.3). Requires colocated 
iceberg-rest aux service and TMS Feature.
+# gravitino.eventListener.names = post-commit-maintenance-listener
+# gravitino.eventListener.post-commit-maintenance-listener.class = 
org.apache.gravitino.maintenance.listener.PostCommitMaintenanceListener
+
 # THE CONFIGURATION FOR sensitive property key matching
 # Comma-separated keywords. Each entry is a literal substring, not a regular 
expression.
 # This list replaces the default 
(secret,password,token,credential,access,account).
diff --git a/design-docs/table-maintenance-service-design.md 
b/design-docs/table-maintenance-service-design.md
index 5913c07926..da3f7e4f0b 100644
--- a/design-docs/table-maintenance-service-design.md
+++ b/design-docs/table-maintenance-service-design.md
@@ -473,14 +473,14 @@ where needed, mutate tables, and run Jobs.
 | ----------------------------------------- | 
------------------------------------------------------------------------------------------
 |
 | `gravitino.server.rest.extensionPackages` | Include TMS Feature package 
(e.g. `org.apache.gravitino.maintenance.web.rest.feature`)     |
 | `gravitino.auxService.names`              | Include `iceberg-rest` when 
using IRC (same JVM)                                           |
-| `gravitino.eventListener.names`           | Include a TMS commit-job 
listener name (e.g. `tms-commit-job`)                             |
+| `gravitino.eventListener.names`           | Include the post-commit 
maintenance listener name (example below)                          |
 | `gravitino.eventListener.{name}.class`    | FQCN of the TMS 
`EventListenerPlugin` that upserts commit-job on `IcebergUpdateTableEvent` |
 
 ```properties
 gravitino.server.rest.extensionPackages = 
org.apache.gravitino.maintenance.web.rest.feature
 gravitino.auxService.names = iceberg-rest
-gravitino.eventListener.names = tms-commit-job
-gravitino.eventListener.tms-commit-job.class = 
org.apache.gravitino.maintenance.listener.IcebergUpdateTableEventListener
+gravitino.eventListener.names = post-commit-maintenance-listener
+gravitino.eventListener.post-commit-maintenance-listener.class = 
org.apache.gravitino.maintenance.listener.PostCommitMaintenanceListener
 gravitino.maintenance.scheduler.expandThreads = 4
 gravitino.maintenance.scheduler.cronThreads = 8
 gravitino.maintenance.scheduler.commitThreads = 4
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/listener/PostCommitMaintenanceListener.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/listener/PostCommitMaintenanceListener.java
new file mode 100644
index 0000000000..facf3d3ec8
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/listener/PostCommitMaintenanceListener.java
@@ -0,0 +1,171 @@
+/*
+ * 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.listener;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.Map;
+import java.util.function.Function;
+import javax.sql.DataSource;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.listener.api.EventListenerPlugin;
+import org.apache.gravitino.listener.api.event.Event;
+import 
org.apache.gravitino.maintenance.scheduler.job.commit.CommitJobScheduler;
+import 
org.apache.gravitino.maintenance.scheduler.job.commit.CommitJobUpsertResult;
+import 
org.apache.gravitino.maintenance.scheduler.job.commit.RelationalCommitJobScheduler;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.storage.relational.service.TableMetaService;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Upserts a one-shot {@code commit-job} / {@code {table_id}} after a 
successful IRC table update
+ * (§5.2.3).
+ *
+ * <p>Register with {@code gravitino.eventListener.names} / {@code 
.{name}.class}. Enqueue builds
+ * the onCommit chain spec (cooldown gate + jobConf) into {@code task_data}; 
the commit pool
+ * consumes that spec and submits Spark.
+ *
+ * <p>Uses the event class name (not a compile dependency on {@code 
iceberg-rest-server}) so TMS can
+ * load when IRC events are absent from the classpath.
+ */
+public class PostCommitMaintenanceListener implements EventListenerPlugin {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(PostCommitMaintenanceListener.class);
+
+  /** FQCN of the IRC post-event that wakes commit maintenance. */
+  static final String ICEBERG_UPDATE_TABLE_EVENT =
+      "org.apache.gravitino.listener.api.event.IcebergUpdateTableEvent";
+
+  private CommitJobScheduler commitJobScheduler;
+  private Function<NameIdentifier, TableEntity> tableLookupOverride;
+
+  @Override
+  public void init(Map<String, String> properties) throws RuntimeException {
+    // No listener-specific properties yet.
+  }
+
+  @Override
+  public void start() throws RuntimeException {
+    DataSource dataSource = resolveEntityStoreDataSource();
+    Config gravitinoConfig = GravitinoEnv.getInstance().config();
+    if (gravitinoConfig == null) {
+      throw new IllegalStateException("GravitinoEnv config is not available");
+    }
+    this.commitJobScheduler = new RelationalCommitJobScheduler(dataSource, 
gravitinoConfig);
+    LOG.info("PostCommitMaintenanceListener started (commit-job upsert with 
spec)");
+  }
+
+  @Override
+  public void stop() throws RuntimeException {
+    commitJobScheduler = null;
+  }
+
+  @Override
+  public Mode mode() {
+    return Mode.ASYNC_ISOLATED;
+  }
+
+  @Override
+  public void onPostEvent(Event postEvent) throws RuntimeException {
+    if (postEvent == null || 
!ICEBERG_UPDATE_TABLE_EVENT.equals(postEvent.getClass().getName())) {
+      return;
+    }
+    enqueueCommitJob(postEvent.identifier());
+  }
+
+  /**
+   * Resolves {@code identifier} to a table entity id and upserts {@code 
commit-job}.
+   *
+   * @param identifier Gravitino table name identifier from the IRC event
+   */
+  @VisibleForTesting
+  void enqueueCommitJob(NameIdentifier identifier) {
+    CommitJobScheduler scheduler = commitJobScheduler;
+    if (scheduler == null) {
+      LOG.debug("Ignoring IcebergUpdateTableEvent; commit-job scheduler is not 
started");
+      return;
+    }
+    if (identifier == null) {
+      return;
+    }
+    try {
+      long tableId = resolveTableId(identifier);
+      CommitJobUpsertResult result = scheduler.upsertDueNow(tableId);
+      switch (result) {
+        case INSERTED:
+          LOG.info("Upserted commit-job for table {} ({})", tableId, 
identifier);
+          break;
+        case ALREADY_QUEUED:
+          LOG.debug("commit-job already queued for table {} ({})", tableId, 
identifier);
+          break;
+        case SKIPPED:
+          LOG.debug(
+              "Skipped commit-job enqueue for table {} ({}) (empty chain, 
unresolved table, auth, or jobConf build)",
+              tableId,
+              identifier);
+          break;
+        default:
+          throw new IllegalStateException("Unexpected commit-job upsert 
result: " + result);
+      }
+    } catch (NoSuchEntityException e) {
+      LOG.debug("No Gravitino table for IRC update {}: {}", identifier, 
e.getMessage());
+    } catch (RuntimeException e) {
+      LOG.warn("Failed to upsert commit-job for IRC update {}: {}", 
identifier, e.toString());
+    }
+  }
+
+  @VisibleForTesting
+  void setCommitJobScheduler(CommitJobScheduler commitJobScheduler) {
+    this.commitJobScheduler = commitJobScheduler;
+  }
+
+  @VisibleForTesting
+  void setTableLookup(Function<NameIdentifier, TableEntity> tableLookup) {
+    this.tableLookupOverride = tableLookup;
+  }
+
+  private long resolveTableId(NameIdentifier identifier) {
+    if (tableLookupOverride != null) {
+      TableEntity table = tableLookupOverride.apply(identifier);
+      if (table == null) {
+        throw new NoSuchEntityException("table not found: %s", identifier);
+      }
+      return table.id();
+    }
+    TableEntity table = 
TableMetaService.getInstance().getTableByIdentifier(identifier);
+    return table.id();
+  }
+
+  private static DataSource resolveEntityStoreDataSource() {
+    Object dataSource =
+        SqlSessionFactoryHelper.getInstance()
+            .getSqlSessionFactory()
+            .getConfiguration()
+            .getEnvironment()
+            .getDataSource();
+    if (!(dataSource instanceof DataSource)) {
+      throw new IllegalStateException("Entity store data source is not 
available");
+    }
+    return (DataSource) dataSource;
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java
index fc1140e922..104ef7e81e 100644
--- 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/TableMaintenanceSchedulerPools.java
@@ -36,6 +36,8 @@ import org.apache.gravitino.Config;
 import org.apache.gravitino.json.JsonUtils;
 import org.apache.gravitino.maintenance.TableMaintenanceConfig;
 import org.apache.gravitino.maintenance.occupancy.OccupancyGate;
+import 
org.apache.gravitino.maintenance.scheduler.job.commit.CommitOccupancyGate;
+import 
org.apache.gravitino.maintenance.scheduler.job.commit.MaintenanceCommitSubmitPipeline;
 import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobConfBuilder;
 import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobSubmitter;
 import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceSparkSubmitPipeline;
@@ -56,8 +58,8 @@ import 
org.apache.gravitino.maintenance.scheduler.policy.expand.ExpandOccupancyG
  * isolation; sharing one physical table across pools is unsupported (see 
design §5.2).
  *
  * <p>The expand pool fans a due {@code policy-expand} lease into {@code 
cron-job} rows with an
- * enqueue-time {@link MaintenanceJobSpec}. The cron pool consumes that spec, 
claims a soft slot,
- * and submits Spark. The commit pool still only removes its one-shot row.
+ * enqueue-time {@link MaintenanceJobSpec}. The cron and commit pools consume 
that spec, claim a
+ * soft slot, and submit Spark.
  */
 public class TableMaintenanceSchedulerPools implements AutoCloseable {
 
@@ -95,6 +97,9 @@ public class TableMaintenanceSchedulerPools implements 
AutoCloseable {
   static final AtomicReference<BiConsumer<String, MaintenanceJobSpec>> 
CRON_SUBMIT =
       new AtomicReference<>(unconfiguredSubmit("cron-job"));
 
+  /** Commit submit handler used by {@link #commitJobTask()}; bound in {@link 
#start}. */
+  static final AtomicReference<BiConsumer<String, MaintenanceJobSpec>> 
COMMIT_SUBMIT =
+      new AtomicReference<>(unconfiguredSubmit("commit-job"));
 
   private final List<Scheduler> schedulers = new ArrayList<>();
 
@@ -135,6 +140,15 @@ public class TableMaintenanceSchedulerPools implements 
AutoCloseable {
             config.claimTimeoutMs());
     CRON_SUBMIT.set(cronSubmit::submit);
 
+    MaintenanceCommitSubmitPipeline commitSubmit =
+        new MaintenanceCommitSubmitPipeline(
+            CommitOccupancyGate.of(occupancyGate),
+            TableMaintenanceActionMetaService.getInstance(),
+            MaintenanceJobSubmitter.fromEnv(),
+            new MaintenanceJobConfBuilder(gravitinoConfig),
+            config.claimTimeoutMs());
+    COMMIT_SUBMIT.set(commitSubmit::submit);
+
     List<Scheduler> built =
         List.of(
             buildScheduler(
@@ -171,6 +185,7 @@ public class TableMaintenanceSchedulerPools implements 
AutoCloseable {
           throw new IllegalStateException("policy-expand pipeline is not 
configured");
         });
     CRON_SUBMIT.set(unconfiguredSubmit("cron-job"));
+    COMMIT_SUBMIT.set(unconfiguredSubmit("commit-job"));
     LOG.info("Stopped table maintenance scheduler pools");
   }
 
@@ -244,15 +259,27 @@ public class TableMaintenanceSchedulerPools implements 
AutoCloseable {
   }
 
 
+
   /**
-   * Commit-pool task definition. Removes the one-shot row. Chained submit 
lands in a follow-up.
+   * Commit-pool task definition. Consumes the enqueue-time chain spec, claims 
a soft slot, submits
+   * Spark, then removes the one-shot row.
    *
    * @return the {@code commit-job} task
    */
   public static Task<MaintenanceJobSpec> commitJobTask() {
     return Tasks.custom(COMMIT_JOB, MaintenanceJobSpec.class)
         .execute(
-            (taskInstance, executionContext) ->
-                (executionComplete, executionOperations) -> 
executionOperations.remove());
+            (taskInstance, executionContext) -> {
+              try {
+                COMMIT_SUBMIT.get().accept(taskInstance.getId(), 
taskInstance.getData());
+              } catch (RuntimeException e) {
+                LOG.warn(
+                    "commit-job submit failed for instance {}; removing 
one-shot row",
+                    taskInstance.getId(),
+                    e);
+              }
+              return (executionComplete, executionOperations) -> 
executionOperations.remove();
+            });
   }
+
 }
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/CommitJobScheduler.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/CommitJobScheduler.java
new file mode 100644
index 0000000000..7d1d564c56
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/CommitJobScheduler.java
@@ -0,0 +1,39 @@
+/*
+ * 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.commit;
+
+/**
+ * Upserts one-shot {@code commit-job} rows for IRC table updates (§5.2.3).
+ *
+ * <p>Instance id is {@code {table_id}}; bursts of commits on the same table 
coalesce via {@code
+ * scheduleIfNotExists}. Enqueue builds a {@link
+ * org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec} when at 
least one onCommit type is
+ * due; an existing row's {@code task_data} is left untouched.
+ */
+public interface CommitJobScheduler {
+
+  /**
+   * Builds an enqueue-time spec (chain + jobConf) and schedules a due-now 
{@code commit-job} when
+   * one is not already queued and the spec is non-empty.
+   *
+   * @param tableId table entity id
+   * @return insert / coalesce / skip outcome
+   */
+  CommitJobUpsertResult upsertDueNow(long tableId);
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/CommitJobSpecFactory.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/CommitJobSpecFactory.java
new file mode 100644
index 0000000000..ad83df0f4f
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/CommitJobSpecFactory.java
@@ -0,0 +1,200 @@
+/*
+ * 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.commit;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.google.common.base.Preconditions;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+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.scheduler.MaintenanceJobSpec;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.Operation;
+import 
org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.TableTarget;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpecFactory;
+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;
+
+/**
+ * Builds enqueue-time {@link MaintenanceJobSpec} values for the commit-job 
path (keeps commit →
+ * scheduler dependency one-way; no scheduler → commit package cycle).
+ *
+ * <p>Chain {@code jobConf} starts from the first policy, takes the max of 
resource-shaped keys from
+ * later members, and {@code putIfAbsent}s remaining keys so later types keep 
their procedure params
+ * (e.g. expire {@code older_than}) when auth bags already agree. {@code 
rules} is unioned across
+ * members so each type keeps its enqueue-time rules for in-Spark recommend.
+ */
+final class CommitJobSpecFactory {
+
+  private static final Set<String> CHAIN_OWNED_KEYS =
+      Set.of(
+          MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY,
+          MaintenanceJobTemplates.POLICY_IDS_KEY,
+          "catalog_name",
+          "table_identifier",
+          "tables",
+          MaintenanceJobTemplates.RULES_KEY);
+
+  private final MaintenanceJobConfBuilder jobConfBuilder;
+
+  /**
+   * @param gravitinoConfig server config for auth overlay and gravitino URI
+   */
+  CommitJobSpecFactory(Config gravitinoConfig) {
+    this.jobConfBuilder =
+        new 
MaintenanceJobConfBuilder(Preconditions.checkNotNull(gravitinoConfig));
+  }
+
+  /**
+   * Commit-chain spec for one table. Caller must already have filtered 
cooldown and verified auth
+   * consistency.
+   *
+   * @param chain non-empty onCommit members in order
+   * @param ref resolved Spark table ref
+   * @return spec with merged {@code jobConf} including {@code 
maintenance_types} and {@code
+   *     policy_ids}
+   */
+  MaintenanceJobSpec forCommit(List<CommitChainMember> chain, ResolvedTableRef 
ref) {
+    Preconditions.checkArgument(chain != null && !chain.isEmpty(), "chain must 
not be empty");
+    Preconditions.checkNotNull(ref, "ref");
+
+    CommitChainMember first = chain.get(0);
+    String metalake = first.policy().namespace().level(0);
+    Map<String, String> jobConf = buildChainJobConf(chain, ref);
+    List<TableTarget> targets = 
List.of(MaintenanceJobSpecFactory.toTarget(metalake, ref));
+    List<Operation> operations =
+        chain.stream()
+            .map(m -> new Operation(m.policy().id(), m.maintenanceType()))
+            .collect(Collectors.toList());
+    return new MaintenanceJobSpec(targets, operations, jobConf);
+  }
+
+  private Map<String, String> buildChainJobConf(
+      List<CommitChainMember> chain, ResolvedTableRef ref) {
+    CommitChainMember first = chain.get(0);
+    Map<String, String> jobConf = jobConfBuilder.build(first.policy(), 
ref.catalogName());
+    for (int i = 1; i < chain.size(); i++) {
+      Map<String, String> other = jobConfBuilder.build(chain.get(i).policy(), 
ref.catalogName());
+      mergeLaterMemberJobConf(jobConf, other);
+    }
+    List<String> types =
+        
chain.stream().map(CommitChainMember::maintenanceType).collect(Collectors.toList());
+    List<String> policyIds =
+        chain.stream().map(m -> 
Long.toString(m.policy().id())).collect(Collectors.toList());
+    jobConf.put(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY, 
String.join(",", types));
+    jobConf.put(MaintenanceJobTemplates.POLICY_IDS_KEY, String.join(",", 
policyIds));
+    // Per-table keys (catalog_name / tables JSON) are applied at pick via 
jobConfForTable.
+    jobConf.remove("catalog_name");
+    jobConf.remove("table_identifier");
+    jobConf.remove("tables");
+    return jobConf;
+  }
+
+  /**
+   * Max-merge resource keys; fill missing non-owned keys from a later chain 
member (procedure
+   * params). First-policy auth / {@code updater_options} win. Nested {@code 
spark_conf} is merged
+   * by key with first-wins {@code putIfAbsent}. {@code rules} are unioned by 
maintenance type.
+   */
+  static void mergeLaterMemberJobConf(Map<String, String> into, Map<String, 
String> from) {
+    MaintenanceJobSpecFactory.mergeResourceKeys(into, from);
+    mergePolicyRulesByType(into, from);
+    mergeSparkConfByKey(into, from);
+    for (Map.Entry<String, String> entry : from.entrySet()) {
+      String key = entry.getKey();
+      String value = entry.getValue();
+      if (key == null
+          || value == null
+          || CHAIN_OWNED_KEYS.contains(key)
+          || "spark_conf".equals(key)) {
+        continue;
+      }
+      into.putIfAbsent(key, value);
+    }
+  }
+
+  /**
+   * Merges nested {@code spark_conf} JSON maps. Existing keys in {@code into} 
win; keys only in
+   * {@code from} are added.
+   */
+  static void mergeSparkConfByKey(Map<String, String> into, Map<String, 
String> from) {
+    String fromJson = from.get("spark_conf");
+    if (fromJson == null || fromJson.trim().isEmpty()) {
+      return;
+    }
+    Map<String, String> merged =
+        new LinkedHashMap<>(
+            IcebergSparkConfigUtils.parseFlatJsonMap(into.get("spark_conf"), 
"spark_conf"));
+    Map<String, String> later = 
IcebergSparkConfigUtils.parseFlatJsonMap(fromJson, "spark_conf");
+    for (Map.Entry<String, String> entry : later.entrySet()) {
+      merged.putIfAbsent(entry.getKey(), entry.getValue());
+    }
+    if (merged.isEmpty()) {
+      return;
+    }
+    try {
+      into.put("spark_conf", 
JsonUtils.anyFieldMapper().writeValueAsString(merged));
+    } catch (JsonProcessingException e) {
+      throw new IllegalStateException("Failed to merge spark_conf for commit 
chain", e);
+    }
+  }
+
+  /**
+   * Union {@link MaintenanceJobTemplates#RULES_KEY} maps so each chain type 
keeps its own
+   * enqueue-time rules for in-Spark recommend.
+   */
+  static void mergePolicyRulesByType(Map<String, String> into, Map<String, 
String> from) {
+    String fromJson = from.get(MaintenanceJobTemplates.RULES_KEY);
+    if (fromJson == null || fromJson.trim().isEmpty()) {
+      return;
+    }
+    Map<String, Map<String, Object>> merged =
+        new 
LinkedHashMap<>(parseRulesByType(into.get(MaintenanceJobTemplates.RULES_KEY)));
+    Map<String, Map<String, Object>> later = parseRulesByType(fromJson);
+    for (Map.Entry<String, Map<String, Object>> entry : later.entrySet()) {
+      merged.putIfAbsent(entry.getKey(), entry.getValue());
+    }
+    try {
+      into.put(
+          MaintenanceJobTemplates.RULES_KEY, 
JsonUtils.anyFieldMapper().writeValueAsString(merged));
+    } catch (JsonProcessingException e) {
+      throw new IllegalStateException(
+          "Failed to serialize " + MaintenanceJobTemplates.RULES_KEY, e);
+    }
+  }
+
+  private static Map<String, Map<String, Object>> parseRulesByType(String 
json) {
+    if (json == null || json.trim().isEmpty()) {
+      return Map.of();
+    }
+    try {
+      Map<String, Map<String, Object>> parsed =
+          JsonUtils.anyFieldMapper()
+              .readValue(json, new TypeReference<Map<String, Map<String, 
Object>>>() {});
+      return parsed == null ? Map.of() : parsed;
+    } catch (JsonProcessingException e) {
+      throw new IllegalStateException("Failed to parse " + 
MaintenanceJobTemplates.RULES_KEY, e);
+    }
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/MaintenanceCommitSubmitPipeline.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/MaintenanceCommitSubmitPipeline.java
new file mode 100644
index 0000000000..91a7d5df1d
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/MaintenanceCommitSubmitPipeline.java
@@ -0,0 +1,192 @@
+/*
+ * 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.commit;
+
+import com.google.common.base.Preconditions;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.Operation;
+import 
org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec.TableTarget;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpecFactory;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobConfBuilder;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobSubmitter;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobTemplates;
+import org.apache.gravitino.maintenance.storage.service.OccupancyClaim;
+import 
org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Commit-pool control-plane submit (§5.2.3): consume enqueue-time {@link 
MaintenanceJobSpec} →
+ * abort if any step is in-flight → soft-slot claim for every chain policy → 
one chained {@code
+ * runJob} → bind the same job id on every claim.
+ *
+ * <p>Chain collection, cooldown filtering, auth consistency, and jobConf 
merge happen at enqueue
+ * (see {@link RelationalCommitJobScheduler}). The caller always deletes the 
one-shot {@code
+ * commit-job} row after this method returns.
+ */
+public final class MaintenanceCommitSubmitPipeline {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(MaintenanceCommitSubmitPipeline.class);
+
+  private final CommitOccupancyGate occupancyGate;
+  private final TableMaintenanceActionMetaService actionMetaService;
+  private final MaintenanceJobSubmitter jobSubmitter;
+  private final MaintenanceJobConfBuilder jobConfBuilder;
+  private final long claimTimeoutMs;
+
+  /**
+   * @param occupancyGate in-flight checks
+   * @param actionMetaService soft-slot claim / bind
+   * @param jobSubmitter Gravitino Job submitter
+   * @param jobConfBuilder resolves enqueue-time conf-key refs before {@code 
runJob}
+   * @param claimTimeoutMs unbound claim TTL
+   */
+  public MaintenanceCommitSubmitPipeline(
+      CommitOccupancyGate occupancyGate,
+      TableMaintenanceActionMetaService actionMetaService,
+      MaintenanceJobSubmitter jobSubmitter,
+      MaintenanceJobConfBuilder jobConfBuilder,
+      long claimTimeoutMs) {
+    this.occupancyGate = Preconditions.checkNotNull(occupancyGate);
+    this.actionMetaService = Preconditions.checkNotNull(actionMetaService);
+    this.jobSubmitter = Preconditions.checkNotNull(jobSubmitter);
+    this.jobConfBuilder = Preconditions.checkNotNull(jobConfBuilder);
+    Preconditions.checkArgument(claimTimeoutMs > 0, "claimTimeoutMs must be 
positive");
+    this.claimTimeoutMs = claimTimeoutMs;
+  }
+
+  /**
+   * Runs one commit-job pick for {@code instanceId} ({@code {table_id}}).
+   *
+   * @param instanceId db-scheduler commit-job instance id
+   * @param spec enqueue-time payload; required
+   */
+  public void submit(String instanceId, @Nullable MaintenanceJobSpec spec) {
+    long tableId;
+    try {
+      tableId = Long.parseLong(instanceId);
+    } catch (NumberFormatException e) {
+      LOG.warn("Skipping commit-job submit for unparseable instance id {}", 
instanceId);
+      return;
+    }
+    if (tableId <= 0) {
+      LOG.warn("Skipping commit-job submit for non-positive table id {}", 
instanceId);
+      return;
+    }
+    if (spec == null || !spec.isValid()) {
+      LOG.warn(
+          "Skipping commit-job submit for table {} with missing/invalid 
task_data spec", tableId);
+      return;
+    }
+    TableTarget target = spec.getTables().get(0);
+    if (target.getTableId() != tableId) {
+      LOG.warn(
+          "Skipping commit-job submit for instance {} whose spec tableId is 
{}",
+          instanceId,
+          target.getTableId());
+      return;
+    }
+
+    // Design §5.2.3: any remaining type in-flight → delete commit-job and 
stop.
+    for (Operation step : spec.getOperations()) {
+      if (occupancyGate.isInFlight(tableId, step.getPolicyId())) {
+        LOG.info(
+            "Skipping commit-job submit for table {} (type {} policy {} still 
in-flight)",
+            tableId,
+            step.getMaintenanceType(),
+            step.getPolicyId());
+        return;
+      }
+    }
+
+    List<Long> policyIds =
+        
spec.getOperations().stream().map(Operation::getPolicyId).collect(Collectors.toList());
+    Optional<List<OccupancyClaim>> claims =
+        actionMetaService.tryAcquireInFlightBatch(tableId, policyIds, 
claimTimeoutMs);
+    if (claims.isEmpty()) {
+      LOG.info(
+          "Skipping commit-job submit for table {} (lost soft-slot race on 
chain policies {})",
+          tableId,
+          policyIds);
+      return;
+    }
+
+    runAndBind(spec, claims.get(), tableId);
+  }
+
+  private void runAndBind(MaintenanceJobSpec spec, List<OccupancyClaim> 
claims, long tableId) {
+    String metalake = spec.metalake();
+    Map<String, String> jobConf;
+    try {
+      jobConf =
+          jobConfBuilder.resolveForSubmit(
+              MaintenanceJobSpecFactory.jobConfForTable(
+                  spec.getJobConf(), spec.getTables().get(0)));
+    } catch (RuntimeException e) {
+      actionMetaService.deleteClaims(claims);
+      LOG.warn(
+          "Skipping commit-job submit for table {}: invalid spec table target: 
{}",
+          tableId,
+          e.toString());
+      return;
+    }
+    Long jobId = null;
+    try {
+      jobId =
+          jobSubmitter.runJob(
+              metalake, 
MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE, jobConf);
+      actionMetaService.bindJobIds(claims, jobId);
+      LOG.info(
+          "Submitted commit-job for table {} as job {} (chain={}, 
policies={})",
+          tableId,
+          jobId,
+          spec.getOperations().stream()
+              .map(Operation::getMaintenanceType)
+              .collect(Collectors.toList()),
+          
spec.getOperations().stream().map(Operation::getPolicyId).collect(Collectors.toList()));
+    } catch (RuntimeException e) {
+      if (jobId != null) {
+        cancelJobQuietly(metalake, jobId);
+      }
+      actionMetaService.deleteClaims(claims);
+      LOG.warn(
+          "Failed commit-job submit for table {} policies={}: {}",
+          tableId,
+          
spec.getOperations().stream().map(Operation::getPolicyId).collect(Collectors.toList()),
+          e.toString());
+    }
+  }
+
+  private void cancelJobQuietly(String metalake, long jobId) {
+    try {
+      jobSubmitter.cancelJob(metalake, jobId);
+    } catch (RuntimeException cancelError) {
+      LOG.warn(
+          "Failed to cancel orphaned commit Spark job {} under metalake {} 
after bind failure",
+          jobId,
+          metalake,
+          cancelError);
+    }
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/RelationalCommitJobScheduler.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/RelationalCommitJobScheduler.java
new file mode 100644
index 0000000000..4eb5524f88
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/scheduler/job/commit/RelationalCommitJobScheduler.java
@@ -0,0 +1,202 @@
+/*
+ * 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.commit;
+
+import com.github.kagkarlsson.scheduler.SchedulerClient;
+import com.github.kagkarlsson.scheduler.serializer.JacksonSerializer;
+import com.github.kagkarlsson.scheduler.task.TaskInstance;
+import com.github.kagkarlsson.scheduler.task.TaskInstanceId;
+import com.google.common.base.Preconditions;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+import javax.sql.DataSource;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.json.JsonUtils;
+import org.apache.gravitino.maintenance.TableMaintenanceConfig;
+import org.apache.gravitino.maintenance.occupancy.OccupancyGate;
+import org.apache.gravitino.maintenance.policy.MaintenanceMinIntervalConfigs;
+import org.apache.gravitino.maintenance.scheduler.AutocommitDataSources;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec;
+import 
org.apache.gravitino.maintenance.scheduler.TableMaintenanceSchedulerPools;
+import 
org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService;
+import org.apache.gravitino.policy.ExecutablePolicyContent;
+import org.apache.gravitino.policy.PolicyContent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Schedules {@code commit-job} rows through a db-scheduler {@link 
SchedulerClient} on the
+ * entity-store DataSource.
+ *
+ * <p>Enqueue collects the onCommit chain, drops members still inside {@code 
minIntervalMs}, checks
+ * auth consistency, builds a {@link MaintenanceJobSpec}, and inserts only 
when the spec is
+ * non-empty. Coalesce leaves an existing row's {@code task_data} untouched.
+ */
+public final class RelationalCommitJobScheduler implements CommitJobScheduler {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(RelationalCommitJobScheduler.class);
+
+  private final DataSource dataSource;
+  private final Config gravitinoConfig;
+  private final Function<Long, Optional<CollectedCommitChain>> chainCollector;
+  private final CommitOccupancyGate occupancyGate;
+  private final CommitJobSpecFactory specFactory;
+
+  /**
+   * @param dataSource relational JDBC pool shared with the entity store
+   * @param gravitinoConfig server config for auth overlay and minInterval 
fallbacks
+   */
+  public RelationalCommitJobScheduler(DataSource dataSource, Config 
gravitinoConfig) {
+    this(
+        dataSource,
+        gravitinoConfig,
+        new CommitChainCollector()::collect,
+        CommitOccupancyGate.of(
+            new OccupancyGate(
+                TableMaintenanceActionMetaService.getInstance(),
+                
TableMaintenanceConfig.from(gravitinoConfig).claimTimeoutMs())));
+  }
+
+  /**
+   * Visible for tests.
+   *
+   * @param dataSource JDBC pool
+   * @param gravitinoConfig server config
+   * @param chainCollector returns the collected onCommit chain for a table id
+   * @param occupancyGate cooldown / in-flight checks for enqueue gating
+   */
+  RelationalCommitJobScheduler(
+      DataSource dataSource,
+      Config gravitinoConfig,
+      Function<Long, Optional<CollectedCommitChain>> chainCollector,
+      CommitOccupancyGate occupancyGate) {
+    this.dataSource = Preconditions.checkNotNull(dataSource);
+    this.gravitinoConfig = Preconditions.checkNotNull(gravitinoConfig);
+    this.chainCollector = Preconditions.checkNotNull(chainCollector);
+    this.occupancyGate = Preconditions.checkNotNull(occupancyGate);
+    this.specFactory = new CommitJobSpecFactory(gravitinoConfig);
+  }
+
+  @Override
+  public CommitJobUpsertResult upsertDueNow(long tableId) {
+    Preconditions.checkArgument(tableId > 0, "tableId must be positive");
+    SchedulerClient client = client(dataSource);
+    String id = Long.toString(tableId);
+    // Coalesce without rebuilding task_data: keep the first enqueue-time spec.
+    if (client
+        
.getScheduledExecution(TaskInstanceId.of(TableMaintenanceSchedulerPools.COMMIT_JOB,
 id))
+        .isPresent()) {
+      return CommitJobUpsertResult.ALREADY_QUEUED;
+    }
+    Optional<MaintenanceJobSpec> spec = buildSpec(tableId);
+    if (spec.isEmpty()) {
+      return CommitJobUpsertResult.SKIPPED;
+    }
+    boolean inserted =
+        client.scheduleIfNotExists(
+            new TaskInstance<>(TableMaintenanceSchedulerPools.COMMIT_JOB, id, 
spec.get()),
+            Instant.now());
+    return inserted ? CommitJobUpsertResult.INSERTED : 
CommitJobUpsertResult.ALREADY_QUEUED;
+  }
+
+  /**
+   * Builds a SchedulerClient that can commit against the entity-store 
DataSource.
+   *
+   * @param dataSource entity-store pool
+   * @return client bound to {@link 
TableMaintenanceSchedulerPools#COMMIT_TASKS_TABLE}
+   */
+  static SchedulerClient client(DataSource dataSource) {
+    return SchedulerClient.Builder.create(
+            AutocommitDataSources.wrap(dataSource), 
TableMaintenanceSchedulerPools.commitJobTask())
+        .serializer(new JacksonSerializer(JsonUtils.anyFieldMapper()))
+        .tableName(TableMaintenanceSchedulerPools.COMMIT_TASKS_TABLE)
+        .build();
+  }
+
+  private Optional<MaintenanceJobSpec> buildSpec(long tableId) {
+    Optional<CollectedCommitChain> collected = chainCollector.apply(tableId);
+    if (collected.isEmpty()) {
+      return Optional.empty();
+    }
+    List<CommitChainMember> members = collected.get().members();
+    if (members.isEmpty()) {
+      LOG.debug("Skipping commit-job enqueue for table {}: empty onCommit 
chain", tableId);
+      return Optional.empty();
+    }
+
+    long nowMs = System.currentTimeMillis();
+    List<CommitChainMember> chain = filterDueToMinInterval(tableId, members, 
nowMs);
+    if (chain.isEmpty()) {
+      LOG.debug(
+          "Skipping commit-job enqueue for table {}: all onCommit types within 
minIntervalMs",
+          tableId);
+      return Optional.empty();
+    }
+
+    try {
+      CommitAuthConsistency.requireConsistent(chain, gravitinoConfig);
+    } catch (IllegalArgumentException e) {
+      LOG.error("Skipping commit-job enqueue for table {}: {}", tableId, 
e.getMessage());
+      return Optional.empty();
+    }
+
+    try {
+      return Optional.of(specFactory.forCommit(chain, 
collected.get().tableRef()));
+    } catch (RuntimeException e) {
+      LOG.warn(
+          "Skipping commit-job enqueue for table {}: failed to build jobConf 
spec: {}",
+          tableId,
+          e.toString());
+      return Optional.empty();
+    }
+  }
+
+  private List<CommitChainMember> filterDueToMinInterval(
+      long tableId, List<CommitChainMember> collected, long nowMs) {
+    List<CommitChainMember> due = new ArrayList<>();
+    for (CommitChainMember member : collected) {
+      long minIntervalMs =
+          MaintenanceMinIntervalConfigs.resolve(
+              gravitinoConfig, member.policyType(), 
policyMinIntervalMs(member.policy().content()));
+      if (occupancyGate.withinMinInterval(tableId, member.policy().id(), 
minIntervalMs, nowMs)) {
+        LOG.debug(
+            "Dropping type {} policy {} from commit-job enqueue for table {} 
(within minIntervalMs={})",
+            member.policyType(),
+            member.policy().id(),
+            tableId,
+            minIntervalMs);
+        continue;
+      }
+      due.add(member);
+    }
+    return due;
+  }
+
+  @Nullable
+  private static Long policyMinIntervalMs(@Nullable PolicyContent content) {
+    if (!(content instanceof ExecutablePolicyContent)) {
+      return null;
+    }
+    return ((ExecutablePolicyContent) content).executionSpec().minIntervalMs();
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java
index 0453d0cd0a..522c4dbcc2 100644
--- 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java
@@ -37,6 +37,9 @@ public interface TableMaintenanceActionMapper {
   @Options(useGeneratedKeys = true, keyProperty = "actionMeta.id", keyColumn = 
"id")
   void insertAction(@Param("actionMeta") TableMaintenanceActionPO actionMeta);
 
+  @InsertProvider(type = TableMaintenanceActionSQLProviderFactory.class, 
method = "insertActions")
+  void insertActions(@Param("tableId") long tableId, @Param("policyIds") 
List<Long> policyIds);
+
   @UpdateProvider(
       type = TableMaintenanceActionSQLProviderFactory.class,
       method = "reclaimExpiredUnbound")
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java
index f4a0aaf3fe..8a9818b84e 100644
--- 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.maintenance.storage.mapper;
 
 import com.google.common.collect.ImmutableMap;
+import java.util.List;
 import java.util.Map;
 import 
org.apache.gravitino.maintenance.storage.mapper.provider.base.TableMaintenanceActionBaseSQLProvider;
 import 
org.apache.gravitino.maintenance.storage.mapper.provider.h2.TableMaintenanceActionH2Provider;
@@ -56,6 +57,16 @@ public class TableMaintenanceActionSQLProviderFactory {
     return getProvider().insertAction(actionMeta);
   }
 
+  /**
+   * @param tableId table entity id
+   * @param policyIds policy entity ids
+   * @return multi-row INSERT statement
+   */
+  public static String insertActions(
+      @Param("tableId") long tableId, @Param("policyIds") List<Long> 
policyIds) {
+    return getProvider().insertActions(tableId, policyIds);
+  }
+
   /**
    * @param tableId table entity id
    * @param policyId policy entity id
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java
index e29ca6c454..5da601c0e7 100644
--- 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java
@@ -20,6 +20,7 @@ package 
org.apache.gravitino.maintenance.storage.mapper.provider.base;
 
 import static 
org.apache.gravitino.maintenance.storage.mapper.TableMaintenanceActionMapper.TABLE_NAME;
 
+import java.util.List;
 import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
 import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
 import org.apache.ibatis.annotations.Param;
@@ -51,6 +52,29 @@ public class TableMaintenanceActionBaseSQLProvider {
         + ", 0)";
   }
 
+  /**
+   * Inserts unbound occupancy claims for a commit chain in one statement. 
Each {@code (table_id,
+   * policy_id, finished_at=0)} must be free or the unique index rejects the 
whole batch.
+   *
+   * @param tableId table entity id (shared by all chain members)
+   * @param policyIds policy entity ids in chain order
+   * @return multi-row INSERT statement
+   */
+  public String insertActions(
+      @Param("tableId") long tableId, @Param("policyIds") List<Long> 
policyIds) {
+    String now = currentTimeMillisExpr();
+    return "<script>"
+        + "INSERT INTO "
+        + TABLE_NAME
+        + " (table_id, policy_id, created_at, finished_at) VALUES "
+        + "<foreach item='policyId' collection='policyIds' separator=','>"
+        + "(#{tableId}, #{policyId}, "
+        + now
+        + ", 0)"
+        + "</foreach>"
+        + "</script>";
+  }
+
   /**
    * Extends an expired unbound claim lease by CAS on {@code created_at} 
(ShedLock-style). Lease age
    * is evaluated against the database clock so every node shares one TTL 
timeline.
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java
index f73ebfd59c..4451b57cf9 100644
--- 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java
@@ -22,7 +22,9 @@ import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATI
 
 import com.google.common.base.Preconditions;
 import java.sql.SQLIntegrityConstraintViolationException;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Objects;
@@ -120,6 +122,78 @@ public class TableMaintenanceActionMetaService {
     return Optional.of(new OccupancyClaim(inserted.id(), 
inserted.createdAt()));
   }
 
+  /**
+   * Tries to acquire unbound soft slots for every entry in {@code policyIds} 
in one transaction
+   * (commit-chain submit).
+   *
+   * <p>Expired unbound rows are reclaimed first; remaining policies are 
inserted with a single
+   * multi-row {@code INSERT}. Any live slot or unique conflict aborts the 
transaction so the caller
+   * never holds a partial chain.
+   *
+   * @param tableId table entity id
+   * @param policyIds chain member policy ids (duplicates collapsed, order 
preserved)
+   * @param claimTimeoutMs unbound claim TTL
+   * @return one {@link OccupancyClaim} per distinct policy id, or empty when 
the chain cannot be
+   *     claimed atomically
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "tryAcquireTableMaintenanceActionBatch")
+  public Optional<List<OccupancyClaim>> tryAcquireInFlightBatch(
+      long tableId, List<Long> policyIds, long claimTimeoutMs) {
+    Preconditions.checkArgument(claimTimeoutMs > 0, "claimTimeoutMs must be 
positive");
+    Preconditions.checkArgument(
+        policyIds != null && !policyIds.isEmpty(), "policyIds must not be 
empty");
+    List<Long> distinct = new ArrayList<>(new LinkedHashSet<>(policyIds));
+    try {
+      return Optional.of(
+          SessionUtils.doWithCommitAndFetchResult(
+              TableMaintenanceActionMapper.class,
+              mapper -> acquireBatchInTransaction(mapper, tableId, distinct, 
claimTimeoutMs)));
+    } catch (SoftSlotBusyException e) {
+      return Optional.empty();
+    } catch (RuntimeException e) {
+      if (isUniqueViolation(e)) {
+        return Optional.empty();
+      }
+      throw e;
+    }
+  }
+
+  private static List<OccupancyClaim> acquireBatchInTransaction(
+      TableMaintenanceActionMapper mapper,
+      long tableId,
+      List<Long> policyIds,
+      long claimTimeoutMs) {
+    List<Long> toInsert = new ArrayList<>();
+    for (long policyId : policyIds) {
+      int reclaimed = mapper.reclaimExpiredUnbound(tableId, policyId, 
claimTimeoutMs);
+      if (reclaimed == 1) {
+        continue;
+      }
+      TableMaintenanceActionPO live =
+          mapper.selectLiveUnboundByTableAndPolicy(tableId, policyId, 
claimTimeoutMs);
+      if (live != null) {
+        throw new SoftSlotBusyException();
+      }
+      toInsert.add(policyId);
+    }
+    if (!toInsert.isEmpty()) {
+      mapper.insertActions(tableId, toInsert);
+    }
+    List<OccupancyClaim> claims = new ArrayList<>(policyIds.size());
+    for (long policyId : policyIds) {
+      TableMaintenanceActionPO unbound = 
mapper.selectUnboundByTableAndPolicy(tableId, policyId);
+      Preconditions.checkState(
+          unbound != null && unbound.id() != null && unbound.createdAt() != 
null,
+          "batch acquire lost unbound row for table %s policy %s",
+          tableId,
+          policyId);
+      claims.add(new OccupancyClaim(unbound.id(), unbound.createdAt()));
+    }
+    return claims;
+  }
+
   /**
    * Acquires an unbound claim using {@link 
TableMaintenanceConfig#claimTimeoutMs()} or fails.
    *
@@ -227,6 +301,61 @@ public class TableMaintenanceActionMetaService {
     }
   }
 
+  /**
+   * Binds the same Gravitino job run id to every claim from {@link 
#tryAcquireInFlightBatch} in one
+   * transaction (commit-chain submit).
+   *
+   * <p>Idempotent per claim when the row is already bound to {@code jobId} 
and still in-flight
+   * (Spark may lazy-bind before submit-side bind), matching {@link 
#bindJobId}.
+   *
+   * @param claims claims returned by batch acquire
+   * @param jobId Gravitino job run id
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "bindTableMaintenanceActionJobIds")
+  public void bindJobIds(List<OccupancyClaim> claims, long jobId) {
+    Preconditions.checkArgument(claims != null && !claims.isEmpty(), "claims 
must not be empty");
+    SessionUtils.doWithCommit(
+        TableMaintenanceActionMapper.class,
+        mapper -> {
+          for (OccupancyClaim claim : claims) {
+            Preconditions.checkNotNull(claim, "claim must not be null");
+            int updated = mapper.bindJobId(claim.id(), jobId, 
claim.createdAt());
+            if (updated == 1) {
+              continue;
+            }
+            TableMaintenanceActionPO current = mapper.selectById(claim.id());
+            Preconditions.checkState(
+                current != null
+                    && Objects.equals(current.jobId(), jobId)
+                    && current.finishedAt() != null
+                    && current.finishedAt() == 0L,
+                "expected to bind job_id on claim id=%s, updated=%s",
+                claim.id(),
+                updated);
+          }
+        });
+  }
+
+  /**
+   * Releases unbound claims after a failed submit. Each delete is fenced on 
{@code (id,
+   * created_at)} so a concurrent reclaim's live claim is not removed.
+   *
+   * @param claims claims to release
+   */
+  public void deleteClaims(List<OccupancyClaim> claims) {
+    if (claims == null || claims.isEmpty()) {
+      return;
+    }
+    for (OccupancyClaim claim : claims) {
+      if (claim == null) {
+        continue;
+      }
+      deleteUnboundClaim(claim);
+    }
+  }
+
   private static OccupancyClaim readUnboundClaim(long tableId, long policyId) {
     TableMaintenanceActionPO unbound =
         SessionUtils.getWithoutCommit(
@@ -534,4 +663,9 @@ public class TableMaintenanceActionMetaService {
     }
     return false;
   }
+
+  /** Thrown inside a batch-acquire transaction to roll back when any soft 
slot is busy. */
+  private static final class SoftSlotBusyException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+  }
 }
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/listener/TestPostCommitMaintenanceListener.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/listener/TestPostCommitMaintenanceListener.java
new file mode 100644
index 0000000000..e635ce66f7
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/listener/TestPostCommitMaintenanceListener.java
@@ -0,0 +1,114 @@
+/*
+ * 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.listener;
+
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Instant;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.listener.api.event.Event;
+import org.apache.gravitino.listener.api.event.OperationStatus;
+import org.apache.gravitino.listener.api.event.OperationType;
+import 
org.apache.gravitino.maintenance.scheduler.job.commit.CommitJobScheduler;
+import 
org.apache.gravitino.maintenance.scheduler.job.commit.CommitJobUpsertResult;
+import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestPostCommitMaintenanceListener {
+
+  @Test
+  void testExpectedEventClassNameConstant() {
+    Assertions.assertEquals(
+        "org.apache.gravitino.listener.api.event.IcebergUpdateTableEvent",
+        PostCommitMaintenanceListener.ICEBERG_UPDATE_TABLE_EVENT);
+  }
+
+  @Test
+  void testIgnoresUnrelatedEvents() {
+    PostCommitMaintenanceListener listener = new 
PostCommitMaintenanceListener();
+    CommitJobScheduler scheduler = mock(CommitJobScheduler.class);
+    listener.setCommitJobScheduler(scheduler);
+
+    listener.onPostEvent(new OtherEvent("u", NameIdentifier.of("ml", "c", "s", 
"t")));
+
+    verify(scheduler, never()).upsertDueNow(anyLong());
+  }
+
+  @Test
+  void testEnqueueUpsertsCommitJob() {
+    PostCommitMaintenanceListener listener = new 
PostCommitMaintenanceListener();
+    CommitJobScheduler scheduler = mock(CommitJobScheduler.class);
+    
when(scheduler.upsertDueNow(eq(42L))).thenReturn(CommitJobUpsertResult.INSERTED);
+    listener.setCommitJobScheduler(scheduler);
+    listener.setTableLookup(
+        ident ->
+            TableEntity.builder()
+                .withId(42L)
+                .withName("t")
+                .withNamespace(NamespaceUtil.ofTable("ml", "c", "s"))
+                .withAuditInfo(
+                    
AuditInfo.builder().withCreator("test").withCreateTime(Instant.EPOCH).build())
+                .build());
+
+    listener.enqueueCommitJob(NameIdentifier.of("ml", "c", "s", "t"));
+
+    verify(scheduler).upsertDueNow(42L);
+  }
+
+  @Test
+  void testEnqueueSkippedWhenSchedulerNotStarted() {
+    PostCommitMaintenanceListener listener = new 
PostCommitMaintenanceListener();
+    listener.setTableLookup(
+        ident ->
+            TableEntity.builder()
+                .withId(1L)
+                .withName("t")
+                .withNamespace(NamespaceUtil.ofTable("ml", "c", "s"))
+                .withAuditInfo(
+                    
AuditInfo.builder().withCreator("test").withCreateTime(Instant.EPOCH).build())
+                .build());
+
+    Assertions.assertDoesNotThrow(
+        () -> listener.enqueueCommitJob(NameIdentifier.of("ml", "c", "s", 
"t")));
+  }
+
+  private static final class OtherEvent extends Event {
+    OtherEvent(String user, NameIdentifier identifier) {
+      super(user, identifier);
+    }
+
+    @Override
+    public OperationType operationType() {
+      return OperationType.CREATE_TABLE;
+    }
+
+    @Override
+    public OperationStatus operationStatus() {
+      return OperationStatus.SUCCESS;
+    }
+  }
+}
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/commit/TestCommitJobSpecFactory.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/commit/TestCommitJobSpecFactory.java
new file mode 100644
index 0000000000..c6bb36e038
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/commit/TestCommitJobSpecFactory.java
@@ -0,0 +1,172 @@
+/*
+ * 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.commit;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+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.maintenance.scheduler.MaintenanceJobSpec;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobTemplates;
+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 TestCommitJobSpecFactory {
+
+  private static final String GRAVITINO_URI = "http://localhost:8090";;
+
+  @Test
+  void testForCommitMergesChainAndSetsMaintenanceTypes() {
+    CommitJobSpecFactory factory = new CommitJobSpecFactory(configWithUri());
+    CommitChainMember compaction =
+        new CommitChainMember(
+            Policy.BuiltInType.ICEBERG_COMPACTION, compaction(202L), 
"rewrite_data_files");
+    CommitChainMember expire =
+        new CommitChainMember(
+            Policy.BuiltInType.ICEBERG_SNAPSHOT_EXPIRATION, expire(303L), 
"expire_snapshots");
+    MaintenanceJobSpec spec =
+        factory.forCommit(
+            List.of(compaction, expire), new ResolvedTableRef(101L, "iceberg", 
"db.t"));
+
+    Assertions.assertTrue(spec.isValid());
+    Assertions.assertEquals(2, spec.getOperations().size());
+    Assertions.assertEquals(
+        "rewrite_data_files,expire_snapshots",
+        spec.getJobConf().get(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY));
+    Assertions.assertEquals(
+        "202,303", 
spec.getJobConf().get(MaintenanceJobTemplates.POLICY_IDS_KEY));
+    Assertions.assertFalse(spec.getJobConf().containsKey("tms_table_id"));
+    Assertions.assertFalse(spec.getJobConf().containsKey("tms_policy_id"));
+    Assertions.assertNull(spec.getJobConf().get("catalog_name"));
+    Assertions.assertNull(spec.getJobConf().get("table_identifier"));
+    Assertions.assertNull(spec.getJobConf().get("tables"));
+    Assertions.assertEquals(101L, spec.getTables().get(0).getTableId());
+    Assertions.assertEquals(202L, spec.getOperations().get(0).getPolicyId());
+    // Later chain members contribute procedure params the first policy does 
not set.
+    Assertions.assertEquals("7d", spec.getJobConf().get("older_than"));
+    Assertions.assertEquals("10", spec.getJobConf().get("retain_last"));
+    Assertions.assertEquals("4g", 
spec.getJobConf().get("spark_executor_memory"));
+    String rulesByType = 
spec.getJobConf().get(MaintenanceJobTemplates.RULES_KEY);
+    Assertions.assertNotNull(rulesByType);
+    Assertions.assertTrue(rulesByType.contains("\"rewrite_data_files\""));
+    Assertions.assertTrue(rulesByType.contains("\"expire_snapshots\""));
+    Assertions.assertTrue(rulesByType.contains("trigger-expr"));
+  }
+
+  @Test
+  void testMergeLaterMemberJobConfMergesSparkConfByKey() {
+    Map<String, String> into = new HashMap<>();
+    into.put("spark_conf", 
"{\"spark.executor.memory\":\"4g\",\"shared\":\"first\"}");
+    Map<String, String> from =
+        Map.of(
+            "spark_conf",
+            
"{\"spark.executor.cores\":\"2\",\"shared\":\"later\",\"spark.driver.memory\":\"2g\"}");
+
+    CommitJobSpecFactory.mergeLaterMemberJobConf(into, from);
+
+    Map<String, String> merged =
+        IcebergSparkConfigUtils.parseFlatJsonMap(into.get("spark_conf"), 
"spark_conf");
+    Assertions.assertEquals("4g", merged.get("spark.executor.memory"));
+    Assertions.assertEquals("first", merged.get("shared"));
+    Assertions.assertEquals("2", merged.get("spark.executor.cores"));
+    Assertions.assertEquals("2g", merged.get("spark.driver.memory"));
+  }
+
+  @Test
+  void testMergeLaterMemberJobConfAddsSparkConfWhenMissing() throws Exception {
+    Map<String, String> into = new HashMap<>();
+    into.put("older_than", "7d");
+    Map<String, String> from =
+        Map.of("spark_conf", 
JsonUtils.anyFieldMapper().writeValueAsString(Map.of("k", "v")));
+
+    CommitJobSpecFactory.mergeLaterMemberJobConf(into, from);
+
+    Assertions.assertEquals("7d", into.get("older_than"));
+    Assertions.assertEquals(
+        "v",
+        IcebergSparkConfigUtils.parseFlatJsonMap(into.get("spark_conf"), 
"spark_conf").get("k"));
+  }
+
+  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();
+  }
+
+  private static PolicyEntity expire(long id) {
+    return PolicyEntity.builder()
+        .withId(id)
+        .withName("e-" + id)
+        .withNamespace(NamespaceUtil.ofPolicy("ml"))
+        .withPolicyType(Policy.BuiltInType.ICEBERG_SNAPSHOT_EXPIRATION)
+        .withEnabled(true)
+        .withContent(
+            PolicyContents.icebergSnapshotExpiration(
+                null,
+                true,
+                null,
+                Map.of("older_than", "7d", "retain_last", "10", 
"spark_executor_memory", "2g"),
+                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/commit/TestMaintenanceCommitSubmitPipeline.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/commit/TestMaintenanceCommitSubmitPipeline.java
new file mode 100644
index 0000000000..296e129d7c
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/commit/TestMaintenanceCommitSubmitPipeline.java
@@ -0,0 +1,185 @@
+/*
+ * 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.commit;
+
+import static org.mockito.ArgumentMatchers.anyList;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec;
+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.MaintenanceJobSubmitter;
+import 
org.apache.gravitino.maintenance.scheduler.job.cron.MaintenanceJobTemplates;
+import org.apache.gravitino.maintenance.storage.service.OccupancyClaim;
+import 
org.apache.gravitino.maintenance.storage.service.TableMaintenanceActionMetaService;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+class TestMaintenanceCommitSubmitPipeline {
+
+  private static final long TABLE_ID = 101L;
+  private static final long POLICY_ID = 202L;
+  private static final long EXPIRE_POLICY_ID = 303L;
+  private static final long CLAIM_TIMEOUT_MS = 60_000L;
+
+  @Test
+  void testUnparseableInstanceSkipsSubmit() {
+    MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class);
+    TableMaintenanceActionMetaService actions = 
mock(TableMaintenanceActionMetaService.class);
+    pipeline(allowAllGate(), actions, submitter).submit("not-a-table-id", 
spec());
+    verify(submitter, never()).runJob(anyString(), anyString(), anyMap());
+  }
+
+  @Test
+  void testNullSpecSkipsSubmit() {
+    MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class);
+    TableMaintenanceActionMetaService actions = 
mock(TableMaintenanceActionMetaService.class);
+    pipeline(allowAllGate(), actions, 
submitter).submit(Long.toString(TABLE_ID), null);
+    verify(submitter, never()).runJob(anyString(), anyString(), anyMap());
+    verify(actions, never()).tryAcquireInFlightBatch(anyLong(), anyList(), 
anyLong());
+  }
+
+  @Test
+  void testInFlightRemainingMemberSkipsSubmit() {
+    MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class);
+    TableMaintenanceActionMetaService actions = 
mock(TableMaintenanceActionMetaService.class);
+    pipeline(inFlightGate(POLICY_ID), actions, 
submitter).submit(Long.toString(TABLE_ID), spec());
+    verify(actions, never()).tryAcquireInFlightBatch(anyLong(), anyList(), 
anyLong());
+    verify(submitter, never()).runJob(anyString(), anyString(), anyMap());
+  }
+
+  @Test
+  void testSubmitClaimsEveryChainPolicyAndUsesSpecJobConf() {
+    MaintenanceJobSubmitter submitter = mock(MaintenanceJobSubmitter.class);
+    when(submitter.runJob(anyString(), anyString(), 
anyMap())).thenReturn(999L);
+    TableMaintenanceActionMetaService actions = 
mock(TableMaintenanceActionMetaService.class);
+    List<OccupancyClaim> claims = List.of(new OccupancyClaim(1L, 2L), new 
OccupancyClaim(3L, 4L));
+    when(actions.tryAcquireInFlightBatch(
+            eq(TABLE_ID), eq(List.of(POLICY_ID, EXPIRE_POLICY_ID)), 
eq(CLAIM_TIMEOUT_MS)))
+        .thenReturn(Optional.of(claims));
+
+    Map<String, String> jobConf = new LinkedHashMap<>();
+    jobConf.put(
+        MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY, 
"rewrite_data_files,expire_snapshots");
+    jobConf.put("updater_options", 
"{\"gravitino_uri\":\"http://localhost:8090\"}";);
+
+    MaintenanceJobSpec spec =
+        new MaintenanceJobSpec(
+            List.of(new TableTarget(TABLE_ID, List.of("ml", "iceberg", "db", 
"t"))),
+            List.of(
+                new Operation(POLICY_ID, "rewrite_data_files"),
+                new Operation(EXPIRE_POLICY_ID, "expire_snapshots")),
+            jobConf);
+
+    pipeline(allowAllGate(), actions, 
submitter).submit(Long.toString(TABLE_ID), spec);
+
+    @SuppressWarnings("unchecked")
+    ArgumentCaptor<Map<String, String>> confCaptor = 
ArgumentCaptor.forClass(Map.class);
+    verify(submitter)
+        .runJob(
+            eq("ml"),
+            eq(MaintenanceJobTemplates.TMS_ICEBERG_MAINTENANCE_TEMPLATE),
+            confCaptor.capture());
+    verify(actions).bindJobIds(claims, 999L);
+    verify(actions, never()).bindJobId(org.mockito.ArgumentMatchers.any(), 
anyLong());
+
+    Map<String, String> conf = confCaptor.getValue();
+    Assertions.assertEquals(
+        "rewrite_data_files,expire_snapshots",
+        conf.get(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY));
+    Assertions.assertFalse(conf.containsKey("tms_table_id"));
+    Assertions.assertFalse(conf.containsKey("tms_policy_id"));
+    Assertions.assertEquals("iceberg", conf.get("catalog_name"));
+    Assertions.assertNull(conf.get("table_identifier"));
+    
Assertions.assertTrue(conf.get("tables").contains("\"table_identifier\":\"db.t\""));
+    Assertions.assertTrue(conf.get("tables").contains("\"table_id\":" + 
TABLE_ID));
+  }
+
+  private static MaintenanceCommitSubmitPipeline pipeline(
+      CommitOccupancyGate gate,
+      TableMaintenanceActionMetaService actions,
+      MaintenanceJobSubmitter submitter) {
+    return new MaintenanceCommitSubmitPipeline(
+        gate,
+        actions,
+        submitter,
+        new MaintenanceJobConfBuilder(new Config(false) {}),
+        CLAIM_TIMEOUT_MS);
+  }
+
+  private static CommitOccupancyGate allowAllGate() {
+    return new CommitOccupancyGate() {
+      @Override
+      public boolean isInFlight(long tableId, long policyId) {
+        return false;
+      }
+
+      @Override
+      public boolean withinMinInterval(
+          long tableId, long policyId, long minIntervalMs, long nowMs) {
+        return false;
+      }
+    };
+  }
+
+  private static CommitOccupancyGate inFlightGate(long... policyIds) {
+    Set<Long> blocked = new HashSet<>();
+    for (long id : policyIds) {
+      blocked.add(id);
+    }
+    return new CommitOccupancyGate() {
+      @Override
+      public boolean isInFlight(long tableId, long policyId) {
+        return blocked.contains(policyId);
+      }
+
+      @Override
+      public boolean withinMinInterval(
+          long tableId, long policyId, long minIntervalMs, long nowMs) {
+        return false;
+      }
+    };
+  }
+
+  private static MaintenanceJobSpec spec() {
+    Map<String, String> jobConf = new LinkedHashMap<>();
+    jobConf.put(MaintenanceJobTemplates.MAINTENANCE_TYPES_KEY, 
"rewrite_data_files");
+    jobConf.put("updater_options", 
"{\"gravitino_uri\":\"http://localhost:8090\"}";);
+    return new MaintenanceJobSpec(
+        List.of(new TableTarget(TABLE_ID, List.of("ml", "iceberg", "db", 
"t"))),
+        List.of(new Operation(POLICY_ID, "rewrite_data_files")),
+        jobConf);
+  }
+}
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/commit/TestRelationalCommitJobScheduler.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/commit/TestRelationalCommitJobScheduler.java
new file mode 100644
index 0000000000..6c6797c898
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/scheduler/job/commit/TestRelationalCommitJobScheduler.java
@@ -0,0 +1,190 @@
+/*
+ * 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.commit;
+
+import com.github.kagkarlsson.scheduler.SchedulerClient;
+import com.github.kagkarlsson.scheduler.task.TaskInstanceId;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Function;
+import javax.sql.DataSource;
+import org.apache.gravitino.Config;
+import org.apache.gravitino.maintenance.policy.MaintenanceAuthConfigs;
+import org.apache.gravitino.maintenance.scheduler.MaintenanceJobSpec;
+import 
org.apache.gravitino.maintenance.scheduler.TableMaintenanceSchedulerPools;
+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.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.gravitino.utils.NamespaceUtil;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+
+class TestRelationalCommitJobScheduler extends TestJDBCBackend {
+
+  private static final long TABLE_ID = 101L;
+  private static final long POLICY_ID = 202L;
+  private static final String GRAVITINO_URI = "http://localhost:8090";;
+
+  @TestTemplate
+  void testEnqueueSkipsWhenAllWithinMinInterval() {
+    RelationalCommitJobScheduler scheduler =
+        new RelationalCommitJobScheduler(
+            dataSource(),
+            configWithUri(),
+            fixedCollector(List.of(member(POLICY_ID))),
+            coolingGate(POLICY_ID));
+    Assertions.assertEquals(CommitJobUpsertResult.SKIPPED, 
scheduler.upsertDueNow(TABLE_ID));
+    Assertions.assertTrue(
+        RelationalCommitJobScheduler.client(dataSource())
+            .getScheduledExecution(
+                TaskInstanceId.of(
+                    TableMaintenanceSchedulerPools.COMMIT_JOB, 
Long.toString(TABLE_ID)))
+            .isEmpty());
+  }
+
+  @TestTemplate
+  void testEnqueueWritesSpecAndIsIdempotent() {
+    RelationalCommitJobScheduler scheduler =
+        new RelationalCommitJobScheduler(
+            dataSource(),
+            configWithUri(),
+            fixedCollector(List.of(member(POLICY_ID))),
+            allowAllGate());
+
+    Assertions.assertEquals(CommitJobUpsertResult.INSERTED, 
scheduler.upsertDueNow(TABLE_ID));
+    Assertions.assertEquals(CommitJobUpsertResult.ALREADY_QUEUED, 
scheduler.upsertDueNow(TABLE_ID));
+
+    SchedulerClient client = RelationalCommitJobScheduler.client(dataSource());
+    MaintenanceJobSpec spec =
+        (MaintenanceJobSpec)
+            client
+                .getScheduledExecution(
+                    TaskInstanceId.of(
+                        TableMaintenanceSchedulerPools.COMMIT_JOB, 
Long.toString(TABLE_ID)))
+                .get()
+                .getData();
+    Assertions.assertTrue(spec.isValid());
+    Assertions.assertEquals(List.of(TABLE_ID), spec.tableIds());
+    Assertions.assertEquals(POLICY_ID, 
spec.getOperations().get(0).getPolicyId());
+    Assertions.assertTrue(spec.getJobConf().containsKey("updater_options"));
+    Assertions.assertNull(spec.getJobConf().get("catalog_name"));
+    Assertions.assertNull(spec.getJobConf().get("table_identifier"));
+    Assertions.assertEquals("iceberg", spec.getTables().get(0).catalogName());
+  }
+
+  private static Function<Long, Optional<CollectedCommitChain>> fixedCollector(
+      List<CommitChainMember> chain) {
+    ResolvedTableRef ref = new ResolvedTableRef(TABLE_ID, "iceberg", "db.t");
+    CollectedCommitChain collected = new CollectedCommitChain(chain, ref);
+    return tableId -> tableId == TABLE_ID ? Optional.of(collected) : 
Optional.empty();
+  }
+
+  private static CommitChainMember member(long policyId) {
+    return new CommitChainMember(
+        Policy.BuiltInType.ICEBERG_COMPACTION, policy(policyId), 
"rewrite_data_files");
+  }
+
+  private static CommitOccupancyGate allowAllGate() {
+    return new CommitOccupancyGate() {
+      @Override
+      public boolean isInFlight(long tableId, long policyId) {
+        return false;
+      }
+
+      @Override
+      public boolean withinMinInterval(
+          long tableId, long policyId, long minIntervalMs, long nowMs) {
+        return false;
+      }
+    };
+  }
+
+  private static CommitOccupancyGate coolingGate(long... policyIds) {
+    Set<Long> cooling = new HashSet<>();
+    for (long id : policyIds) {
+      cooling.add(id);
+    }
+    return new CommitOccupancyGate() {
+      @Override
+      public boolean isInFlight(long tableId, long policyId) {
+        return false;
+      }
+
+      @Override
+      public boolean withinMinInterval(
+          long tableId, long policyId, long minIntervalMs, long nowMs) {
+        return cooling.contains(policyId);
+      }
+    };
+  }
+
+  private static DataSource dataSource() {
+    return SqlSessionFactoryHelper.getInstance()
+        .getSqlSessionFactory()
+        .getConfiguration()
+        .getEnvironment()
+        .getDataSource();
+  }
+
+  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 policy(long id) {
+    return PolicyEntity.builder()
+        .withId(id)
+        .withName("p-" + id)
+        .withNamespace(NamespaceUtil.ofPolicy("ml"))
+        .withPolicyType(Policy.BuiltInType.ICEBERG_COMPACTION)
+        .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("gravitino_uri", GRAVITINO_URI)))
+        .withEnabled(true)
+        .withAuditInfo(
+            
AuditInfo.builder().withCreator("test").withCreateTime(Instant.EPOCH).build())
+        .build();
+  }
+}
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java
index c5c694ae7b..515b59b6bb 100644
--- 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.maintenance.storage.service;
 
 import static 
org.apache.gravitino.maintenance.storage.TableMaintenanceActionTestUtils.expireClaim;
 
+import java.util.List;
 import java.util.Optional;
 import org.apache.gravitino.maintenance.TableMaintenanceConfig;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
@@ -42,6 +43,7 @@ class TestTableMaintenanceActionMetaService extends 
TestJDBCBackend {
     long after = System.currentTimeMillis();
     Assertions.assertNull(actionService.getById(claim.id()).jobId());
     Assertions.assertEquals(0L, 
actionService.getById(claim.id()).finishedAt());
+    // DB clock may skew slightly from the JVM; allow a small window.
     Assertions.assertTrue(claim.createdAt() >= before - 5_000L);
     Assertions.assertTrue(claim.createdAt() <= after + 5_000L);
     Assertions.assertEquals(claim.createdAt(), 
actionService.getById(claim.id()).createdAt());
@@ -153,6 +155,24 @@ class TestTableMaintenanceActionMetaService extends 
TestJDBCBackend {
     Assertions.assertEquals(jobId, actions.getByJobIdAndTableId(jobId, 
tableId).jobId());
   }
 
+  @TestTemplate
+  void testBindJobIdsIdempotentWhenAlreadyBoundToSameJob() {
+    TableMaintenanceActionMetaService actions = 
TableMaintenanceActionMetaService.getInstance();
+    TableMaintenanceConfig config = TableMaintenanceConfig.forTest(1, 1, 1, 
500L, 1_000L, 4, 10);
+    long tableId = 37L;
+    long policyA = 48L;
+    long policyB = 49L;
+    long jobId = 5700L;
+    OccupancyClaim claimA = actions.insertInFlight(tableId, policyA, config);
+    OccupancyClaim claimB = actions.insertInFlight(tableId, policyB, config);
+    actions.bindJobId(claimA, jobId);
+    actions.bindJobId(claimB, jobId);
+    // Spark may lazy-bind before commit-chain submit-side bindJobIds.
+    actions.bindJobIds(List.of(claimA, claimB), jobId);
+    Assertions.assertEquals(jobId, actions.getById(claimA.id()).jobId());
+    Assertions.assertEquals(jobId, actions.getById(claimB.id()).jobId());
+  }
+
   @TestTemplate
   void testBindJobIdFailsWhenBoundToDifferentJob() {
     TableMaintenanceActionMetaService actions = 
TableMaintenanceActionMetaService.getInstance();
@@ -265,7 +285,7 @@ class TestTableMaintenanceActionMetaService extends 
TestJDBCBackend {
     TableMaintenanceActionMetaService actions = 
TableMaintenanceActionMetaService.getInstance();
     long tableId = 12L;
     long policyId = 22L;
-    long timeoutMs = 200L;
+    long timeoutMs = TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS;
 
     Optional<OccupancyClaim> first = actions.tryAcquireInFlight(tableId, 
policyId, timeoutMs);
     Assertions.assertTrue(first.isPresent());
@@ -274,11 +294,14 @@ class TestTableMaintenanceActionMetaService extends 
TestJDBCBackend {
 
     Optional<OccupancyClaim> second = actions.tryAcquireInFlight(tableId, 
policyId, timeoutMs);
     Assertions.assertTrue(second.isPresent());
-    Thread.sleep(timeoutMs + 50L);
+    OccupancyClaim expired = expireClaim(second.get(), timeoutMs);
+    // Let the DB clock advance so reclaim cannot reuse the original fencing 
token.
+    Thread.sleep(2L);
     Optional<OccupancyClaim> reclaimed = actions.tryAcquireInFlight(tableId, 
policyId, timeoutMs);
     Assertions.assertTrue(reclaimed.isPresent());
     Assertions.assertEquals(second.get().id(), reclaimed.get().id());
-    Assertions.assertTrue(reclaimed.get().createdAt() >= 
second.get().createdAt());
+    Assertions.assertTrue(reclaimed.get().createdAt() > expired.createdAt());
+    Assertions.assertNotEquals(second.get().createdAt(), 
reclaimed.get().createdAt());
 
     // Stale submitter cleanup must not delete the reclaimed live claim.
     Assertions.assertFalse(actions.deleteUnboundClaim(second.get()));
@@ -289,4 +312,63 @@ class TestTableMaintenanceActionMetaService extends 
TestJDBCBackend {
     Assertions.assertTrue(actions.deleteUnboundClaim(reclaimed.get()));
     Assertions.assertNull(actions.getById(reclaimed.get().id()));
   }
+
+  @TestTemplate
+  void testDeleteClaimsFencesAgainstReclaim() throws Exception {
+    TableMaintenanceActionMetaService actions = 
TableMaintenanceActionMetaService.getInstance();
+    long tableId = 38L;
+    long policyId = 50L;
+    long timeoutMs = TableMaintenanceConfig.DEFAULT_CLAIM_TIMEOUT_MS;
+
+    Optional<OccupancyClaim> first = actions.tryAcquireInFlight(tableId, 
policyId, timeoutMs);
+    Assertions.assertTrue(first.isPresent());
+    OccupancyClaim expired = expireClaim(first.get(), timeoutMs);
+    // Let the DB clock advance so reclaim cannot reuse the original fencing 
token.
+    Thread.sleep(2L);
+    Optional<OccupancyClaim> reclaimed = actions.tryAcquireInFlight(tableId, 
policyId, timeoutMs);
+    Assertions.assertTrue(reclaimed.isPresent());
+    Assertions.assertEquals(first.get().id(), reclaimed.get().id());
+    Assertions.assertTrue(reclaimed.get().createdAt() > expired.createdAt());
+
+    // Failed submit cleanup uses deleteClaims; stale created_at must not 
remove live claim.
+    actions.deleteClaims(List.of(first.get()));
+    Assertions.assertNotNull(actions.getById(reclaimed.get().id()));
+    Assertions.assertNull(actions.getById(reclaimed.get().id()).jobId());
+    Assertions.assertEquals(0L, 
actions.getById(reclaimed.get().id()).finishedAt());
+
+    actions.deleteClaims(List.of(reclaimed.get()));
+    Assertions.assertNull(actions.getById(reclaimed.get().id()));
+  }
+
+  @TestTemplate
+  void testTryAcquireBatchClaimsAllPoliciesOrNone() {
+    TableMaintenanceActionMetaService actions = 
TableMaintenanceActionMetaService.getInstance();
+    long tableId = 31L;
+    long policyA = 41L;
+    long policyB = 42L;
+    long timeoutMs = 60_000L;
+
+    Optional<List<OccupancyClaim>> batch =
+        actions.tryAcquireInFlightBatch(tableId, List.of(policyA, policyB), 
timeoutMs);
+    Assertions.assertTrue(batch.isPresent());
+    Assertions.assertEquals(2, batch.get().size());
+    Assertions.assertEquals(0L, 
actions.getById(batch.get().get(0).id()).finishedAt());
+    Assertions.assertEquals(0L, 
actions.getById(batch.get().get(1).id()).finishedAt());
+
+    // Partial hold blocks the whole chain.
+    Assertions.assertTrue(
+        actions.tryAcquireInFlightBatch(tableId, List.of(policyA, policyB), 
timeoutMs).isEmpty());
+    Assertions.assertTrue(actions.tryAcquireInFlight(tableId, policyA, 
timeoutMs).isEmpty());
+
+    long jobId = 3100L;
+    actions.bindJobIds(batch.get(), jobId);
+    Assertions.assertEquals(jobId, 
actions.getById(batch.get().get(0).id()).jobId());
+    Assertions.assertEquals(jobId, 
actions.getById(batch.get().get(1).id()).jobId());
+
+    long finishedAt = System.currentTimeMillis();
+    actions.markFinished(jobId, tableId, policyA, 500L, finishedAt);
+    actions.markFinished(jobId, tableId, policyB, 501L, finishedAt + 1);
+    
Assertions.assertTrue(actions.getById(batch.get().get(0).id()).finishedAt() > 
0);
+    
Assertions.assertTrue(actions.getById(batch.get().get(1).id()).finishedAt() > 
0);
+  }
 }

Reply via email to