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 991788f35f0e0fd751880ccbb05500193607f591
Author: remilu <[email protected]>
AuthorDate: Mon Oct 26 10:27:44 2026 +0800

    [#14221] feat(tms): Built-in TMS Iceberg maintenance Spark job
    
    Fixes #14221.
    
    Squashed from fork #77 (B10).
    
    Co-authored-by: Cursor <[email protected]>
---
 .../optimizer-configuration.md                     |   2 +-
 maintenance/jobs/build.gradle.kts                  |   2 +
 .../jobs/BuiltInJobTemplateProvider.java           |   4 +-
 .../jobs/MaintenanceActionHttpClient.java          | 368 ++++++++++
 .../jobs/iceberg/IcebergMaintenanceJob.java        | 740 +++++++++++++++++++++
 .../jobs/iceberg/MaintenanceTriggerGate.java       | 171 +++++
 .../jobs/TestBuiltInJobTemplateProvider.java       |   6 +
 .../jobs/TestMaintenanceActionHttpClient.java      | 141 ++++
 .../jobs/iceberg/TestIcebergMaintenanceJob.java    |  83 +++
 .../jobs/iceberg/TestMaintenanceTriggerGate.java   |  67 ++
 .../common/util/GravitinoAuthSettings.java         |  42 ++
 11 files changed, 1624 insertions(+), 2 deletions(-)

diff --git a/docs/table-maintenance-service/optimizer-configuration.md 
b/docs/table-maintenance-service/optimizer-configuration.md
index 226a21578d..7c6e8c54b4 100644
--- a/docs/table-maintenance-service/optimizer-configuration.md
+++ b/docs/table-maintenance-service/optimizer-configuration.md
@@ -66,7 +66,7 @@ type is still in-flight it deletes the row and stops; 
otherwise it claims a soft
 gravitino.server.rest.extensionPackages = 
org.apache.gravitino.maintenance.web.rest.feature
 gravitino.auxService.names = iceberg-rest
 gravitino.eventListener.names = post-commit-maintenance-listener
-gravitino.eventListener.post-commit-maintenance-listener.class = 
org.apache.gravitino.maintenance.listener.PostCommitMaintenanceListener
+gravitino.eventListener.post-commit-maintenance-listener.class = 
org.apache.gravitino.maintenance.listener.IcebergUpdateTableEventListener
 ```
 
 Without the listener, the crontab (`timeTrigger` → expand → cron-job) path 
still works; commit-triggered
diff --git a/maintenance/jobs/build.gradle.kts 
b/maintenance/jobs/build.gradle.kts
index 2d40e6a663..baef5c062d 100644
--- a/maintenance/jobs/build.gradle.kts
+++ b/maintenance/jobs/build.gradle.kts
@@ -36,7 +36,9 @@ val sparkMajorVersion = "3.5"
 
 dependencies {
   implementation(project(":api"))
+  implementation(project(":common"))
   implementation(project(":clients:client-java"))
+  implementation(project(":maintenance:maintenance-common"))
   implementation(project(":maintenance:optimizer-api"))
   implementation(project(":maintenance:updaters"))
 
diff --git 
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/BuiltInJobTemplateProvider.java
 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/BuiltInJobTemplateProvider.java
index 6b26dce55e..1cb2f8e566 100644
--- 
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/BuiltInJobTemplateProvider.java
+++ 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/BuiltInJobTemplateProvider.java
@@ -26,6 +26,7 @@ import java.util.stream.Collectors;
 import org.apache.gravitino.job.JobTemplate;
 import org.apache.gravitino.job.JobTemplateProvider;
 import org.apache.gravitino.maintenance.jobs.iceberg.IcebergExpireSnapshotsJob;
+import org.apache.gravitino.maintenance.jobs.iceberg.IcebergMaintenanceJob;
 import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergRemoveOrphanFilesJob;
 import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergRewriteDataFilesJob;
 import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergRewriteManifestsJob;
@@ -53,7 +54,8 @@ public class BuiltInJobTemplateProvider implements 
JobTemplateProvider {
           new IcebergUpdateStatsAndMetricsJob(),
           new IcebergUpdateManifestStatsJob(),
           new IcebergExpireSnapshotsJob(),
-          new IcebergRemoveOrphanFilesJob());
+          new IcebergRemoveOrphanFilesJob(),
+          new IcebergMaintenanceJob());
 
   @Override
   public List<? extends JobTemplate> jobTemplates() {
diff --git 
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/MaintenanceActionHttpClient.java
 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/MaintenanceActionHttpClient.java
new file mode 100644
index 0000000000..b98cea2a2d
--- /dev/null
+++ 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/MaintenanceActionHttpClient.java
@@ -0,0 +1,368 @@
+/*
+ * 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.jobs;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.gravitino.auth.AuthConstants;
+import org.apache.gravitino.dto.responses.BaseResponse;
+import org.apache.gravitino.dto.responses.OAuth2TokenResponse;
+import org.apache.gravitino.json.JsonUtils;
+import 
org.apache.gravitino.maintenance.dto.requests.MaintenanceActionFinishRequest;
+import 
org.apache.gravitino.maintenance.dto.requests.MaintenanceActionMetricsRequest;
+import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
+import 
org.apache.gravitino.maintenance.optimizer.common.util.GravitinoAuthSettings;
+import org.apache.gravitino.rest.RESTRequest;
+import org.apache.gravitino.rest.RESTUtils;
+
+/**
+ * HTTP client for TMS Spark callbacks ({@code metrics/before}, {@code 
metrics/after}, {@code
+ * finish}).
+ *
+ * <p>Uses {@link HttpURLConnection} only (Java 8, no {@code client-java}). 
Authentication follows
+ * the same {@code --updater-options} keys as {@code 
builtin-iceberg-update-stats} ({@link
+ * GravitinoAuthSettings}): {@code none}, {@code simple}, {@code basic}, and 
{@code oauth}.
+ */
+public final class MaintenanceActionHttpClient implements AutoCloseable {
+
+  private static final String ACCEPT = "application/vnd.gravitino.v1+json";
+  private static final String JSON = "application/json";
+  private static final String FORM_URLENCODED = 
"application/x-www-form-urlencoded";
+  private static final String SIMPLE_AUTH_DUMMY_PASSWORD = "dummy";
+  private static final int CONNECT_TIMEOUT_MS = 30_000;
+  private static final int READ_TIMEOUT_MS = 60_000;
+  private static final int OAUTH_EXPIRY_SKEW_SECONDS = 30;
+  private static final int DEFAULT_OAUTH_EXPIRES_IN_SECONDS = 300;
+  private static final int ERROR_BODY_TRUNCATE = 512;
+
+  private final ObjectMapper mapper;
+  private final String baseUri;
+  private final String metalakeName;
+  private final GravitinoAuthSettings authSettings;
+
+  @Nullable private String cachedBearer;
+  private long bearerExpiresAtMillis;
+
+  @VisibleForTesting
+  MaintenanceActionHttpClient(
+      ObjectMapper mapper,
+      String baseUri,
+      String metalakeName,
+      GravitinoAuthSettings authSettings) {
+    this.mapper = Preconditions.checkNotNull(mapper, "mapper");
+    this.baseUri = trimTrailingSlash(Preconditions.checkNotNull(baseUri, 
"baseUri"));
+    this.metalakeName = Preconditions.checkNotNull(metalakeName, 
"metalakeName");
+    this.authSettings = Preconditions.checkNotNull(authSettings, 
"authSettings");
+  }
+
+  /**
+   * Creates a client from updater-options (same shape as 
iceberg-update-stats).
+   *
+   * @param updaterOptions flat map from {@code --updater-options} JSON
+   * @return open client
+   */
+  public static MaintenanceActionHttpClient fromUpdaterOptions(Map<String, 
String> updaterOptions) {
+    Map<String, String> properties = normalizeUpdaterOptions(updaterOptions);
+    String uri = properties.get(OptimizerConfig.GRAVITINO_URI);
+    String metalake = properties.get(OptimizerConfig.GRAVITINO_METALAKE);
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(uri), "updater_options must contain 
gravitino_uri");
+    Preconditions.checkArgument(
+        StringUtils.isNotBlank(metalake), "updater_options must contain 
metalake");
+    GravitinoAuthSettings auth = GravitinoAuthSettings.from(new 
OptimizerConfig(properties));
+    return new MaintenanceActionHttpClient(
+        JsonUtils.objectMapper(), uri.trim(), metalake.trim(), auth);
+  }
+
+  /**
+   * Reports S0 (before-maintenance) snapshot metrics.
+   *
+   * @param jobId Gravitino job run id
+   * @param request metrics payload
+   */
+  public void reportBeforeMetrics(long jobId, MaintenanceActionMetricsRequest 
request) {
+    postJson(jobId, "metrics/before", request);
+  }
+
+  /**
+   * Reports S1 (after-maintenance) snapshot metrics.
+   *
+   * @param jobId Gravitino job run id
+   * @param request metrics payload
+   */
+  public void reportAfterMetrics(long jobId, MaintenanceActionMetricsRequest 
request) {
+    postJson(jobId, "metrics/after", request);
+  }
+
+  /**
+   * Closes the soft slot for one table without writing metrics.
+   *
+   * @param jobId Gravitino job run id
+   * @param request finish payload
+   */
+  public void finish(long jobId, MaintenanceActionFinishRequest request) {
+    postJson(jobId, "finish", request);
+  }
+
+  @Override
+  public void close() {
+    cachedBearer = null;
+    bearerExpiresAtMillis = 0L;
+  }
+
+  private void postJson(long jobId, String action, RESTRequest request) {
+    Preconditions.checkArgument(request != null, "request must not be null");
+    request.validate();
+    String path =
+        String.format(
+            "/api/metalakes/%s/maintenance/actions/%d/%s",
+            RESTUtils.encodeString(metalakeName), jobId, action);
+    try {
+      HttpResult result =
+          post(baseUri + path, JSON, mapper.writeValueAsString(request), 
authorizationHeader());
+      if (!isSuccess(result.status)) {
+        throw new IllegalStateException(
+            String.format(
+                "TMS %s failed: HTTP %d body=%s", action, result.status, 
truncate(result.body)));
+      }
+      if (StringUtils.isNotBlank(result.body)) {
+        BaseResponse parsed = mapper.readValue(result.body, 
BaseResponse.class);
+        parsed.validate();
+      }
+    } catch (IOException e) {
+      throw new IllegalStateException("Failed to post TMS " + action + ": " + 
e.getMessage(), e);
+    }
+  }
+
+  @Nullable
+  private String authorizationHeader() {
+    switch (authSettings.authType()) {
+      case GravitinoAuthSettings.TYPE_SIMPLE:
+        return basicAuthorization(simpleUsername(), 
SIMPLE_AUTH_DUMMY_PASSWORD);
+      case GravitinoAuthSettings.TYPE_BASIC:
+        requireAuthValue(authSettings.username(), "username is required for 
basic authentication");
+        requireAuthValue(authSettings.password(), "password is required for 
basic authentication");
+        return basicAuthorization(authSettings.username(), 
authSettings.password());
+      case GravitinoAuthSettings.TYPE_OAUTH:
+        return AuthConstants.AUTHORIZATION_BEARER_HEADER + oauthAccessToken();
+      case GravitinoAuthSettings.TYPE_NONE:
+        return null;
+      default:
+        throw new IllegalArgumentException("Unsupported auth_type: " + 
authSettings.authType());
+    }
+  }
+
+  private String simpleUsername() {
+    if (StringUtils.isNotBlank(authSettings.username())) {
+      return authSettings.username().trim();
+    }
+    String envUser = System.getenv("GRAVITINO_USER");
+    if (StringUtils.isNotBlank(envUser)) {
+      return envUser.trim();
+    }
+    return System.getProperty("user.name");
+  }
+
+  private String oauthAccessToken() {
+    long now = System.currentTimeMillis();
+    if (cachedBearer != null && now < bearerExpiresAtMillis) {
+      return cachedBearer;
+    }
+    requireAuthValue(
+        authSettings.oauthServerUri(), "oauth server URI is required for oauth 
authentication");
+    requireAuthValue(authSettings.oauthPath(), "oauth path is required for 
oauth authentication");
+    requireAuthValue(
+        authSettings.oauthCredential(), "oauth credential is required for 
oauth authentication");
+    requireAuthValue(authSettings.oauthScope(), "oauth scope is required for 
oauth authentication");
+
+    String tokenUrl = joinUrl(authSettings.oauthServerUri(), 
authSettings.oauthPath());
+    try {
+      HttpResult result = post(tokenUrl, FORM_URLENCODED, 
buildClientCredentialsForm(), null);
+      if (!isSuccess(result.status)) {
+        throw new IllegalStateException("OAuth token request failed: HTTP " + 
result.status);
+      }
+      OAuth2TokenResponse token = mapper.readValue(result.body, 
OAuth2TokenResponse.class);
+      if (StringUtils.isBlank(token.getAccessToken())) {
+        throw new IllegalStateException("OAuth token response did not include 
access_token");
+      }
+      cachedBearer = token.getAccessToken();
+      int expiresIn =
+          token.getExpiresIn() == null ? DEFAULT_OAUTH_EXPIRES_IN_SECONDS : 
token.getExpiresIn();
+      bearerExpiresAtMillis = now + Math.max(1, expiresIn - 
OAUTH_EXPIRY_SKEW_SECONDS) * 1000L;
+      return cachedBearer;
+    } catch (IOException e) {
+      throw new IllegalStateException("Failed to request OAuth token: " + 
e.getMessage(), e);
+    }
+  }
+
+  private String buildClientCredentialsForm() {
+    String credential = authSettings.oauthCredential().trim();
+    String clientId = null;
+    String clientSecret = credential;
+    int split = credential.indexOf(':');
+    if (split >= 0) {
+      clientId = credential.substring(0, split);
+      clientSecret = credential.substring(split + 1);
+    }
+    StringBuilder form = new StringBuilder("grant_type=client_credentials");
+    if (StringUtils.isNotBlank(clientId)) {
+      form.append("&client_id=").append(urlEncode(clientId));
+    }
+    form.append("&client_secret=").append(urlEncode(clientSecret));
+    form.append("&scope=").append(urlEncode(authSettings.oauthScope().trim()));
+    return form.toString();
+  }
+
+  private static HttpResult post(
+      String url, String contentType, String body, @Nullable String 
authorization)
+      throws IOException {
+    HttpURLConnection connection = (HttpURLConnection) 
URI.create(url).toURL().openConnection();
+    connection.setRequestMethod("POST");
+    connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
+    connection.setReadTimeout(READ_TIMEOUT_MS);
+    connection.setDoOutput(true);
+    connection.setRequestProperty("Accept", ACCEPT);
+    connection.setRequestProperty("Content-Type", contentType);
+    if (authorization != null) {
+      connection.setRequestProperty(AuthConstants.HTTP_HEADER_AUTHORIZATION, 
authorization);
+    }
+    byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
+    connection.setFixedLengthStreamingMode(bytes.length);
+    try {
+      try (OutputStream out = connection.getOutputStream()) {
+        out.write(bytes);
+      }
+      int status = connection.getResponseCode();
+      InputStream stream =
+          isSuccess(status) ? connection.getInputStream() : 
connection.getErrorStream();
+      return new HttpResult(status, readUtf8(stream));
+    } finally {
+      connection.disconnect();
+    }
+  }
+
+  @VisibleForTesting
+  static Map<String, String> normalizeUpdaterOptions(@Nullable Map<String, 
String> updaterOptions) {
+    Map<String, String> properties =
+        updaterOptions == null ? new HashMap<>() : new 
HashMap<>(updaterOptions);
+    putCanonical(properties, OptimizerConfig.GRAVITINO_URI, "gravitino_uri", 
"gravitino-uri");
+    putCanonical(properties, OptimizerConfig.GRAVITINO_METALAKE, "metalake");
+    GravitinoAuthSettings.copyAliases(properties);
+    return properties;
+  }
+
+  private static void putCanonical(
+      Map<String, String> properties, String canonical, String... aliases) {
+    if (StringUtils.isNotBlank(properties.get(canonical))) {
+      return;
+    }
+    for (String alias : aliases) {
+      String value = properties.get(alias);
+      if (StringUtils.isNotBlank(value)) {
+        properties.put(canonical, value.trim());
+        return;
+      }
+    }
+  }
+
+  private static void requireAuthValue(@Nullable String value, String message) 
{
+    Preconditions.checkArgument(StringUtils.isNotBlank(value), message);
+  }
+
+  private static boolean isSuccess(int status) {
+    return status >= 200 && status < 300;
+  }
+
+  private static String basicAuthorization(String user, String password) {
+    String userInformation = user + ":" + password;
+    return AuthConstants.AUTHORIZATION_BASIC_HEADER
+        + 
Base64.getEncoder().encodeToString(userInformation.getBytes(StandardCharsets.UTF_8));
+  }
+
+  private static String joinUrl(String serverUri, String path) {
+    String base = trimTrailingSlash(serverUri);
+    String suffix = path.startsWith("/") ? path : "/" + path;
+    return base + suffix;
+  }
+
+  private static String urlEncode(String value) {
+    try {
+      return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
+    } catch (UnsupportedEncodingException e) {
+      throw new IllegalStateException(e);
+    }
+  }
+
+  private static String trimTrailingSlash(String uri) {
+    String trimmed = uri.trim();
+    while (trimmed.endsWith("/")) {
+      trimmed = trimmed.substring(0, trimmed.length() - 1);
+    }
+    return trimmed;
+  }
+
+  private static String readUtf8(@Nullable InputStream stream) throws 
IOException {
+    if (stream == null) {
+      return "";
+    }
+    try (InputStream in = stream;
+        ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+      byte[] buffer = new byte[4096];
+      int read;
+      while ((read = in.read(buffer)) >= 0) {
+        out.write(buffer, 0, read);
+      }
+      return out.toString(StandardCharsets.UTF_8.name());
+    }
+  }
+
+  private static String truncate(@Nullable String body) {
+    if (body == null) {
+      return "";
+    }
+    return body.length() <= ERROR_BODY_TRUNCATE
+        ? body
+        : body.substring(0, ERROR_BODY_TRUNCATE) + "...";
+  }
+
+  private static final class HttpResult {
+    private final int status;
+    private final String body;
+
+    private HttpResult(int status, String body) {
+      this.status = status;
+      this.body = body;
+    }
+  }
+}
diff --git 
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergMaintenanceJob.java
 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergMaintenanceJob.java
new file mode 100644
index 0000000000..1a5419e70b
--- /dev/null
+++ 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergMaintenanceJob.java
@@ -0,0 +1,740 @@
+/*
+ * 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.jobs.iceberg;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.google.common.collect.ImmutableList;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.apache.gravitino.job.JobHandle;
+import org.apache.gravitino.job.JobTemplateProvider;
+import org.apache.gravitino.job.SparkJobTemplate;
+import org.apache.gravitino.json.JsonUtils;
+import 
org.apache.gravitino.maintenance.dto.requests.MaintenanceActionFinishRequest;
+import 
org.apache.gravitino.maintenance.dto.requests.MaintenanceActionMetricsRequest;
+import org.apache.gravitino.maintenance.jobs.BuiltInJob;
+import org.apache.gravitino.maintenance.jobs.MaintenanceActionHttpClient;
+import 
org.apache.gravitino.maintenance.optimizer.common.util.IcebergSparkConfigUtils;
+import org.apache.gravitino.policy.IcebergDataCompactionContent;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.spark.Spark3Util;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * TMS Spark entry for cron (§5.2.2) and commit-chain (§5.2.3) {@code runJob} 
submits.
+ *
+ * <p>Not a user-facing CLI. Operators / optimizer keep using the four 
standalone {@code
+ * Iceberg*Job} templates. This class is launched only via the built-in job 
template; program
+ * arguments are template-expanded {@code jobConf} values.
+ *
+ * <p>Dispatches by {@code maintenance_types} (one wire name or a 
comma-separated commit chain).
+ * Reuses procedure SQL builders from the standalone {@code Iceberg*Job} 
classes. Continue / stop
+ * (snapshot drift and trigger-expr recommend) is decided in-job after S0 
sample. Soft slots are
+ * closed via the finish callback in {@code finally}.
+ *
+ * <p>TMS-specific {@code jobConf} keys: {@code maintenance_types}, {@code 
policy_ids} (parallel
+ * comma-separated policy entity ids), {@code tables} (JSON targets with 
system-filled {@code
+ * table_id}), {@code updater_options}, {@code rules}. Shared procedure / 
Spark keys match other
+ * Iceberg built-ins. The Gravitino job id is read from the local executor 
staging directory ({@code
+ * job-} plus the numeric id), or from {@code GRAVITINO_JOB_ID}.
+ */
+public class IcebergMaintenanceJob implements BuiltInJob {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IcebergMaintenanceJob.class);
+
+  /**
+   * Optional job-id override. Not injected by {@code JobManager}; the local 
executor staging
+   * directory is the default source.
+   */
+  private static final String GRAVITINO_JOB_ID_ENV = "GRAVITINO_JOB_ID";
+
+  /** Built-in template name submitted by TMS cron / commit. */
+  public static final String NAME =
+      JobTemplateProvider.BUILTIN_NAME_PREFIX + "iceberg-maintenance-task";
+
+  /**
+   * JobConf key for procedure dispatch ({@code maintenance_types}). One wire 
name, or a
+   * comma-separated commit chain. Required; no template default.
+   */
+  public static final String MAINTENANCE_TYPES_KEY = "maintenance_types";
+
+  /**
+   * JobConf key for table targets ({@code tables}): JSON array of {@code 
table_id}, {@code
+   * catalog_name}, {@code table_identifier}. Single- and multi-table TMS 
submits use the same
+   * shape; {@code table_id} is system-filled by enqueue/submit.
+   */
+  public static final String TABLES_KEY = "tables";
+
+  /**
+   * JobConf key for policy entity ids parallel to {@link 
#MAINTENANCE_TYPES_KEY} (comma-separated).
+   * Required so metrics/finish callbacks can attribute soft slots when one 
job binds multiple
+   * policies.
+   */
+  public static final String POLICY_IDS_KEY = "policy_ids";
+
+  private static final String VERSION = "v1";
+
+  /** Supported {@code maintenance_types} wire values. */
+  public enum MaintenanceType {
+    REWRITE_DATA_FILES("rewrite_data_files"),
+    REWRITE_MANIFESTS("rewrite_manifests"),
+    EXPIRE_SNAPSHOTS("expire_snapshots"),
+    REMOVE_ORPHAN_FILES("remove_orphan_files");
+
+    private final String wireName;
+
+    MaintenanceType(String wireName) {
+      this.wireName = wireName;
+    }
+
+    /**
+     * @return wire value used in jobConf
+     */
+    public String wireName() {
+      return wireName;
+    }
+
+    /**
+     * @param raw jobConf {@code maintenance_types} segment
+     * @return parsed type
+     */
+    public static MaintenanceType fromWire(@Nullable String raw) {
+      if (raw == null || raw.trim().isEmpty()) {
+        throw new IllegalArgumentException("maintenance_types is required");
+      }
+      String normalized = raw.trim().toLowerCase(Locale.ROOT);
+      for (MaintenanceType type : values()) {
+        if (type.wireName.equals(normalized)) {
+          return type;
+        }
+      }
+      throw new IllegalArgumentException(
+          "Unknown maintenance_types value '"
+              + raw
+              + "'. Expected one of: rewrite_data_files, rewrite_manifests, 
expire_snapshots,"
+              + " remove_orphan_files");
+    }
+  }
+
+  @Override
+  public SparkJobTemplate jobTemplate() {
+    return SparkJobTemplate.builder()
+        .withName(NAME)
+        .withComment(
+            "TMS Iceberg maintenance: S0 sample/gate, procedure dispatch 
(single or commit-chain),"
+                + " S1 finish")
+        .withExecutable(resolveExecutable(IcebergMaintenanceJob.class))
+        .withClassName(IcebergMaintenanceJob.class.getName())
+        .withArguments(buildArguments())
+        .withConfigs(IcebergSparkConfigUtils.buildTemplateSparkConfigs())
+        .withCustomFields(
+            Collections.singletonMap(JobTemplateProvider.PROPERTY_VERSION_KEY, 
VERSION))
+        .build();
+  }
+
+  /**
+   * Spark application entry used by the job executor after template 
expansion. Not a supported
+   * operator CLI.
+   *
+   * @param args template-expanded jobConf as named arguments
+   */
+  public static void main(String[] args) {
+    int code = run(args);
+    if (code != 0) {
+      System.exit(code);
+    }
+  }
+
+  /**
+   * @param args template-expanded jobConf as named arguments
+   * @return process exit code ({@code 0} success, {@code 1} failure)
+   */
+  static int run(String[] args) {
+    Map<String, String> argMap = IcebergJobUtils.parseArguments(args);
+
+    List<MaintenanceType> types;
+    List<Long> policyIds;
+    long jobId;
+    List<TableTarget> tables;
+    Map<String, String> updaterOptions;
+    try {
+      types = parseMaintenanceTypes(argMap.get("maintenance-types"));
+      policyIds = parsePolicyIds(argMap.get("policy-ids"), types.size());
+      jobId = resolveGravitinoJobId();
+      tables = 
parseTables(IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("tables")));
+      updaterOptions =
+          parseUpdaterOptions(
+              
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("updater-options")));
+    } catch (IllegalArgumentException | IllegalStateException e) {
+      LOG.error("Invalid TMS maintenance arguments: {}", e.getMessage());
+      return 1;
+    }
+
+    String appLabel =
+        types.size() == 1
+            ? types.get(0).wireName
+            : "chain:" + types.stream().map(t -> 
t.wireName).collect(Collectors.joining(","));
+    SparkSession.Builder sparkBuilder =
+        SparkSession.builder().appName("Gravitino Iceberg Maintenance Task (" 
+ appLabel + ")");
+    String sparkConfJson = 
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("spark-conf"));
+    if (sparkConfJson != null && !sparkConfJson.isEmpty()) {
+      
IcebergJobUtils.parseCustomSparkConfigs(sparkConfJson).forEach(sparkBuilder::config);
+    }
+
+    try (SparkSession spark = sparkBuilder.getOrCreate()) {
+      IcebergJobUtils.requireIcebergSparkRuntimeOrExit(spark);
+      try (MaintenanceActionHttpClient client =
+          MaintenanceActionHttpClient.fromUpdaterOptions(updaterOptions)) {
+        // Soft slots are per (tableId, policyId) and already bound to jobId; 
close every slot even
+        // when a procedure fails or we never reach a later type/table.
+        Set<String> finishedSlots = new HashSet<>();
+        try {
+          for (TableTarget table : tables) {
+            for (int i = 0; i < types.size(); i++) {
+              MaintenanceType type = types.get(i);
+              long policyId = policyIds.get(i);
+              Long afterSnapshotId = null;
+              try {
+                SnapshotSample s0 =
+                    sampleSnapshot(spark, table.catalogName, 
table.tableIdentifier, type);
+                reportMetricsQuietly(client, jobId, table.tableId, policyId, 
s0, /*after=*/ false);
+
+                if (s0.snapshotId != s0.headSnapshotId) {
+                  LOG.info(
+                      "TMS type {} skipped for job {} table {} policy {} due 
to snapshot drift"
+                          + " (s0={} head={})",
+                      type.wireName,
+                      jobId,
+                      table.tableId,
+                      policyId,
+                      s0.snapshotId,
+                      s0.headSnapshotId);
+                  continue;
+                }
+
+                if (!MaintenanceTriggerGate.shouldMaintain(type, argMap, 
s0.metricsJson)) {
+                  LOG.info(
+                      "TMS type {} skipped for job {} table {} policy {} due 
to in-Spark recommend"
+                          + " skip",
+                      type.wireName,
+                      jobId,
+                      table.tableId,
+                      policyId);
+                  continue;
+                }
+
+                runProcedure(spark, type, table.catalogName, 
table.tableIdentifier, argMap);
+                SnapshotSample s1 =
+                    sampleSnapshot(spark, table.catalogName, 
table.tableIdentifier, type);
+                afterSnapshotId = s1.snapshotId;
+                reportMetricsQuietly(client, jobId, table.tableId, policyId, 
s1, /*after=*/ true);
+              } finally {
+                finishQuietly(client, jobId, table.tableId, policyId, 
afterSnapshotId);
+                finishedSlots.add(slotKey(table.tableId, policyId));
+              }
+            }
+          }
+          return 0;
+        } catch (Exception e) {
+          LOG.error("TMS Iceberg maintenance failed for job {}", jobId, e);
+          return 1;
+        } finally {
+          for (TableTarget table : tables) {
+            for (long policyId : policyIds) {
+              if (!finishedSlots.contains(slotKey(table.tableId, policyId))) {
+                finishQuietly(client, jobId, table.tableId, policyId, 
/*afterSnapshotId=*/ null);
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  private static List<TableTarget> parseTables(@Nullable String tablesJson) {
+    if (tablesJson == null || tablesJson.trim().isEmpty()) {
+      throw new IllegalArgumentException(
+          "tables JSON is required (array of table_id, catalog_name, 
table_identifier)");
+    }
+    try {
+      List<Map<String, Object>> raw =
+          JsonUtils.anyFieldMapper()
+              .readValue(tablesJson, new TypeReference<List<Map<String, 
Object>>>() {});
+      if (raw == null || raw.isEmpty()) {
+        throw new IllegalArgumentException("tables must not be empty");
+      }
+      List<TableTarget> tables = new ArrayList<>(raw.size());
+      for (Map<String, Object> entry : raw) {
+        if (entry == null) {
+          throw new IllegalArgumentException("tables entry must not be null");
+        }
+        Object tableIdObj = entry.get("table_id");
+        Object catalogObj = entry.get("catalog_name");
+        Object tableObj = entry.get("table_identifier");
+        if (tableIdObj == null || catalogObj == null || tableObj == null) {
+          throw new IllegalArgumentException(
+              "tables entry requires table_id, catalog_name, 
table_identifier");
+        }
+        long tableId = Long.parseLong(String.valueOf(tableIdObj));
+        if (tableId <= 0) {
+          throw new IllegalArgumentException("tables table_id must be 
positive: " + tableId);
+        }
+        String catalogName = String.valueOf(catalogObj).trim();
+        String tableIdentifier = String.valueOf(tableObj).trim();
+        if (catalogName.isEmpty() || tableIdentifier.isEmpty()) {
+          throw new IllegalArgumentException(
+              "tables catalog_name and table_identifier must not be blank");
+        }
+        tables.add(new TableTarget(tableId, catalogName, tableIdentifier));
+      }
+      return tables;
+    } catch (JsonProcessingException e) {
+      throw new IllegalArgumentException("tables must be valid JSON: " + 
e.getMessage(), e);
+    } catch (NumberFormatException e) {
+      throw new IllegalArgumentException("tables table_id must be a long", e);
+    }
+  }
+
+  private static ImmutableList<MaintenanceType> parseMaintenanceTypes(
+      @Nullable String maintenanceTypes) {
+    String chain = 
IcebergJobUtils.nullIfUnresolvedPlaceholder(maintenanceTypes);
+    if (chain == null || chain.trim().isEmpty()) {
+      throw new IllegalArgumentException("maintenance_types is required");
+    }
+    ImmutableList.Builder<MaintenanceType> parsed = ImmutableList.builder();
+    for (String part : chain.split(",")) {
+      String trimmed = part.trim();
+      if (!trimmed.isEmpty()) {
+        parsed.add(MaintenanceType.fromWire(trimmed));
+      }
+    }
+    ImmutableList<MaintenanceType> types = parsed.build();
+    if (types.isEmpty()) {
+      throw new IllegalArgumentException("maintenance_types must not be 
empty");
+    }
+    return types;
+  }
+
+  private static List<Long> parsePolicyIds(@Nullable String policyIds, int 
expectedCount) {
+    String raw = IcebergJobUtils.nullIfUnresolvedPlaceholder(policyIds);
+    if (raw == null || raw.trim().isEmpty()) {
+      throw new IllegalArgumentException("policy_ids is required");
+    }
+    List<Long> parsed = new ArrayList<>();
+    for (String part : raw.split(",")) {
+      String trimmed = part.trim();
+      if (trimmed.isEmpty()) {
+        continue;
+      }
+      try {
+        long policyId = Long.parseLong(trimmed);
+        if (policyId <= 0) {
+          throw new IllegalArgumentException("policy_ids must be positive 
longs: " + trimmed);
+        }
+        parsed.add(policyId);
+      } catch (NumberFormatException e) {
+        throw new IllegalArgumentException("policy_ids must be longs: " + 
trimmed, e);
+      }
+    }
+    if (parsed.size() != expectedCount) {
+      throw new IllegalArgumentException(
+          String.format(
+              "policy_ids count %s must match maintenance_types count %s",
+              parsed.size(), expectedCount));
+    }
+    return parsed;
+  }
+
+  private static String slotKey(long tableId, long policyId) {
+    return tableId + ":" + policyId;
+  }
+
+  private static void runProcedure(
+      SparkSession spark,
+      MaintenanceType type,
+      String catalogName,
+      String tableIdentifier,
+      Map<String, String> argMap)
+      throws Exception {
+    switch (type) {
+      case REWRITE_DATA_FILES:
+        {
+          String strategy = firstNonBlank(argMap.get("strategy"), "binpack");
+          String sql =
+              IcebergRewriteDataFilesJob.buildProcedureCall(
+                  catalogName,
+                  tableIdentifier,
+                  strategy,
+                  
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("sort-order")),
+                  
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("where")),
+                  
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("options")));
+          LOG.info("Executing TMS rewrite_data_files: {}", sql);
+          spark.sql(sql).collect();
+          break;
+        }
+      case REWRITE_MANIFESTS:
+        {
+          IcebergRewriteManifestsJob.validateUseCaching(
+              
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("use-caching")));
+          IcebergRewriteManifestsJob.validateSpecId(
+              
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("spec-id")));
+          String sql =
+              IcebergRewriteManifestsJob.buildProcedureCall(
+                  catalogName,
+                  tableIdentifier,
+                  
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("use-caching")),
+                  
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("spec-id")));
+          LOG.info("Executing TMS rewrite_manifests: {}", sql);
+          spark.sql(sql).collect();
+          break;
+        }
+      case EXPIRE_SNAPSHOTS:
+        {
+          String retainLast =
+              
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("retain-last"));
+          IcebergExpireSnapshotsJob.validateRetainLast(retainLast);
+          boolean streamResults =
+              IcebergExpireSnapshotsJob.parseStreamResults(
+                  
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("stream-results")));
+          String sql =
+              IcebergExpireSnapshotsJob.buildProcedureCall(
+                  catalogName,
+                  tableIdentifier,
+                  
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get("older-than")),
+                  retainLast,
+                  streamResults);
+          LOG.info("Executing TMS expire_snapshots: {}", sql);
+          Row[] results = (Row[]) spark.sql(sql).collect();
+          if (results.length > 0) {
+            LOG.info(
+                "Expire snapshots deleted data/manifest/manifest-list files: 
{}/{}/{}",
+                results[0].getLong(0),
+                results[0].getLong(1),
+                results[0].getLong(2));
+          }
+          break;
+        }
+      case REMOVE_ORPHAN_FILES:
+        {
+          Map<String, String> orphanOpts = new HashMap<>();
+          orphanOpts.put("catalog", catalogName);
+          orphanOpts.put("table", tableIdentifier);
+          putIfPresent(orphanOpts, "older-than", argMap.get("older-than"));
+          putIfPresent(orphanOpts, "location", argMap.get("location"));
+          putIfPresent(orphanOpts, "dry-run", argMap.get("dry-run"));
+          IcebergRemoveOrphanFilesJob.execute(spark, orphanOpts);
+          break;
+        }
+      default:
+        throw new IllegalStateException("Unhandled maintenance type: " + type);
+    }
+  }
+
+  private static SnapshotSample sampleSnapshot(
+      SparkSession spark, String catalogName, String tableIdentifier, 
MaintenanceType type) {
+    // Capture S0 first, then collect metrics (may take time), then re-read 
HEAD for the drift gate.
+    long snapshotId = currentSnapshotId(spark, catalogName, tableIdentifier);
+    Map<String, Object> payload = new HashMap<>();
+    payload.put("maintenanceType", type.wireName());
+    payload.put("snapshotId", snapshotId);
+    if (type == MaintenanceType.REWRITE_DATA_FILES) {
+      payload.putAll(collectCompactionMetrics(spark, catalogName, 
tableIdentifier));
+    }
+    long headSnapshotId = currentSnapshotId(spark, catalogName, 
tableIdentifier);
+    try {
+      return new SnapshotSample(
+          snapshotId, headSnapshotId, 
JsonUtils.anyFieldMapper().writeValueAsString(payload));
+    } catch (JsonProcessingException e) {
+      throw new IllegalStateException("Failed to serialize metrics JSON", e);
+    }
+  }
+
+  /**
+   * Compaction metrics from the Iceberg files metadata table (same formulas 
as {@link
+   * IcebergUpdateStatsAndMetricsJob}). For partitioned tables, uses max 
partition MSE and sum of
+   * delete-file counts so any hot partition can trip {@code trigger-expr}.
+   */
+  private static Map<String, Object> collectCompactionMetrics(
+      SparkSession spark, String catalogName, String tableIdentifier) {
+    boolean partitioned =
+        IcebergUpdateStatsAndMetricsJob.isPartitionedTable(spark, catalogName, 
tableIdentifier);
+    if (partitioned) {
+      String sql =
+          IcebergUpdateStatsAndMetricsJob.buildPartitionStatsSql(catalogName, 
tableIdentifier);
+      Row[] rows = (Row[]) spark.sql(sql).collect();
+      Map<String, Object> metrics = new HashMap<>();
+      double maxMse = 0D;
+      long deleteFiles = 0L;
+      for (Row row : rows) {
+        maxMse = Math.max(maxMse, numberAsDouble(row, "data-file-mse"));
+        deleteFiles += numberAsLong(row, "delete-file-number");
+      }
+      metrics.put(IcebergDataCompactionContent.DATA_FILE_MSE_METRIC, maxMse);
+      metrics.put(IcebergDataCompactionContent.DELETE_FILE_NUMBER_METRIC, 
deleteFiles);
+      return metrics;
+    }
+
+    String sql = 
IcebergUpdateStatsAndMetricsJob.buildTableStatsSql(catalogName, 
tableIdentifier);
+    Row[] rows = (Row[]) spark.sql(sql).collect();
+    Map<String, Object> metrics = new HashMap<>();
+    if (rows.length == 0) {
+      metrics.put(IcebergDataCompactionContent.DATA_FILE_MSE_METRIC, 0D);
+      metrics.put(IcebergDataCompactionContent.DELETE_FILE_NUMBER_METRIC, 0L);
+      return metrics;
+    }
+    Row row = rows[0];
+    metrics.put(
+        IcebergDataCompactionContent.DATA_FILE_MSE_METRIC, numberAsDouble(row, 
"data-file-mse"));
+    metrics.put(
+        IcebergDataCompactionContent.DELETE_FILE_NUMBER_METRIC,
+        numberAsLong(row, "delete-file-number"));
+    return metrics;
+  }
+
+  private static double numberAsDouble(Row row, String field) {
+    Number number = row.getAs(field);
+    return number == null ? 0D : number.doubleValue();
+  }
+
+  private static long numberAsLong(Row row, String field) {
+    Number number = row.getAs(field);
+    return number == null ? 0L : number.longValue();
+  }
+
+  private static long currentSnapshotId(
+      SparkSession spark, String catalogName, String tableIdentifier) {
+    // Catch Exception (not Spark NoSuchTableException) so 
BuiltInJobTemplateProvider can load this
+    // class on the Gravitino server classpath, which does not ship Spark 
catalyst types.
+    try {
+      Table table =
+          Spark3Util.loadIcebergTable(spark, String.format("%s.%s", 
catalogName, tableIdentifier));
+      Snapshot snapshot = table.currentSnapshot();
+      if (snapshot == null) {
+        throw new IllegalStateException(
+            "Iceberg table has no current snapshot: " + catalogName + "." + 
tableIdentifier);
+      }
+      return snapshot.snapshotId();
+    } catch (RuntimeException e) {
+      throw e;
+    } catch (Exception e) {
+      throw new IllegalStateException(
+          "Failed to load Iceberg snapshot for " + catalogName + "." + 
tableIdentifier, e);
+    }
+  }
+
+  private static void finishQuietly(
+      MaintenanceActionHttpClient client,
+      long jobId,
+      long tableId,
+      long policyId,
+      @Nullable Long afterSnapshotId) {
+    try {
+      client.finish(jobId, new MaintenanceActionFinishRequest(tableId, 
policyId, afterSnapshotId));
+    } catch (Exception e) {
+      LOG.warn(
+          "Best-effort TMS finish failed for job {} table {} policy {} 
afterSnapshotId={}",
+          jobId,
+          tableId,
+          policyId,
+          afterSnapshotId,
+          e);
+    }
+  }
+
+  private static void reportMetricsQuietly(
+      MaintenanceActionHttpClient client,
+      long jobId,
+      long tableId,
+      long policyId,
+      SnapshotSample sample,
+      boolean after) {
+    if (sample == null || sample.metricsJson == null || 
sample.metricsJson.trim().isEmpty()) {
+      return;
+    }
+    try {
+      MaintenanceActionMetricsRequest request =
+          new MaintenanceActionMetricsRequest(
+              tableId, policyId, sample.snapshotId, sample.metricsJson);
+      if (after) {
+        client.reportAfterMetrics(jobId, request);
+      } else {
+        client.reportBeforeMetrics(jobId, request);
+      }
+    } catch (Exception e) {
+      LOG.warn(
+          "Best-effort TMS {} metrics report failed for job {} table {} policy 
{} snapshot {}",
+          after ? "after" : "before",
+          jobId,
+          tableId,
+          policyId,
+          sample.snapshotId,
+          e);
+    }
+  }
+
+  private static Map<String, String> parseUpdaterOptions(@Nullable String 
json) {
+    if (json == null || json.trim().isEmpty()) {
+      return Collections.emptyMap();
+    }
+    try {
+      @SuppressWarnings("unchecked")
+      Map<String, String> parsed = JsonUtils.anyFieldMapper().readValue(json, 
Map.class);
+      return parsed == null ? Collections.emptyMap() : parsed;
+    } catch (JsonProcessingException e) {
+      throw new IllegalArgumentException("Invalid updater-options JSON: " + 
e.getMessage(), e);
+    }
+  }
+
+  /**
+   * Prefers {@code GRAVITINO_JOB_ID}. Falls back to parsing a {@code job-} 
plus numeric id segment
+   * from {@code user.dir} (local executor staging directory).
+   */
+  private static long resolveGravitinoJobId() {
+    String envValue = System.getenv(GRAVITINO_JOB_ID_ENV);
+    if (envValue != null && !envValue.trim().isEmpty()) {
+      try {
+        long parsed = Long.parseLong(envValue.trim());
+        if (parsed <= 0) {
+          throw new IllegalStateException(
+              GRAVITINO_JOB_ID_ENV + " must be a positive long: " + envValue);
+        }
+        return parsed;
+      } catch (NumberFormatException e) {
+        throw new IllegalStateException(GRAVITINO_JOB_ID_ENV + " must be a 
long: " + envValue, e);
+      }
+    }
+    String userDir = System.getProperty("user.dir");
+    if (userDir != null && !userDir.trim().isEmpty()) {
+      Path dir = Paths.get(userDir).toAbsolutePath().normalize();
+      for (Path current = dir; current != null; current = current.getParent()) 
{
+        Path fileName = current.getFileName();
+        if (fileName == null) {
+          continue;
+        }
+        String name = fileName.toString();
+        if (!name.startsWith(JobHandle.JOB_ID_PREFIX)) {
+          continue;
+        }
+        try {
+          long parsed = 
Long.parseLong(name.substring(JobHandle.JOB_ID_PREFIX.length()));
+          if (parsed > 0) {
+            return parsed;
+          }
+        } catch (NumberFormatException ignored) {
+          // Keep walking parents; staging dirs use job-<numericId>.
+        }
+      }
+    }
+    throw new IllegalStateException(
+        "Cannot resolve Gravitino job id: set "
+            + GRAVITINO_JOB_ID_ENV
+            + " or run under a job-<id> staging directory");
+  }
+
+  private static void putIfPresent(Map<String, String> target, String key, 
@Nullable String raw) {
+    String value = IcebergJobUtils.nullIfUnresolvedPlaceholder(raw);
+    if (value != null && !value.isEmpty()) {
+      target.put(key, value);
+    }
+  }
+
+  private static String firstNonBlank(@Nullable String value, String 
defaultValue) {
+    String normalized = IcebergJobUtils.nullIfUnresolvedPlaceholder(value);
+    return normalized == null || normalized.isEmpty() ? defaultValue : 
normalized;
+  }
+
+  private static List<String> buildArguments() {
+    // Named args are the Spark job-executor wiring for expanded jobConf — not 
a user CLI.
+    return Arrays.asList(
+        "--maintenance-types",
+        "{{maintenance_types}}",
+        "--policy-ids",
+        "{{policy_ids}}",
+        "--tables",
+        "{{tables}}",
+        "--updater-options",
+        "{{updater_options:-}}",
+        "--strategy",
+        "{{strategy:-binpack}}",
+        "--sort-order",
+        "{{sort_order:-}}",
+        "--where",
+        "{{where_clause:-}}",
+        "--options",
+        "{{options:-}}",
+        "--use-caching",
+        "{{use_caching:-}}",
+        "--spec-id",
+        "{{spec_id:-}}",
+        "--older-than",
+        "{{older_than:-}}",
+        "--retain-last",
+        "{{retain_last:-}}",
+        "--stream-results",
+        "{{stream_results:-false}}",
+        "--location",
+        "{{location:-}}",
+        "--dry-run",
+        "{{dry_run:-false}}",
+        "--spark-conf",
+        "{{spark_conf:-}}",
+        "--rules",
+        "{{rules:-}}");
+  }
+
+  private static final class TableTarget {
+    private final long tableId;
+    private final String catalogName;
+    private final String tableIdentifier;
+
+    private TableTarget(long tableId, String catalogName, String 
tableIdentifier) {
+      this.tableId = tableId;
+      this.catalogName = catalogName;
+      this.tableIdentifier = tableIdentifier;
+    }
+  }
+
+  private static final class SnapshotSample {
+    private final long snapshotId;
+    private final long headSnapshotId;
+    private final String metricsJson;
+
+    private SnapshotSample(long snapshotId, long headSnapshotId, String 
metricsJson) {
+      this.snapshotId = snapshotId;
+      this.headSnapshotId = headSnapshotId;
+      this.metricsJson = metricsJson;
+    }
+  }
+}
diff --git 
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/MaintenanceTriggerGate.java
 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/MaintenanceTriggerGate.java
new file mode 100644
index 0000000000..262785428a
--- /dev/null
+++ 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/MaintenanceTriggerGate.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.jobs.iceberg;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Map.Entry;
+import javax.annotation.Nullable;
+import org.apache.gravitino.json.JsonUtils;
+import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergMaintenanceJob.MaintenanceType;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.util.ExpressionEvaluator;
+import 
org.apache.gravitino.maintenance.optimizer.recommender.util.QLExpressionEvaluator;
+import org.apache.gravitino.policy.IcebergDataCompactionContent;
+import org.apache.gravitino.policy.IcebergOrphanFileRemovalContent;
+import org.apache.gravitino.policy.PolicyContents;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * In-Spark maintain-or-skip gate after S0 metrics (§5.2.2).
+ *
+ * <p>Evaluates enqueue-time policy rules locally with the same QL stack as 
the optimizer. Does not
+ * call Gravitino. Expire / rewrite-manifests placeholders always maintain 
(optimizer-only {@code
+ * trigger-expr=false} on those types).
+ */
+final class MaintenanceTriggerGate {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(MaintenanceTriggerGate.class);
+  private static final ExpressionEvaluator EVALUATOR = new 
QLExpressionEvaluator();
+
+  /** Spark job argument key: JSON map of wire maintenance type → policy 
{@code rules()} object. */
+  static final String RULES_ARG = "rules";
+
+  private MaintenanceTriggerGate() {}
+
+  /**
+   * @param type maintenance type for this sample
+   * @param argMap Spark job arguments (includes optional {@link #RULES_ARG})
+   * @param metricsJson S0 metrics JSON
+   * @return whether the procedure should run
+   */
+  static boolean shouldMaintain(
+      MaintenanceType type, Map<String, String> argMap, @Nullable String 
metricsJson) {
+    if (type == MaintenanceType.EXPIRE_SNAPSHOTS || type == 
MaintenanceType.REWRITE_MANIFESTS) {
+      return true;
+    }
+
+    Map<String, Object> rules = rulesForType(type, argMap);
+    Map<String, Object> metrics = parseNumericMetrics(metricsJson);
+    if (type == MaintenanceType.REWRITE_DATA_FILES
+        && (metrics.isEmpty()
+            || 
!metrics.containsKey(IcebergDataCompactionContent.DATA_FILE_MSE_METRIC)
+            || 
!metrics.containsKey(IcebergDataCompactionContent.DELETE_FILE_NUMBER_METRIC))) {
+      LOG.warn("Compaction recommend skip: metrics JSON missing compaction 
fields");
+      return false;
+    }
+
+    boolean maintain = evaluateTrigger(rules, metrics);
+    LOG.info(
+        "In-Spark recommend type={} expr={} -> {}",
+        type.wireName(),
+        rules.get(IcebergDataCompactionContent.TRIGGER_EXPR_KEY),
+        maintain ? "MAINTAIN" : "SKIP");
+    return maintain;
+  }
+
+  /**
+   * Same binding rules as {@code CompactionTriggerEvaluator}: numeric policy 
rules become QL
+   * bindings, then metric values overlay them, then {@code trigger-expr} is 
evaluated.
+   */
+  private static boolean evaluateTrigger(
+      Map<String, Object> rules, Map<String, Object> metricValues) {
+    Map<String, Object> context = new HashMap<>();
+    rules.forEach(
+        (key, value) -> {
+          if (value == null) {
+            return;
+          }
+          try {
+            context.put(key, Long.parseLong(value.toString()));
+          } catch (NumberFormatException ignored) {
+            // Non-numeric rule values (expressions, strings) are not QL 
bindings.
+          }
+        });
+    context.putAll(metricValues);
+
+    Object trigger = rules.get(IcebergDataCompactionContent.TRIGGER_EXPR_KEY);
+    String expression =
+        trigger == null || trigger.toString().trim().isEmpty()
+            ? "false"
+            : trigger.toString().trim();
+    return EVALUATOR.evaluateBool(expression, context);
+  }
+
+  private static Map<String, Object> rulesForType(
+      MaintenanceType type, Map<String, String> argMap) {
+    Map<String, Map<String, Object>> byType = Collections.emptyMap();
+    if (argMap != null) {
+      String raw = 
IcebergJobUtils.nullIfUnresolvedPlaceholder(argMap.get(RULES_ARG));
+      if (raw != null && !raw.trim().isEmpty()) {
+        try {
+          Map<String, Map<String, Object>> parsed =
+              JsonUtils.anyFieldMapper()
+                  .readValue(raw, new TypeReference<Map<String, Map<String, 
Object>>>() {});
+          if (parsed != null) {
+            byType = parsed;
+          }
+        } catch (IOException | RuntimeException e) {
+          LOG.warn("Failed to parse {}: {}", RULES_ARG, e.toString());
+        }
+      }
+    }
+    Map<String, Object> rules = byType.get(type.wireName());
+    if (rules != null && !rules.isEmpty()) {
+      return rules;
+    }
+    if (type == MaintenanceType.REWRITE_DATA_FILES) {
+      return new HashMap<>(PolicyContents.icebergDataCompaction().rules());
+    }
+    if (type == MaintenanceType.REMOVE_ORPHAN_FILES) {
+      return new HashMap<>(PolicyContents.icebergOrphanFileRemoval().rules());
+    }
+    Map<String, Object> empty = new HashMap<>();
+    empty.put(IcebergOrphanFileRemovalContent.TRIGGER_EXPR_KEY, "false");
+    return empty;
+  }
+
+  private static Map<String, Object> parseNumericMetrics(@Nullable String 
metricsJson) {
+    Map<String, Object> metrics = new HashMap<>();
+    if (metricsJson == null || metricsJson.trim().isEmpty()) {
+      return metrics;
+    }
+    try {
+      JsonNode root = JsonUtils.anyFieldMapper().readTree(metricsJson);
+      Iterator<Entry<String, JsonNode>> fields = root.fields();
+      while (fields.hasNext()) {
+        Entry<String, JsonNode> field = fields.next();
+        JsonNode value = field.getValue();
+        if (value == null || value.isNull() || !value.isNumber()) {
+          continue;
+        }
+        metrics.put(
+            field.getKey(), value.isIntegralNumber() ? value.longValue() : 
value.doubleValue());
+      }
+    } catch (IOException | RuntimeException e) {
+      LOG.warn("Failed to parse metrics JSON: {}", e.toString());
+    }
+    return metrics;
+  }
+}
diff --git 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/TestBuiltInJobTemplateProvider.java
 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/TestBuiltInJobTemplateProvider.java
index 4ed6c75093..c89e3ef4a9 100644
--- 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/TestBuiltInJobTemplateProvider.java
+++ 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/TestBuiltInJobTemplateProvider.java
@@ -111,6 +111,12 @@ public class TestBuiltInJobTemplateProvider {
     expected.put(
         "builtin-iceberg-rewrite-data-files",
         
ImmutableSet.<String>builder().addAll(icebergCatalog).add("where_clause").build());
+    expected.put(
+        "builtin-iceberg-maintenance-task",
+        ImmutableSet.<String>builder()
+            .add("catalog_name", "catalog_type", "catalog_uri", 
"warehouse_location")
+            .add("maintenance_types", "policy_ids", "tables")
+            .build());
 
     for (JobTemplate template : new 
BuiltInJobTemplateProvider().jobTemplates()) {
       Set<String> required = new 
JobTemplateResolver(toEntity(template)).requiredParameters();
diff --git 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/TestMaintenanceActionHttpClient.java
 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/TestMaintenanceActionHttpClient.java
new file mode 100644
index 0000000000..c7fdf4d8ed
--- /dev/null
+++ 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/TestMaintenanceActionHttpClient.java
@@ -0,0 +1,141 @@
+/*
+ * 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.jobs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.sun.net.httpserver.HttpServer;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import 
org.apache.gravitino.maintenance.dto.requests.MaintenanceActionFinishRequest;
+import 
org.apache.gravitino.maintenance.dto.requests.MaintenanceActionMetricsRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link MaintenanceActionHttpClient}. */
+public class TestMaintenanceActionHttpClient {
+
+  private HttpServer server;
+  private final AtomicReference<String> auth = new AtomicReference<>();
+  private final AtomicReference<String> path = new AtomicReference<>();
+  private final AtomicReference<String> body = new AtomicReference<>();
+
+  @BeforeEach
+  public void startServer() throws Exception {
+    server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          auth.set(exchange.getRequestHeaders().getFirst("Authorization"));
+          path.set(exchange.getRequestURI().getPath());
+          body.set(new String(exchange.getRequestBody().readAllBytes(), 
StandardCharsets.UTF_8));
+          byte[] payload = "{\"code\":0}".getBytes(StandardCharsets.UTF_8);
+          int status = 200;
+          if (exchange.getRequestURI().getPath().endsWith("/token")) {
+            payload =
+                
"{\"access_token\":\"tok-1\",\"token_type\":\"bearer\",\"expires_in\":3600}"
+                    .getBytes(StandardCharsets.UTF_8);
+          }
+          if (exchange.getRequestURI().getPath().contains("/fail")) {
+            status = 500;
+            payload = "{\"code\":-1}".getBytes(StandardCharsets.UTF_8);
+          }
+          exchange.sendResponseHeaders(status, payload.length);
+          exchange.getResponseBody().write(payload);
+          exchange.close();
+        });
+    server.start();
+  }
+
+  @AfterEach
+  public void stopServer() {
+    if (server != null) {
+      server.stop(0);
+    }
+  }
+
+  @Test
+  public void testSimpleAuthPostsMetricsAndFinish() {
+    Map<String, String> options = baseOptions();
+    options.put("auth_type", "simple");
+    options.put("username", "alice");
+    try (MaintenanceActionHttpClient client =
+        MaintenanceActionHttpClient.fromUpdaterOptions(options)) {
+      client.reportBeforeMetrics(7L, new MaintenanceActionMetricsRequest(1L, 
20L, 2L, "{}"));
+      assertEquals("/api/metalakes/demo/maintenance/actions/7/metrics/before", 
path.get());
+      assertTrue(body.get().contains("\"tableId\":1"));
+      assertTrue(body.get().contains("\"policyId\":20"));
+      client.finish(7L, new MaintenanceActionFinishRequest(1L, 20L, 11L));
+      assertEquals("/api/metalakes/demo/maintenance/actions/7/finish", 
path.get());
+    }
+    assertEquals(basic("alice", "dummy"), auth.get());
+  }
+
+  @Test
+  public void testOauthFetchesBearerToken() {
+    Map<String, String> options = baseOptions();
+    options.put("auth_type", "oauth");
+    options.put("oauth_server_uri", gravitinoUri());
+    options.put("oauth_path", "oauth/token");
+    options.put("oauth_credential", "id:secret");
+    options.put("oauth_scope", "openid");
+    try (MaintenanceActionHttpClient client =
+        MaintenanceActionHttpClient.fromUpdaterOptions(options)) {
+      client.reportBeforeMetrics(1L, new MaintenanceActionMetricsRequest(1L, 
20L, 2L, "{}"));
+    }
+    assertEquals("Bearer tok-1", auth.get());
+    assertTrue(path.get().endsWith("/metrics/before"));
+  }
+
+  @Test
+  public void testHttpErrorFails() {
+    Map<String, String> options = baseOptions();
+    options.put("gravitino_uri", gravitinoUri() + "/fail");
+    MaintenanceActionHttpClient client = 
MaintenanceActionHttpClient.fromUpdaterOptions(options);
+    assertThrows(
+        IllegalStateException.class,
+        () ->
+            client.reportBeforeMetrics(1L, new 
MaintenanceActionMetricsRequest(1L, 20L, 2L, "{}")));
+    client.close();
+  }
+
+  private Map<String, String> baseOptions() {
+    Map<String, String> options = new HashMap<>();
+    options.put("gravitino_uri", gravitinoUri());
+    options.put("metalake", "demo");
+    return options;
+  }
+
+  private String gravitinoUri() {
+    return "http://127.0.0.1:"; + server.getAddress().getPort();
+  }
+
+  private static String basic(String user, String password) {
+    return "Basic "
+        + Base64.getEncoder()
+            .encodeToString((user + ":" + 
password).getBytes(StandardCharsets.UTF_8));
+  }
+}
diff --git 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergMaintenanceJob.java
 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergMaintenanceJob.java
new file mode 100644
index 0000000000..f942ca3968
--- /dev/null
+++ 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergMaintenanceJob.java
@@ -0,0 +1,83 @@
+/*
+ * 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.jobs.iceberg;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.gravitino.job.SparkJobTemplate;
+import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergMaintenanceJob.MaintenanceType;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link IcebergMaintenanceJob}. */
+public class TestIcebergMaintenanceJob {
+
+  @Test
+  public void testTemplatePlaceholders() {
+    SparkJobTemplate template = new IcebergMaintenanceJob().jobTemplate();
+    assertEquals(IcebergMaintenanceJob.NAME, template.name());
+    List<String> args = template.arguments();
+    assertTrue(args.contains("--maintenance-types"));
+    assertTrue(args.contains("{{maintenance_types}}"));
+    assertFalse(args.contains("{{maintenance_types:-}}"));
+    assertTrue(args.contains("--policy-ids"));
+    assertTrue(args.contains("{{policy_ids}}"));
+    assertTrue(args.contains("--tables"));
+    assertTrue(args.contains("{{tables}}"));
+    assertTrue(args.contains("--rules"));
+    assertTrue(args.contains("{{rules:-}}"));
+    assertFalse(args.contains("--table-id"));
+    assertFalse(args.contains("--gravitino-job-id"));
+  }
+
+  @Test
+  public void testMaintenanceTypeFromWire() {
+    assertEquals(MaintenanceType.EXPIRE_SNAPSHOTS, 
MaintenanceType.fromWire("expire_snapshots"));
+    assertEquals(
+        MaintenanceType.REWRITE_DATA_FILES, 
MaintenanceType.fromWire("REWRITE_DATA_FILES"));
+    assertThrows(IllegalArgumentException.class, () -> 
MaintenanceType.fromWire("unknown"));
+    assertThrows(IllegalArgumentException.class, () -> 
MaintenanceType.fromWire(null));
+  }
+
+  @Test
+  public void testRunRejectsInvalidArgs() {
+    assertEquals(
+        1, IcebergMaintenanceJob.run(new String[] {"--maintenance-types", 
"expire_snapshots"}));
+    assertEquals(
+        1,
+        IcebergMaintenanceJob.run(
+            new String[] {
+              "--maintenance-types",
+              "expire_snapshots,rewrite_manifests",
+              "--policy-ids",
+              "10",
+              "--tables",
+              
"[{\"table_id\":1,\"catalog_name\":\"c\",\"table_identifier\":\"db.t\"}]"
+            }));
+    assertEquals(
+        1,
+        IcebergMaintenanceJob.run(
+            new String[] {
+              "--maintenance-types", "expire_snapshots", "--policy-ids", "10", 
"--tables", "[]"
+            }));
+  }
+}
diff --git 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestMaintenanceTriggerGate.java
 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestMaintenanceTriggerGate.java
new file mode 100644
index 0000000000..10abbe887f
--- /dev/null
+++ 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestMaintenanceTriggerGate.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.jobs.iceberg;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.HashMap;
+import java.util.Map;
+import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergMaintenanceJob.MaintenanceType;
+import org.apache.gravitino.policy.IcebergDataCompactionContent;
+import org.junit.jupiter.api.Test;
+
+/** Unit tests for {@link MaintenanceTriggerGate}. */
+public class TestMaintenanceTriggerGate {
+
+  @Test
+  public void testNonCompactionAlwaysMaintain() {
+    assertTrue(
+        MaintenanceTriggerGate.shouldMaintain(
+            MaintenanceType.EXPIRE_SNAPSHOTS, new HashMap<>(), 
"{\"snapshotId\":1}"));
+  }
+
+  @Test
+  public void testCompactionUsesEnqueueRules() {
+    Map<String, String> args = new HashMap<>();
+    args.put(
+        MaintenanceTriggerGate.RULES_ARG,
+        "{\"rewrite_data_files\":{"
+            + "\"trigger-expr\":\"custom-data-file-mse >= minDataFileMse\","
+            + "\"minDataFileMse\":10}}");
+
+    String below =
+        "{\""
+            + IcebergDataCompactionContent.DATA_FILE_MSE_METRIC
+            + "\":5,\""
+            + IcebergDataCompactionContent.DELETE_FILE_NUMBER_METRIC
+            + "\":0}";
+    assertFalse(
+        
MaintenanceTriggerGate.shouldMaintain(MaintenanceType.REWRITE_DATA_FILES, args, 
below));
+
+    String above =
+        "{\""
+            + IcebergDataCompactionContent.DATA_FILE_MSE_METRIC
+            + "\":11,\""
+            + IcebergDataCompactionContent.DELETE_FILE_NUMBER_METRIC
+            + "\":0}";
+    assertTrue(
+        
MaintenanceTriggerGate.shouldMaintain(MaintenanceType.REWRITE_DATA_FILES, args, 
above));
+  }
+}
diff --git 
a/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/GravitinoAuthSettings.java
 
b/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/GravitinoAuthSettings.java
index fb9640f9cc..2180308c69 100644
--- 
a/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/GravitinoAuthSettings.java
+++ 
b/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/GravitinoAuthSettings.java
@@ -166,6 +166,48 @@ public final class GravitinoAuthSettings {
     return normalizedType();
   }
 
+  /**
+   * @return simple/basic username, or null when unset
+   */
+  public String username() {
+    return username;
+  }
+
+  /**
+   * @return basic password, or null when unset
+   */
+  public String password() {
+    return password;
+  }
+
+  /**
+   * @return OAuth token server URI, or null when unset
+   */
+  public String oauthServerUri() {
+    return oauthServerUri;
+  }
+
+  /**
+   * @return OAuth token path, or null when unset
+   */
+  public String oauthPath() {
+    return oauthPath;
+  }
+
+  /**
+   * @return OAuth client credential ({@code client_id:secret} or secret), or 
null when unset
+   */
+  public String oauthCredential() {
+    return oauthCredential;
+  }
+
+  /**
+   * @return OAuth scope, or null when unset
+   */
+  public String oauthScope() {
+    return oauthScope;
+  }
+
   private String normalizedType() {
     if (StringUtils.isBlank(authType)) {
       return TYPE_NONE;

Reply via email to