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


The following commit(s) were added to refs/heads/main by this push:
     new 4f11ac17b8 [#13761] feat(tms): plugins/tms skeleton, DDL, and DAOs 
(#13762)
4f11ac17b8 is described below

commit 4f11ac17b84134a766fcf3ed0396ac7a04302d11
Author: MaSai <[email protected]>
AuthorDate: Tue Oct 13 09:55:44 2026 +0800

    [#13761] feat(tms): plugins/tms skeleton, DDL, and DAOs (#13762)
    
    ### What changes were proposed in this pull request?
    
    - Add `plugins/tms` module (same packaging pattern as `idp-basic`, not
    in `core`)
    - DDL for `table_maintenance_action` and `table_snapshot_metrics` (MySQL
    / H2 / PostgreSQL), including 1.3.0→2.0.0 upgrade scripts
    - Relational DAOs (POs, MyBatis mappers / SQL providers, meta services)
    + `TmsMapperPackageProvider` SPI
    - Action lifecycle: insert in-flight occupancy → bind optional `job_id`
    after `runJob`; S0 / finish updates are one-shot; metrics insert is
    idempotent and not deleted with the action
    - Distribution `copyLibAndConfigs` and DAO unit tests
    
    **Out of scope:** db-scheduler task tables / pools, REST Feature
    lifecycle, CAS / occupancy gates, and in-job sample / drift / recommend
    (follow-up subtasks under #13760).
    
    ### Why are the changes needed?
    
    First subtask of Epic #13760: TMS persistence foundation for the
    in-process Table Maintenance Service.
    
    Fix: #13761
    
    ### Does this PR introduce _any_ user-facing change?
    
    New relational tables on fresh 2.0.0 installs. Plugin jar is optional
    until enabled by later Feature / scheduler work.
    
    ### How was this patch tested?
    
    ```bash
    ./gradlew spotlessApply :plugins:tms:test -PskipITs
    ```
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 build.gradle.kts                                   |   3 +-
 plugins/tms/build.gradle.kts                       |  76 +++++++++
 .../mapper/TableMaintenanceActionMapper.java       |  67 ++++++++
 .../TableMaintenanceActionSQLProviderFactory.java  | 131 +++++++++++++++
 .../storage/mapper/TableSnapshotMetricsMapper.java |  41 +++++
 .../TableSnapshotMetricsSQLProviderFactory.java    |  80 +++++++++
 .../mapper/provider/TmsMapperPackageProvider.java  |  34 ++++
 .../TableMaintenanceActionBaseSQLProvider.java     | 128 +++++++++++++++
 .../base/TableSnapshotMetricsBaseSQLProvider.java  |  56 +++++++
 .../h2/TableMaintenanceActionH2Provider.java       |  24 +++
 .../h2/TableSnapshotMetricsH2Provider.java         |  24 +++
 .../TableMaintenanceActionPostgreSQLProvider.java  |  25 +++
 .../TableSnapshotMetricsPostgreSQLProvider.java    |  24 +++
 .../storage/po/TableMaintenanceActionPO.java       |  48 ++++++
 .../storage/po/TableSnapshotMetricsPO.java         |  46 ++++++
 .../service/TableMaintenanceActionMetaService.java | 178 +++++++++++++++++++++
 .../service/TableSnapshotMetricsMetaService.java   |  87 ++++++++++
 ...elational.mapper.provider.MapperPackageProvider |  18 +++
 .../mapper/TestTmsMapperPackageProvider.java       |  51 ++++++
 .../TestTableMaintenanceActionMetaService.java     |  65 ++++++++
 .../TestTableSnapshotMetricsMetaService.java       |  46 ++++++
 scripts/h2/schema-2.0.0-h2.sql                     |  29 ++++
 scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql           |  29 ++++
 scripts/mysql/schema-2.0.0-mysql.sql               |  31 ++++
 scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql     |  31 ++++
 scripts/postgresql/schema-2.0.0-postgresql.sql     |  43 +++++
 .../upgrade-1.3.0-to-2.0.0-postgresql.sql          |  43 +++++
 settings.gradle.kts                                |   1 +
 28 files changed, 1458 insertions(+), 1 deletion(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index f6bf7855ea..0161f01f72 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1170,7 +1170,8 @@ tasks {
         ":iceberg:iceberg-rest-server:copyLibAndConfigs",
         ":lance:lance-rest-server:copyLibAndConfigs",
         ":maintenance:optimizer:copyLibAndConfigs",
-        ":plugins:idp-basic:copyLibAndConfigs"
+        ":plugins:idp-basic:copyLibAndConfigs",
+        ":plugins:tms:copyLibAndConfigs"
       )
     if (!skipWeb) {
       dependencies.add(":web:web:build")
diff --git a/plugins/tms/build.gradle.kts b/plugins/tms/build.gradle.kts
new file mode 100644
index 0000000000..1e6d92d9ad
--- /dev/null
+++ b/plugins/tms/build.gradle.kts
@@ -0,0 +1,76 @@
+/*
+ * 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.
+ */
+
+plugins {
+  `maven-publish`
+  id("java")
+  id("idea")
+}
+
+dependencies {
+  annotationProcessor(libs.lombok)
+
+  implementation(project(":api"))
+  implementation(project(":common"))
+  implementation(project(":core"))
+
+  implementation(libs.guava)
+  implementation(libs.mybatis)
+
+  compileOnly(libs.lombok)
+  compileOnly(libs.slf4j.api)
+
+  testImplementation(project(":clients:client-java"))
+  testImplementation(project(":core", "testArtifacts"))
+  testImplementation(project(":integration-test-common", "testArtifacts"))
+  testImplementation(project(":server"))
+  testImplementation(project(":server-common"))
+  testImplementation(libs.junit.jupiter.api)
+  testImplementation(libs.junit.jupiter.params)
+  testImplementation(libs.mockito.core)
+  testImplementation(libs.mysql.driver)
+  testImplementation(libs.postgresql.driver)
+  testImplementation(libs.testcontainers)
+  testImplementation(libs.testcontainers.mysql)
+  testImplementation(libs.testcontainers.postgresql)
+  testRuntimeOnly(libs.junit.jupiter.engine)
+}
+
+tasks {
+  val copyLibs by registering(Copy::class) {
+    dependsOn(jar)
+    from(layout.buildDirectory.dir("libs")) {
+      include("gravitino-tms-*.jar")
+      exclude("*-javadoc.jar", "*-sources.jar")
+    }
+    into("$rootDir/distribution/package/libs")
+    duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+  }
+
+  register("copyLibAndConfigs", Copy::class) {
+    group = "gravitino distribution"
+    description = "Copy tms plugin jar into distribution package libs"
+    dependsOn(copyLibs)
+  }
+
+  test {
+    useJUnitPlatform()
+    environment("GRAVITINO_HOME", rootDir.path)
+  }
+}
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
new file mode 100644
index 0000000000..ac14efa56a
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionMapper.java
@@ -0,0 +1,67 @@
+/*
+ * 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.storage.mapper;
+
+import javax.annotation.Nullable;
+import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
+import org.apache.ibatis.annotations.DeleteProvider;
+import org.apache.ibatis.annotations.InsertProvider;
+import org.apache.ibatis.annotations.Options;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.SelectProvider;
+import org.apache.ibatis.annotations.UpdateProvider;
+
+/** MyBatis mapper for {@code table_maintenance_action}. */
+public interface TableMaintenanceActionMapper {
+
+  String TABLE_NAME = "table_maintenance_action";
+
+  @InsertProvider(type = TableMaintenanceActionSQLProviderFactory.class, 
method = "insertAction")
+  @Options(useGeneratedKeys = true, keyProperty = "actionMeta.id", keyColumn = 
"id")
+  void insertAction(@Param("actionMeta") TableMaintenanceActionPO actionMeta);
+
+  @UpdateProvider(type = TableMaintenanceActionSQLProviderFactory.class, 
method = "bindJobId")
+  int bindJobId(@Param("id") long id, @Param("jobId") long jobId);
+
+  @Nullable
+  @SelectProvider(type = TableMaintenanceActionSQLProviderFactory.class, 
method = "selectById")
+  TableMaintenanceActionPO selectById(@Param("id") long id);
+
+  @Nullable
+  @SelectProvider(type = TableMaintenanceActionSQLProviderFactory.class, 
method = "selectByJobId")
+  TableMaintenanceActionPO selectByJobId(@Param("jobId") long jobId);
+
+  @UpdateProvider(
+      type = TableMaintenanceActionSQLProviderFactory.class,
+      method = "updateBeforeSnapshot")
+  int updateBeforeSnapshot(
+      @Param("jobId") long jobId, @Param("beforeSnapshotId") long 
beforeSnapshotId);
+
+  @UpdateProvider(type = TableMaintenanceActionSQLProviderFactory.class, 
method = "markFinished")
+  int markFinished(
+      @Param("jobId") long jobId,
+      @Param("afterSnapshotId") Long afterSnapshotId,
+      @Param("finishedAt") long finishedAt);
+
+  @DeleteProvider(type = TableMaintenanceActionSQLProviderFactory.class, 
method = "deleteById")
+  int deleteById(@Param("id") long id);
+
+  @DeleteProvider(type = TableMaintenanceActionSQLProviderFactory.class, 
method = "deleteByJobId")
+  int deleteByJobId(@Param("jobId") long jobId);
+}
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
new file mode 100644
index 0000000000..2bfe0b66ee
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableMaintenanceActionSQLProviderFactory.java
@@ -0,0 +1,131 @@
+/*
+ * 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.storage.mapper;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.base.TableMaintenanceActionBaseSQLProvider;
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.h2.TableMaintenanceActionH2Provider;
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.postgresql.TableMaintenanceActionPostgreSQLProvider;
+import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
+import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.annotations.Param;
+
+/** SQL provider factory for {@link TableMaintenanceActionMapper}. */
+public class TableMaintenanceActionSQLProviderFactory {
+
+  private static final TableMaintenanceActionBaseSQLProvider MYSQL_PROVIDER =
+      new TableMaintenanceActionBaseSQLProvider();
+  private static final TableMaintenanceActionBaseSQLProvider H2_PROVIDER =
+      new TableMaintenanceActionH2Provider();
+  private static final TableMaintenanceActionBaseSQLProvider 
POSTGRESQL_PROVIDER =
+      new TableMaintenanceActionPostgreSQLProvider();
+  private static final Map<JDBCBackendType, 
TableMaintenanceActionBaseSQLProvider> PROVIDER_MAP =
+      ImmutableMap.of(
+          JDBCBackendType.MYSQL,
+          MYSQL_PROVIDER,
+          JDBCBackendType.H2,
+          H2_PROVIDER,
+          JDBCBackendType.POSTGRESQL,
+          POSTGRESQL_PROVIDER);
+
+  private TableMaintenanceActionSQLProviderFactory() {}
+
+  /**
+   * @param actionMeta row to insert
+   * @return INSERT statement
+   */
+  public static String insertAction(@Param("actionMeta") 
TableMaintenanceActionPO actionMeta) {
+    return getProvider().insertAction(actionMeta);
+  }
+
+  /**
+   * @param id action id
+   * @param jobId job run id
+   * @return UPDATE statement
+   */
+  public static String bindJobId(@Param("id") long id, @Param("jobId") long 
jobId) {
+    return getProvider().bindJobId(id, jobId);
+  }
+
+  /**
+   * @param id action id
+   * @return SELECT statement
+   */
+  public static String selectById(@Param("id") long id) {
+    return getProvider().selectById(id);
+  }
+
+  /**
+   * @param jobId job run id
+   * @return SELECT statement
+   */
+  public static String selectByJobId(@Param("jobId") long jobId) {
+    return getProvider().selectByJobId(jobId);
+  }
+
+  /**
+   * @param jobId job run id
+   * @param beforeSnapshotId snapshot id
+   * @return UPDATE statement
+   */
+  public static String updateBeforeSnapshot(
+      @Param("jobId") long jobId, @Param("beforeSnapshotId") long 
beforeSnapshotId) {
+    return getProvider().updateBeforeSnapshot(jobId, beforeSnapshotId);
+  }
+
+  /**
+   * @param jobId job run id
+   * @param afterSnapshotId after snapshot
+   * @param finishedAt finish millis
+   * @return UPDATE statement
+   */
+  public static String markFinished(
+      @Param("jobId") long jobId,
+      @Param("afterSnapshotId") Long afterSnapshotId,
+      @Param("finishedAt") long finishedAt) {
+    return getProvider().markFinished(jobId, afterSnapshotId, finishedAt);
+  }
+
+  /**
+   * @param id action id
+   * @return DELETE statement
+   */
+  public static String deleteById(@Param("id") long id) {
+    return getProvider().deleteById(id);
+  }
+
+  /**
+   * @param jobId job run id
+   * @return DELETE statement
+   */
+  public static String deleteByJobId(@Param("jobId") long jobId) {
+    return getProvider().deleteByJobId(jobId);
+  }
+
+  static TableMaintenanceActionBaseSQLProvider getProvider() {
+    String databaseId =
+        SqlSessionFactoryHelper.getInstance()
+            .getSqlSessionFactory()
+            .getConfiguration()
+            .getDatabaseId();
+    return PROVIDER_MAP.get(JDBCBackendType.fromString(databaseId));
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableSnapshotMetricsMapper.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableSnapshotMetricsMapper.java
new file mode 100644
index 0000000000..d80303fc1f
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableSnapshotMetricsMapper.java
@@ -0,0 +1,41 @@
+/*
+ * 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.storage.mapper;
+
+import javax.annotation.Nullable;
+import org.apache.gravitino.maintenance.storage.po.TableSnapshotMetricsPO;
+import org.apache.ibatis.annotations.InsertProvider;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.SelectProvider;
+
+/** MyBatis mapper for {@code table_snapshot_metrics}. */
+public interface TableSnapshotMetricsMapper {
+
+  String TABLE_NAME = "table_snapshot_metrics";
+
+  @InsertProvider(type = TableSnapshotMetricsSQLProviderFactory.class, method 
= "insertMetrics")
+  void insertMetrics(@Param("metricsMeta") TableSnapshotMetricsPO metricsMeta);
+
+  @Nullable
+  @SelectProvider(type = TableSnapshotMetricsSQLProviderFactory.class, method 
= "selectMetrics")
+  TableSnapshotMetricsPO selectMetrics(
+      @Param("tableId") long tableId,
+      @Param("policyId") long policyId,
+      @Param("snapshotId") long snapshotId);
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableSnapshotMetricsSQLProviderFactory.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableSnapshotMetricsSQLProviderFactory.java
new file mode 100644
index 0000000000..1ab21cd27f
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/TableSnapshotMetricsSQLProviderFactory.java
@@ -0,0 +1,80 @@
+/*
+ * 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.storage.mapper;
+
+import com.google.common.collect.ImmutableMap;
+import java.util.Map;
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.base.TableSnapshotMetricsBaseSQLProvider;
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.h2.TableSnapshotMetricsH2Provider;
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.postgresql.TableSnapshotMetricsPostgreSQLProvider;
+import org.apache.gravitino.maintenance.storage.po.TableSnapshotMetricsPO;
+import org.apache.gravitino.storage.relational.JDBCBackend.JDBCBackendType;
+import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.ibatis.annotations.Param;
+
+/** SQL provider factory for {@link TableSnapshotMetricsMapper}. */
+public class TableSnapshotMetricsSQLProviderFactory {
+
+  private static final TableSnapshotMetricsBaseSQLProvider MYSQL_PROVIDER =
+      new TableSnapshotMetricsBaseSQLProvider();
+  private static final TableSnapshotMetricsBaseSQLProvider H2_PROVIDER =
+      new TableSnapshotMetricsH2Provider();
+  private static final TableSnapshotMetricsBaseSQLProvider POSTGRESQL_PROVIDER 
=
+      new TableSnapshotMetricsPostgreSQLProvider();
+  private static final Map<JDBCBackendType, 
TableSnapshotMetricsBaseSQLProvider> PROVIDER_MAP =
+      ImmutableMap.of(
+          JDBCBackendType.MYSQL,
+          MYSQL_PROVIDER,
+          JDBCBackendType.H2,
+          H2_PROVIDER,
+          JDBCBackendType.POSTGRESQL,
+          POSTGRESQL_PROVIDER);
+
+  private TableSnapshotMetricsSQLProviderFactory() {}
+
+  /**
+   * @param metricsMeta metrics row
+   * @return INSERT statement
+   */
+  public static String insertMetrics(@Param("metricsMeta") 
TableSnapshotMetricsPO metricsMeta) {
+    return getProvider().insertMetrics(metricsMeta);
+  }
+
+  /**
+   * @param tableId table id
+   * @param policyId policy id
+   * @param snapshotId snapshot id
+   * @return SELECT statement
+   */
+  public static String selectMetrics(
+      @Param("tableId") long tableId,
+      @Param("policyId") long policyId,
+      @Param("snapshotId") long snapshotId) {
+    return getProvider().selectMetrics(tableId, policyId, snapshotId);
+  }
+
+  static TableSnapshotMetricsBaseSQLProvider getProvider() {
+    String databaseId =
+        SqlSessionFactoryHelper.getInstance()
+            .getSqlSessionFactory()
+            .getConfiguration()
+            .getDatabaseId();
+    return PROVIDER_MAP.get(JDBCBackendType.fromString(databaseId));
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/TmsMapperPackageProvider.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/TmsMapperPackageProvider.java
new file mode 100644
index 0000000000..bafb8ce6e3
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/TmsMapperPackageProvider.java
@@ -0,0 +1,34 @@
+/*
+ * 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.storage.mapper.provider;
+
+import com.google.common.collect.ImmutableList;
+import java.util.List;
+import 
org.apache.gravitino.maintenance.storage.mapper.TableMaintenanceActionMapper;
+import 
org.apache.gravitino.maintenance.storage.mapper.TableSnapshotMetricsMapper;
+import 
org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider;
+
+/** Supplies TMS mapper classes from the {@code tms} plugin. */
+public class TmsMapperPackageProvider implements MapperPackageProvider {
+
+  @Override
+  public List<Class<?>> getMapperClasses() {
+    return ImmutableList.of(TableMaintenanceActionMapper.class, 
TableSnapshotMetricsMapper.class);
+  }
+}
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
new file mode 100644
index 0000000000..9b5c44b134
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableMaintenanceActionBaseSQLProvider.java
@@ -0,0 +1,128 @@
+/*
+ * 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.storage.mapper.provider.base;
+
+import static 
org.apache.gravitino.maintenance.storage.mapper.TableMaintenanceActionMapper.TABLE_NAME;
+
+import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
+import org.apache.ibatis.annotations.Param;
+
+/** Portable SQL for {@code table_maintenance_action}. */
+public class TableMaintenanceActionBaseSQLProvider {
+
+  /**
+   * Inserts an in-flight occupancy row before {@code runJob}. {@code job_id} 
stays null until bound
+   * after submission (§5.2).
+   *
+   * @param actionMeta row to insert
+   * @return INSERT statement
+   */
+  public String insertAction(@Param("actionMeta") TableMaintenanceActionPO 
actionMeta) {
+    return "INSERT INTO "
+        + TABLE_NAME
+        + " (table_id, policy_id, created_at)"
+        + " VALUES (#{actionMeta.tableId}, #{actionMeta.policyId}, 
#{actionMeta.createdAt})";
+  }
+
+  /**
+   * Binds the Gravitino job run id after {@code runJob} succeeds.
+   *
+   * @param id action row id
+   * @param jobId {@code job_run_meta.job_run_id}
+   * @return UPDATE statement
+   */
+  public String bindJobId(@Param("id") long id, @Param("jobId") long jobId) {
+    return "UPDATE "
+        + TABLE_NAME
+        + " SET job_id = #{jobId}"
+        + " WHERE id = #{id} AND job_id IS NULL AND finished_at IS NULL";
+  }
+
+  /**
+   * @param id action row id
+   * @return SELECT statement
+   */
+  public String selectById(@Param("id") long id) {
+    return selectColumns() + " WHERE id = #{id}";
+  }
+
+  /**
+   * @param jobId Gravitino job run id
+   * @return SELECT statement
+   */
+  public String selectByJobId(@Param("jobId") long jobId) {
+    return selectColumns() + " WHERE job_id = #{jobId}";
+  }
+
+  /**
+   * Sets {@code before_snapshot_id} once when {@code S0} is sampled.
+   *
+   * @param jobId Gravitino job run id
+   * @param beforeSnapshotId sampled snapshot id
+   * @return UPDATE statement
+   */
+  public String updateBeforeSnapshot(
+      @Param("jobId") long jobId, @Param("beforeSnapshotId") long 
beforeSnapshotId) {
+    return "UPDATE "
+        + TABLE_NAME
+        + " SET before_snapshot_id = #{beforeSnapshotId}"
+        + " WHERE job_id = #{jobId} AND before_snapshot_id IS NULL AND 
finished_at IS NULL";
+  }
+
+  /**
+   * Sets terminal snapshot pointers when the Spark Job finishes.
+   *
+   * @param jobId Gravitino job run id
+   * @param afterSnapshotId terminal snapshot id
+   * @param finishedAt finish time millis
+   * @return UPDATE statement
+   */
+  public String markFinished(
+      @Param("jobId") long jobId,
+      @Param("afterSnapshotId") Long afterSnapshotId,
+      @Param("finishedAt") long finishedAt) {
+    return "UPDATE "
+        + TABLE_NAME
+        + " SET after_snapshot_id = #{afterSnapshotId}, finished_at = 
#{finishedAt}"
+        + " WHERE job_id = #{jobId} AND finished_at IS NULL";
+  }
+
+  /**
+   * @param id action row id
+   * @return DELETE statement
+   */
+  public String deleteById(@Param("id") long id) {
+    return "DELETE FROM " + TABLE_NAME + " WHERE id = #{id}";
+  }
+
+  /**
+   * @param jobId Gravitino job run id
+   * @return DELETE statement
+   */
+  public String deleteByJobId(@Param("jobId") long jobId) {
+    return "DELETE FROM " + TABLE_NAME + " WHERE job_id = #{jobId}";
+  }
+
+  private static String selectColumns() {
+    return "SELECT id, job_id AS jobId, table_id AS tableId, policy_id AS 
policyId,"
+        + " before_snapshot_id AS beforeSnapshotId, after_snapshot_id AS 
afterSnapshotId,"
+        + " finished_at AS finishedAt, created_at AS createdAt FROM "
+        + TABLE_NAME;
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableSnapshotMetricsBaseSQLProvider.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableSnapshotMetricsBaseSQLProvider.java
new file mode 100644
index 0000000000..b3b58ac2fa
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/base/TableSnapshotMetricsBaseSQLProvider.java
@@ -0,0 +1,56 @@
+/*
+ * 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.storage.mapper.provider.base;
+
+import 
org.apache.gravitino.maintenance.storage.mapper.TableSnapshotMetricsMapper;
+import org.apache.gravitino.maintenance.storage.po.TableSnapshotMetricsPO;
+import org.apache.ibatis.annotations.Param;
+
+/** Portable SQL for {@code table_snapshot_metrics}. */
+public class TableSnapshotMetricsBaseSQLProvider {
+
+  /**
+   * @param metricsMeta metrics row
+   * @return INSERT statement
+   */
+  public String insertMetrics(@Param("metricsMeta") TableSnapshotMetricsPO 
metricsMeta) {
+    return "INSERT INTO "
+        + TableSnapshotMetricsMapper.TABLE_NAME
+        + " (table_id, policy_id, snapshot_id, metrics_value, created_at)"
+        + " VALUES (#{metricsMeta.tableId}, #{metricsMeta.policyId}, 
#{metricsMeta.snapshotId},"
+        + " #{metricsMeta.metricsValue}, #{metricsMeta.createdAt})";
+  }
+
+  /**
+   * @param tableId table id
+   * @param policyId policy id
+   * @param snapshotId Iceberg snapshot id
+   * @return SELECT statement
+   */
+  public String selectMetrics(
+      @Param("tableId") long tableId,
+      @Param("policyId") long policyId,
+      @Param("snapshotId") long snapshotId) {
+    return "SELECT id, table_id AS tableId, policy_id AS policyId, snapshot_id 
AS snapshotId,"
+        + " metrics_value AS metricsValue, created_at AS createdAt FROM "
+        + TableSnapshotMetricsMapper.TABLE_NAME
+        + " WHERE table_id = #{tableId} AND policy_id = #{policyId} AND 
snapshot_id ="
+        + " #{snapshotId}";
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/h2/TableMaintenanceActionH2Provider.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/h2/TableMaintenanceActionH2Provider.java
new file mode 100644
index 0000000000..835f656b70
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/h2/TableMaintenanceActionH2Provider.java
@@ -0,0 +1,24 @@
+/*
+ * 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.storage.mapper.provider.h2;
+
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.base.TableMaintenanceActionBaseSQLProvider;
+
+/** H2 SQL provider for {@code table_maintenance_action}. */
+public class TableMaintenanceActionH2Provider extends 
TableMaintenanceActionBaseSQLProvider {}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/h2/TableSnapshotMetricsH2Provider.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/h2/TableSnapshotMetricsH2Provider.java
new file mode 100644
index 0000000000..7a532055c5
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/h2/TableSnapshotMetricsH2Provider.java
@@ -0,0 +1,24 @@
+/*
+ * 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.storage.mapper.provider.h2;
+
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.base.TableSnapshotMetricsBaseSQLProvider;
+
+/** H2 SQL provider for {@code table_snapshot_metrics}. */
+public class TableSnapshotMetricsH2Provider extends 
TableSnapshotMetricsBaseSQLProvider {}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableMaintenanceActionPostgreSQLProvider.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableMaintenanceActionPostgreSQLProvider.java
new file mode 100644
index 0000000000..49f89e7827
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableMaintenanceActionPostgreSQLProvider.java
@@ -0,0 +1,25 @@
+/*
+ * 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.storage.mapper.provider.postgresql;
+
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.base.TableMaintenanceActionBaseSQLProvider;
+
+/** PostgreSQL SQL provider for {@code table_maintenance_action}. */
+public class TableMaintenanceActionPostgreSQLProvider
+    extends TableMaintenanceActionBaseSQLProvider {}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableSnapshotMetricsPostgreSQLProvider.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableSnapshotMetricsPostgreSQLProvider.java
new file mode 100644
index 0000000000..16484522f7
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/mapper/provider/postgresql/TableSnapshotMetricsPostgreSQLProvider.java
@@ -0,0 +1,24 @@
+/*
+ * 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.storage.mapper.provider.postgresql;
+
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.base.TableSnapshotMetricsBaseSQLProvider;
+
+/** PostgreSQL SQL provider for {@code table_snapshot_metrics}. */
+public class TableSnapshotMetricsPostgreSQLProvider extends 
TableSnapshotMetricsBaseSQLProvider {}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/po/TableMaintenanceActionPO.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/po/TableMaintenanceActionPO.java
new file mode 100644
index 0000000000..1f1928bbae
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/po/TableMaintenanceActionPO.java
@@ -0,0 +1,48 @@
+/*
+ * 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.storage.po;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import lombok.experimental.Accessors;
+
+/** Persistence object for {@code table_maintenance_action}. */
+@Getter
+@Accessors(fluent = true)
+@EqualsAndHashCode
+@ToString
+@Builder(setterPrefix = "with")
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class TableMaintenanceActionPO {
+
+  private Long id;
+  private Long jobId;
+  private Long tableId;
+  private Long policyId;
+  private Long beforeSnapshotId;
+  private Long afterSnapshotId;
+  private Long finishedAt;
+  private Long createdAt;
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/po/TableSnapshotMetricsPO.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/po/TableSnapshotMetricsPO.java
new file mode 100644
index 0000000000..8cb2fd1313
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/po/TableSnapshotMetricsPO.java
@@ -0,0 +1,46 @@
+/*
+ * 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.storage.po;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.ToString;
+import lombok.experimental.Accessors;
+
+/** Persistence object for {@code table_snapshot_metrics}. */
+@Getter
+@Accessors(fluent = true)
+@EqualsAndHashCode
+@ToString
+@Builder(setterPrefix = "with")
+@NoArgsConstructor(access = AccessLevel.PRIVATE)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class TableSnapshotMetricsPO {
+
+  private Long id;
+  private Long tableId;
+  private Long policyId;
+  private Long snapshotId;
+  private String metricsValue;
+  private Long createdAt;
+}
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
new file mode 100644
index 0000000000..ea5c570d3e
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableMaintenanceActionMetaService.java
@@ -0,0 +1,178 @@
+/*
+ * 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.storage.service;
+
+import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
+
+import com.google.common.base.Preconditions;
+import javax.annotation.Nullable;
+import 
org.apache.gravitino.maintenance.storage.mapper.TableMaintenanceActionMapper;
+import org.apache.gravitino.maintenance.storage.po.TableMaintenanceActionPO;
+import org.apache.gravitino.metrics.Monitored;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+
+/** DAO service for {@code table_maintenance_action}. */
+public class TableMaintenanceActionMetaService {
+
+  private static final TableMaintenanceActionMetaService INSTANCE =
+      new TableMaintenanceActionMetaService();
+
+  private TableMaintenanceActionMetaService() {}
+
+  /**
+   * @return singleton service instance
+   */
+  public static TableMaintenanceActionMetaService getInstance() {
+    return INSTANCE;
+  }
+
+  /**
+   * Inserts an in-flight occupancy row before {@code runJob}. {@code job_id} 
is bound later via
+   * {@link #bindJobId(long, long)}.
+   *
+   * @param tableId table entity id
+   * @param policyId policy entity id
+   * @return generated action id
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "insertTableMaintenanceAction")
+  public long insertInFlight(long tableId, long policyId) {
+    TableMaintenanceActionPO actionMeta =
+        TableMaintenanceActionPO.builder()
+            .withTableId(tableId)
+            .withPolicyId(policyId)
+            .withCreatedAt(System.currentTimeMillis())
+            .build();
+    SessionUtils.doWithCommit(
+        TableMaintenanceActionMapper.class, mapper -> 
mapper.insertAction(actionMeta));
+    Preconditions.checkState(actionMeta.id() != null, "insert did not return 
generated id");
+    return actionMeta.id();
+  }
+
+  /**
+   * Binds {@code job_run_meta.job_run_id} after a successful {@code runJob}.
+   *
+   * @param id action row id
+   * @param jobId Gravitino job run id
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "bindTableMaintenanceActionJobId")
+  public void bindJobId(long id, long jobId) {
+    int updated =
+        SessionUtils.doWithCommitAndFetchResult(
+            TableMaintenanceActionMapper.class, mapper -> mapper.bindJobId(id, 
jobId));
+    Preconditions.checkState(
+        updated == 1,
+        "expected to bind job_id on exactly one in-flight action, updated=%s",
+        updated);
+  }
+
+  /**
+   * @param id action row id
+   * @return action row or null
+   */
+  @Nullable
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "getTableMaintenanceActionById")
+  public TableMaintenanceActionPO getById(long id) {
+    return SessionUtils.getWithoutCommit(
+        TableMaintenanceActionMapper.class, mapper -> mapper.selectById(id));
+  }
+
+  /**
+   * @param jobId Gravitino job run id
+   * @return action row or null
+   */
+  @Nullable
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "getTableMaintenanceActionByJobId")
+  public TableMaintenanceActionPO getByJobId(long jobId) {
+    return SessionUtils.getWithoutCommit(
+        TableMaintenanceActionMapper.class, mapper -> 
mapper.selectByJobId(jobId));
+  }
+
+  /**
+   * Records the sampled Iceberg snapshot id ({@code S0}) once for a running 
action.
+   *
+   * @param jobId Gravitino job run id
+   * @param beforeSnapshotId sampled snapshot id
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "recordTableMaintenanceActionBeforeSnapshot")
+  public void recordBeforeSnapshot(long jobId, long beforeSnapshotId) {
+    int updated =
+        SessionUtils.doWithCommitAndFetchResult(
+            TableMaintenanceActionMapper.class,
+            mapper -> mapper.updateBeforeSnapshot(jobId, beforeSnapshotId));
+    Preconditions.checkState(
+        updated == 1,
+        "expected to set before_snapshot_id once on an unfinished action, 
updated=%s",
+        updated);
+  }
+
+  /**
+   * Marks the action finished with the terminal snapshot id.
+   *
+   * @param jobId Gravitino job run id
+   * @param afterSnapshotId terminal snapshot id
+   * @param finishedAt finish timestamp millis
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "markTableMaintenanceActionFinished")
+  public void markFinished(long jobId, Long afterSnapshotId, long finishedAt) {
+    int updated =
+        SessionUtils.doWithCommitAndFetchResult(
+            TableMaintenanceActionMapper.class,
+            mapper -> mapper.markFinished(jobId, afterSnapshotId, finishedAt));
+    Preconditions.checkState(
+        updated == 1, "expected to finish exactly one in-flight action, 
updated=%s", updated);
+  }
+
+  /**
+   * Deletes the {@code table_maintenance_action} row. Does not delete {@code
+   * table_snapshot_metrics}; metrics are retained for TTL/N cleanup.
+   *
+   * @param id action row id
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "deleteTableMaintenanceActionById")
+  public void deleteById(long id) {
+    SessionUtils.doWithCommit(TableMaintenanceActionMapper.class, mapper -> 
mapper.deleteById(id));
+  }
+
+  /**
+   * Deletes the {@code table_maintenance_action} row keyed by job run id. 
Does not delete metrics.
+   *
+   * @param jobId Gravitino job run id
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "deleteTableMaintenanceActionByJobId")
+  public void deleteByJobId(long jobId) {
+    SessionUtils.doWithCommit(
+        TableMaintenanceActionMapper.class, mapper -> 
mapper.deleteByJobId(jobId));
+  }
+}
diff --git 
a/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableSnapshotMetricsMetaService.java
 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableSnapshotMetricsMetaService.java
new file mode 100644
index 0000000000..392c846b94
--- /dev/null
+++ 
b/plugins/tms/src/main/java/org/apache/gravitino/maintenance/storage/service/TableSnapshotMetricsMetaService.java
@@ -0,0 +1,87 @@
+/*
+ * 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.storage.service;
+
+import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
+
+import javax.annotation.Nullable;
+import 
org.apache.gravitino.maintenance.storage.mapper.TableSnapshotMetricsMapper;
+import org.apache.gravitino.maintenance.storage.po.TableSnapshotMetricsPO;
+import org.apache.gravitino.metrics.Monitored;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
+
+/** DAO service for {@code table_snapshot_metrics}. */
+public class TableSnapshotMetricsMetaService {
+
+  private static final TableSnapshotMetricsMetaService INSTANCE =
+      new TableSnapshotMetricsMetaService();
+
+  private TableSnapshotMetricsMetaService() {}
+
+  /**
+   * @return singleton service instance
+   */
+  public static TableSnapshotMetricsMetaService getInstance() {
+    return INSTANCE;
+  }
+
+  /**
+   * Idempotently stores metrics JSON for a table/policy/snapshot triple. If a 
row already exists,
+   * this is a no-op (does not overwrite) so sample / listener retries are 
safe.
+   *
+   * @param tableId table id
+   * @param policyId policy id
+   * @param snapshotId Iceberg snapshot id
+   * @param metricsJson serialized metrics payload
+   */
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "insertTableSnapshotMetrics")
+  public void insertMetrics(long tableId, long policyId, long snapshotId, 
String metricsJson) {
+    if (getMetrics(tableId, policyId, snapshotId) != null) {
+      return;
+    }
+    TableSnapshotMetricsPO metricsMeta =
+        TableSnapshotMetricsPO.builder()
+            .withTableId(tableId)
+            .withPolicyId(policyId)
+            .withSnapshotId(snapshotId)
+            .withMetricsValue(metricsJson)
+            .withCreatedAt(System.currentTimeMillis())
+            .build();
+    SessionUtils.doWithCommit(
+        TableSnapshotMetricsMapper.class, mapper -> 
mapper.insertMetrics(metricsMeta));
+  }
+
+  /**
+   * @param tableId table id
+   * @param policyId policy id
+   * @param snapshotId snapshot id
+   * @return stored metrics or null
+   */
+  @Nullable
+  @Monitored(
+      metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
+      baseMetricName = "getTableSnapshotMetrics")
+  public TableSnapshotMetricsPO getMetrics(long tableId, long policyId, long 
snapshotId) {
+    return SessionUtils.getWithoutCommit(
+        TableSnapshotMetricsMapper.class,
+        mapper -> mapper.selectMetrics(tableId, policyId, snapshotId));
+  }
+}
diff --git 
a/plugins/tms/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
 
b/plugins/tms/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
new file mode 100644
index 0000000000..a130adea4e
--- /dev/null
+++ 
b/plugins/tms/src/main/resources/META-INF/services/org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider
@@ -0,0 +1,18 @@
+# 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.
+
+org.apache.gravitino.maintenance.storage.mapper.provider.TmsMapperPackageProvider
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/mapper/TestTmsMapperPackageProvider.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/mapper/TestTmsMapperPackageProvider.java
new file mode 100644
index 0000000000..f0afbe27fb
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/mapper/TestTmsMapperPackageProvider.java
@@ -0,0 +1,51 @@
+/*
+ * 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.storage.mapper;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import java.util.ServiceLoader;
+import 
org.apache.gravitino.maintenance.storage.mapper.provider.TmsMapperPackageProvider;
+import 
org.apache.gravitino.storage.relational.mapper.provider.MapperPackageProvider;
+import org.junit.jupiter.api.Test;
+
+class TestTmsMapperPackageProvider {
+
+  @Test
+  void testMapperClasses() {
+    MapperPackageProvider provider = new TmsMapperPackageProvider();
+    assertEquals(2, provider.getMapperClasses().size());
+    
assertTrue(provider.getMapperClasses().contains(TableMaintenanceActionMapper.class));
+    
assertTrue(provider.getMapperClasses().contains(TableSnapshotMetricsMapper.class));
+  }
+
+  @Test
+  void testServiceLoaderRegistration() {
+    List<MapperPackageProvider> providers =
+        ServiceLoader.load(MapperPackageProvider.class).stream()
+            .map(ServiceLoader.Provider::get)
+            .filter(provider -> provider instanceof TmsMapperPackageProvider)
+            .toList();
+    assertEquals(1, providers.size());
+    assertTrue(providers.get(0) instanceof TmsMapperPackageProvider);
+  }
+}
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
new file mode 100644
index 0000000000..2921255cd4
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableMaintenanceActionMetaService.java
@@ -0,0 +1,65 @@
+/*
+ * 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.storage.service;
+
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+
+class TestTableMaintenanceActionMetaService extends TestJDBCBackend {
+
+  @TestTemplate
+  void testInsertBindSampleFinishAndDeleteKeepsMetrics() {
+    TableMaintenanceActionMetaService actionService =
+        TableMaintenanceActionMetaService.getInstance();
+    TableSnapshotMetricsMetaService metricsService = 
TableSnapshotMetricsMetaService.getInstance();
+
+    long before = System.currentTimeMillis();
+    long actionId = actionService.insertInFlight(10L, 20L);
+    long after = System.currentTimeMillis();
+    Assertions.assertNull(actionService.getById(actionId).jobId());
+    Assertions.assertTrue(actionService.getById(actionId).createdAt() >= 
before);
+    Assertions.assertTrue(actionService.getById(actionId).createdAt() <= 
after);
+
+    long jobId = 999L;
+    actionService.bindJobId(actionId, jobId);
+    Assertions.assertEquals(jobId, actionService.getByJobId(jobId).jobId());
+
+    actionService.recordBeforeSnapshot(jobId, 100L);
+    Assertions.assertEquals(100L, 
actionService.getByJobId(jobId).beforeSnapshotId());
+    Assertions.assertThrows(
+        IllegalStateException.class, () -> 
actionService.recordBeforeSnapshot(jobId, 101L));
+
+    metricsService.insertMetrics(10L, 20L, 100L, "{\"mse\":1}");
+    actionService.markFinished(jobId, 200L, System.currentTimeMillis());
+    Assertions.assertThrows(
+        IllegalStateException.class,
+        () -> actionService.markFinished(jobId, 201L, 
System.currentTimeMillis()));
+    metricsService.insertMetrics(10L, 20L, 200L, "{\"mse\":2}");
+
+    actionService.deleteByJobId(jobId);
+
+    Assertions.assertNull(actionService.getByJobId(jobId));
+    Assertions.assertEquals(
+        "{\"mse\":1}", metricsService.getMetrics(10L, 20L, 
100L).metricsValue());
+    Assertions.assertEquals(
+        "{\"mse\":2}", metricsService.getMetrics(10L, 20L, 
200L).metricsValue());
+  }
+}
diff --git 
a/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableSnapshotMetricsMetaService.java
 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableSnapshotMetricsMetaService.java
new file mode 100644
index 0000000000..ca1fc00c79
--- /dev/null
+++ 
b/plugins/tms/src/test/java/org/apache/gravitino/maintenance/storage/service/TestTableSnapshotMetricsMetaService.java
@@ -0,0 +1,46 @@
+/*
+ * 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.storage.service;
+
+import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.TestTemplate;
+
+class TestTableSnapshotMetricsMetaService extends TestJDBCBackend {
+
+  @TestTemplate
+  void testInsertAndGetMetrics() {
+    TableSnapshotMetricsMetaService service = 
TableSnapshotMetricsMetaService.getInstance();
+    long before = System.currentTimeMillis();
+    service.insertMetrics(1L, 2L, 99L, "{\"mse\":1}");
+    long after = System.currentTimeMillis();
+    Assertions.assertEquals("{\"mse\":1}", service.getMetrics(1L, 2L, 
99L).metricsValue());
+    Assertions.assertTrue(service.getMetrics(1L, 2L, 99L).createdAt() >= 
before);
+    Assertions.assertTrue(service.getMetrics(1L, 2L, 99L).createdAt() <= 
after);
+  }
+
+  @TestTemplate
+  void testInsertMetricsIsIdempotent() {
+    TableSnapshotMetricsMetaService service = 
TableSnapshotMetricsMetaService.getInstance();
+    service.insertMetrics(3L, 4L, 88L, "{\"mse\":1}");
+    service.insertMetrics(3L, 4L, 88L, "{\"mse\":2}");
+    Assertions.assertEquals("{\"mse\":1}", service.getMetrics(3L, 4L, 
88L).metricsValue());
+  }
+}
diff --git a/scripts/h2/schema-2.0.0-h2.sql b/scripts/h2/schema-2.0.0-h2.sql
index 373b8558bf..ac7fb308d1 100644
--- a/scripts/h2/schema-2.0.0-h2.sql
+++ b/scripts/h2/schema-2.0.0-h2.sql
@@ -680,3 +680,32 @@ CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
 ) COMMENT='async Iceberg table cleanup jobs';
 CREATE INDEX IF NOT EXISTS `idx_state_updated` ON `iceberg_cleanup_job` 
(`state`, `updated_at`);
 CREATE INDEX IF NOT EXISTS `idx_object` ON `iceberg_cleanup_job` 
(`catalog_id`, `namespace`, `table_name`, `state`);
+
+CREATE TABLE IF NOT EXISTS `table_maintenance_action` (
+    `id` BIGINT AUTO_INCREMENT NOT NULL COMMENT 'auto increment id',
+    `job_id` BIGINT COMMENT 'optional job run id mapped to 
job_run_meta.job_run_id, null until bound after runJob',
+    `table_id` BIGINT NOT NULL COMMENT 'table id from table_meta',
+    `policy_id` BIGINT NOT NULL COMMENT 'policy id from policy_meta',
+    `before_snapshot_id` BIGINT COMMENT 'Iceberg snapshot id before 
maintenance, null until sampled',
+    `after_snapshot_id` BIGINT COMMENT 'Iceberg snapshot id after maintenance, 
null while pending',
+    `finished_at` BIGINT COMMENT 'finish timestamp in milliseconds, null while 
in flight',
+    `created_at` BIGINT NOT NULL COMMENT 'creation timestamp in milliseconds',
+    PRIMARY KEY (`id`)
+) COMMENT='table maintenance action occupancy and run metadata';
+CREATE UNIQUE INDEX IF NOT EXISTS `uk_tma_job_id` ON 
`table_maintenance_action`(`job_id`);
+CREATE INDEX IF NOT EXISTS `idx_tma_table_policy_finished` ON 
`table_maintenance_action`(`table_id`, `policy_id`, `finished_at`);
+CREATE INDEX IF NOT EXISTS `idx_tma_before_sid` ON 
`table_maintenance_action`(`table_id`, `before_snapshot_id`);
+CREATE INDEX IF NOT EXISTS `idx_tma_after_sid` ON 
`table_maintenance_action`(`table_id`, `after_snapshot_id`);
+CREATE INDEX IF NOT EXISTS `idx_tma_created_at` ON 
`table_maintenance_action`(`created_at`);
+
+CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
+    `id` BIGINT AUTO_INCREMENT NOT NULL COMMENT 'auto increment id',
+    `table_id` BIGINT NOT NULL COMMENT 'table id from table_meta',
+    `policy_id` BIGINT NOT NULL COMMENT 'policy id from policy_meta',
+    `snapshot_id` BIGINT NOT NULL COMMENT 'Iceberg snapshot id',
+    `metrics_value` CLOB NOT NULL COMMENT 'metrics value as JSON',
+    `created_at` BIGINT NOT NULL COMMENT 'creation timestamp in milliseconds',
+    PRIMARY KEY (`id`)
+) COMMENT='table snapshot metrics metadata';
+CREATE UNIQUE INDEX IF NOT EXISTS `uk_tid_pid_sid` ON 
`table_snapshot_metrics`(`table_id`, `policy_id`, `snapshot_id`);
+CREATE INDEX IF NOT EXISTS `idx_tsm_created_at` ON 
`table_snapshot_metrics`(`created_at`);
diff --git a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql 
b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
index 6b66d9e791..fb4f838a66 100644
--- a/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
+++ b/scripts/h2/upgrade-1.3.0-to-2.0.0-h2.sql
@@ -102,6 +102,35 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
     KEY `idx_smvi_sid` (`schema_id`)
 ) ENGINE=InnoDB COMMENT 'semantic model version information';
 
+CREATE TABLE IF NOT EXISTS `table_maintenance_action` (
+    `id` BIGINT AUTO_INCREMENT NOT NULL COMMENT 'auto increment id',
+    `job_id` BIGINT COMMENT 'optional job run id mapped to 
job_run_meta.job_run_id, null until bound after runJob',
+    `table_id` BIGINT NOT NULL COMMENT 'table id from table_meta',
+    `policy_id` BIGINT NOT NULL COMMENT 'policy id from policy_meta',
+    `before_snapshot_id` BIGINT COMMENT 'Iceberg snapshot id before 
maintenance, null until sampled',
+    `after_snapshot_id` BIGINT COMMENT 'Iceberg snapshot id after maintenance, 
null while pending',
+    `finished_at` BIGINT COMMENT 'finish timestamp in milliseconds, null while 
in flight',
+    `created_at` BIGINT NOT NULL COMMENT 'creation timestamp in milliseconds',
+    PRIMARY KEY (`id`)
+) COMMENT='table maintenance action occupancy and run metadata';
+CREATE UNIQUE INDEX IF NOT EXISTS `uk_tma_job_id` ON 
`table_maintenance_action`(`job_id`);
+CREATE INDEX IF NOT EXISTS `idx_tma_table_policy_finished` ON 
`table_maintenance_action`(`table_id`, `policy_id`, `finished_at`);
+CREATE INDEX IF NOT EXISTS `idx_tma_before_sid` ON 
`table_maintenance_action`(`table_id`, `before_snapshot_id`);
+CREATE INDEX IF NOT EXISTS `idx_tma_after_sid` ON 
`table_maintenance_action`(`table_id`, `after_snapshot_id`);
+CREATE INDEX IF NOT EXISTS `idx_tma_created_at` ON 
`table_maintenance_action`(`created_at`);
+
+CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
+    `id` BIGINT AUTO_INCREMENT NOT NULL COMMENT 'auto increment id',
+    `table_id` BIGINT NOT NULL COMMENT 'table id from table_meta',
+    `policy_id` BIGINT NOT NULL COMMENT 'policy id from policy_meta',
+    `snapshot_id` BIGINT NOT NULL COMMENT 'Iceberg snapshot id',
+    `metrics_value` CLOB NOT NULL COMMENT 'metrics value as JSON',
+    `created_at` BIGINT NOT NULL COMMENT 'creation timestamp in milliseconds',
+    PRIMARY KEY (`id`)
+) COMMENT='table snapshot metrics metadata';
+CREATE UNIQUE INDEX IF NOT EXISTS `uk_tid_pid_sid` ON 
`table_snapshot_metrics`(`table_id`, `policy_id`, `snapshot_id`);
+CREATE INDEX IF NOT EXISTS `idx_tsm_created_at` ON 
`table_snapshot_metrics`(`created_at`);
+
 -- Merge duplicate live owners left by concurrent assignments: the newest live 
row
 -- (largest id) wins, and older ones are soft-deleted.
 UPDATE `owner_meta`
diff --git a/scripts/mysql/schema-2.0.0-mysql.sql 
b/scripts/mysql/schema-2.0.0-mysql.sql
index 0edbb0f1f2..786356bcf1 100644
--- a/scripts/mysql/schema-2.0.0-mysql.sql
+++ b/scripts/mysql/schema-2.0.0-mysql.sql
@@ -667,3 +667,34 @@ CREATE TABLE IF NOT EXISTS `iceberg_cleanup_job` (
   KEY `idx_state_updated` (`state`, `updated_at`),
   KEY `idx_object` (`catalog_id`, `namespace`(255), `table_name`(128), `state`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'async 
Iceberg table cleanup jobs';
+
+CREATE TABLE IF NOT EXISTS `table_maintenance_action` (
+    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
+    `job_id` BIGINT(20) UNSIGNED NULL COMMENT 'optional job run id mapped to 
job_run_meta.job_run_id, null until bound after runJob',
+    `table_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'table id from table_meta',
+    `policy_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'policy id from 
policy_meta',
+    `before_snapshot_id` BIGINT(20) NULL COMMENT 'Iceberg snapshot id before 
maintenance, null until sampled',
+    `after_snapshot_id` BIGINT(20) NULL COMMENT 'Iceberg snapshot id after 
maintenance, null while pending',
+    `finished_at` BIGINT(20) UNSIGNED NULL COMMENT 'finish timestamp in 
milliseconds, null while in flight',
+    `created_at` BIGINT(20) UNSIGNED NOT NULL COMMENT 'creation timestamp in 
milliseconds',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_tma_job_id` (`job_id`),
+    KEY `idx_tma_table_policy_finished` (`table_id`, `policy_id`, 
`finished_at`),
+    KEY `idx_tma_before_sid` (`table_id`, `before_snapshot_id`),
+    KEY `idx_tma_after_sid` (`table_id`, `after_snapshot_id`),
+    KEY `idx_tma_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
+  COMMENT 'table maintenance action occupancy and run metadata';
+
+CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
+    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
+    `table_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'table id from table_meta',
+    `policy_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'policy id from 
policy_meta',
+    `snapshot_id` BIGINT(20) NOT NULL COMMENT 'Iceberg snapshot id',
+    `metrics_value` MEDIUMTEXT NOT NULL COMMENT 'metrics value as JSON',
+    `created_at` BIGINT(20) UNSIGNED NOT NULL COMMENT 'creation timestamp in 
milliseconds',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_tid_pid_sid` (`table_id`, `policy_id`, `snapshot_id`),
+    KEY `idx_tsm_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
+  COMMENT 'table snapshot metrics metadata';
diff --git a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql 
b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
index 2815471571..c5108a79c9 100644
--- a/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
+++ b/scripts/mysql/upgrade-1.3.0-to-2.0.0-mysql.sql
@@ -549,6 +549,37 @@ CREATE TABLE IF NOT EXISTS `semantic_model_version_info` (
     KEY `idx_smvi_sid` (`schema_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT 'semantic 
model version information';
 
+CREATE TABLE IF NOT EXISTS `table_maintenance_action` (
+    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
+    `job_id` BIGINT(20) UNSIGNED NULL COMMENT 'optional job run id mapped to 
job_run_meta.job_run_id, null until bound after runJob',
+    `table_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'table id from table_meta',
+    `policy_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'policy id from 
policy_meta',
+    `before_snapshot_id` BIGINT(20) NULL COMMENT 'Iceberg snapshot id before 
maintenance, null until sampled',
+    `after_snapshot_id` BIGINT(20) NULL COMMENT 'Iceberg snapshot id after 
maintenance, null while pending',
+    `finished_at` BIGINT(20) UNSIGNED NULL COMMENT 'finish timestamp in 
milliseconds, null while in flight',
+    `created_at` BIGINT(20) UNSIGNED NOT NULL COMMENT 'creation timestamp in 
milliseconds',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_tma_job_id` (`job_id`),
+    KEY `idx_tma_table_policy_finished` (`table_id`, `policy_id`, 
`finished_at`),
+    KEY `idx_tma_before_sid` (`table_id`, `before_snapshot_id`),
+    KEY `idx_tma_after_sid` (`table_id`, `after_snapshot_id`),
+    KEY `idx_tma_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
+  COMMENT 'table maintenance action occupancy and run metadata';
+
+CREATE TABLE IF NOT EXISTS `table_snapshot_metrics` (
+    `id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'auto increment 
id',
+    `table_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'table id from table_meta',
+    `policy_id` BIGINT(20) UNSIGNED NOT NULL COMMENT 'policy id from 
policy_meta',
+    `snapshot_id` BIGINT(20) NOT NULL COMMENT 'Iceberg snapshot id',
+    `metrics_value` MEDIUMTEXT NOT NULL COMMENT 'metrics value as JSON',
+    `created_at` BIGINT(20) UNSIGNED NOT NULL COMMENT 'creation timestamp in 
milliseconds',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_tid_pid_sid` (`table_id`, `policy_id`, `snapshot_id`),
+    KEY `idx_tsm_created_at` (`created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
+  COMMENT 'table snapshot metrics metadata';
+
 -- Merge duplicate live owners left by concurrent assignments: the newest live 
row
 -- (largest id) wins, and older ones are soft-deleted.
 UPDATE `owner_meta` o
diff --git a/scripts/postgresql/schema-2.0.0-postgresql.sql 
b/scripts/postgresql/schema-2.0.0-postgresql.sql
index 3fe920d0eb..b64a8d7fbb 100644
--- a/scripts/postgresql/schema-2.0.0-postgresql.sql
+++ b/scripts/postgresql/schema-2.0.0-postgresql.sql
@@ -1175,3 +1175,46 @@ COMMENT ON COLUMN iceberg_cleanup_job.last_error IS 
'truncated reason for the mo
 COMMENT ON COLUMN iceberg_cleanup_job.heartbeat_at IS 'last heartbeat from the 
worker, 0 when not running';
 COMMENT ON COLUMN iceberg_cleanup_job.created_by IS 'principal that requested 
the drop (audit)';
 COMMENT ON COLUMN iceberg_cleanup_job.updated_at IS 'last state change, drives 
poll ordering and old finished-job cleanup';
+
+CREATE TABLE IF NOT EXISTS table_maintenance_action (
+    id BIGSERIAL PRIMARY KEY,
+    job_id BIGINT,
+    table_id BIGINT NOT NULL,
+    policy_id BIGINT NOT NULL,
+    before_snapshot_id BIGINT,
+    after_snapshot_id BIGINT,
+    finished_at BIGINT,
+    created_at BIGINT NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_tma_job_id ON table_maintenance_action 
(job_id);
+CREATE INDEX IF NOT EXISTS idx_tma_table_policy_finished ON 
table_maintenance_action (table_id, policy_id, finished_at);
+CREATE INDEX IF NOT EXISTS idx_tma_before_sid ON table_maintenance_action 
(table_id, before_snapshot_id);
+CREATE INDEX IF NOT EXISTS idx_tma_after_sid ON table_maintenance_action 
(table_id, after_snapshot_id);
+CREATE INDEX IF NOT EXISTS idx_tma_created_at ON table_maintenance_action 
(created_at);
+COMMENT ON TABLE table_maintenance_action IS 'table maintenance action 
occupancy and run metadata';
+COMMENT ON COLUMN table_maintenance_action.id IS 'auto increment id';
+COMMENT ON COLUMN table_maintenance_action.job_id IS 'optional job run id 
mapped to job_run_meta.job_run_id, null until bound after runJob';
+COMMENT ON COLUMN table_maintenance_action.table_id IS 'table id from 
table_meta';
+COMMENT ON COLUMN table_maintenance_action.policy_id IS 'policy id from 
policy_meta';
+COMMENT ON COLUMN table_maintenance_action.before_snapshot_id IS 'Iceberg 
snapshot id before maintenance, null until sampled';
+COMMENT ON COLUMN table_maintenance_action.after_snapshot_id IS 'Iceberg 
snapshot id after maintenance, null while pending';
+COMMENT ON COLUMN table_maintenance_action.finished_at IS 'finish timestamp in 
milliseconds, null while in flight';
+COMMENT ON COLUMN table_maintenance_action.created_at IS 'creation timestamp 
in milliseconds';
+
+CREATE TABLE IF NOT EXISTS table_snapshot_metrics (
+    id BIGSERIAL PRIMARY KEY,
+    table_id BIGINT NOT NULL,
+    policy_id BIGINT NOT NULL,
+    snapshot_id BIGINT NOT NULL,
+    metrics_value TEXT NOT NULL,
+    created_at BIGINT NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_tid_pid_sid ON table_snapshot_metrics 
(table_id, policy_id, snapshot_id);
+CREATE INDEX IF NOT EXISTS idx_tsm_created_at ON table_snapshot_metrics 
(created_at);
+COMMENT ON TABLE table_snapshot_metrics IS 'table snapshot metrics metadata';
+COMMENT ON COLUMN table_snapshot_metrics.id IS 'auto increment id';
+COMMENT ON COLUMN table_snapshot_metrics.table_id IS 'table id from 
table_meta';
+COMMENT ON COLUMN table_snapshot_metrics.policy_id IS 'policy id from 
policy_meta';
+COMMENT ON COLUMN table_snapshot_metrics.snapshot_id IS 'Iceberg snapshot id';
+COMMENT ON COLUMN table_snapshot_metrics.metrics_value IS 'metrics value as 
JSON';
+COMMENT ON COLUMN table_snapshot_metrics.created_at IS 'creation timestamp in 
milliseconds';
diff --git a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql 
b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
index 771b9ccd67..6fde2a8e27 100644
--- a/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
+++ b/scripts/postgresql/upgrade-1.3.0-to-2.0.0-postgresql.sql
@@ -151,6 +151,49 @@ COMMENT ON COLUMN semantic_model_version_info.properties 
IS 'semantic model prop
 COMMENT ON COLUMN semantic_model_version_info.audit_info IS 'semantic model 
version audit info';
 COMMENT ON COLUMN semantic_model_version_info.deleted_at IS 'version deleted 
at';
 
+CREATE TABLE IF NOT EXISTS table_maintenance_action (
+    id BIGSERIAL PRIMARY KEY,
+    job_id BIGINT,
+    table_id BIGINT NOT NULL,
+    policy_id BIGINT NOT NULL,
+    before_snapshot_id BIGINT,
+    after_snapshot_id BIGINT,
+    finished_at BIGINT,
+    created_at BIGINT NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_tma_job_id ON table_maintenance_action 
(job_id);
+CREATE INDEX IF NOT EXISTS idx_tma_table_policy_finished ON 
table_maintenance_action (table_id, policy_id, finished_at);
+CREATE INDEX IF NOT EXISTS idx_tma_before_sid ON table_maintenance_action 
(table_id, before_snapshot_id);
+CREATE INDEX IF NOT EXISTS idx_tma_after_sid ON table_maintenance_action 
(table_id, after_snapshot_id);
+CREATE INDEX IF NOT EXISTS idx_tma_created_at ON table_maintenance_action 
(created_at);
+COMMENT ON TABLE table_maintenance_action IS 'table maintenance action 
occupancy and run metadata';
+COMMENT ON COLUMN table_maintenance_action.id IS 'auto increment id';
+COMMENT ON COLUMN table_maintenance_action.job_id IS 'optional job run id 
mapped to job_run_meta.job_run_id, null until bound after runJob';
+COMMENT ON COLUMN table_maintenance_action.table_id IS 'table id from 
table_meta';
+COMMENT ON COLUMN table_maintenance_action.policy_id IS 'policy id from 
policy_meta';
+COMMENT ON COLUMN table_maintenance_action.before_snapshot_id IS 'Iceberg 
snapshot id before maintenance, null until sampled';
+COMMENT ON COLUMN table_maintenance_action.after_snapshot_id IS 'Iceberg 
snapshot id after maintenance, null while pending';
+COMMENT ON COLUMN table_maintenance_action.finished_at IS 'finish timestamp in 
milliseconds, null while in flight';
+COMMENT ON COLUMN table_maintenance_action.created_at IS 'creation timestamp 
in milliseconds';
+
+CREATE TABLE IF NOT EXISTS table_snapshot_metrics (
+    id BIGSERIAL PRIMARY KEY,
+    table_id BIGINT NOT NULL,
+    policy_id BIGINT NOT NULL,
+    snapshot_id BIGINT NOT NULL,
+    metrics_value TEXT NOT NULL,
+    created_at BIGINT NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uk_tid_pid_sid ON table_snapshot_metrics 
(table_id, policy_id, snapshot_id);
+CREATE INDEX IF NOT EXISTS idx_tsm_created_at ON table_snapshot_metrics 
(created_at);
+COMMENT ON TABLE table_snapshot_metrics IS 'table snapshot metrics metadata';
+COMMENT ON COLUMN table_snapshot_metrics.id IS 'auto increment id';
+COMMENT ON COLUMN table_snapshot_metrics.table_id IS 'table id from 
table_meta';
+COMMENT ON COLUMN table_snapshot_metrics.policy_id IS 'policy id from 
policy_meta';
+COMMENT ON COLUMN table_snapshot_metrics.snapshot_id IS 'Iceberg snapshot id';
+COMMENT ON COLUMN table_snapshot_metrics.metrics_value IS 'metrics value as 
JSON';
+COMMENT ON COLUMN table_snapshot_metrics.created_at IS 'creation timestamp in 
milliseconds';
+
 -- Merge duplicate live owners left by concurrent assignments: the newest live 
row
 -- (largest id) wins, and older ones are soft-deleted.
 UPDATE owner_meta
diff --git a/settings.gradle.kts b/settings.gradle.kts
index a8ec768322..ced9feab65 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -127,6 +127,7 @@ include(":catalogs:hadoop-auth")
 include(":lineage")
 include(":mcp-server")
 include(":plugins:idp-basic")
+include(":plugins:tms")
 include(
   ":maintenance:optimizer-api",
   ":maintenance:updaters",

Reply via email to