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 abac270b35 [#11196] feat(maintenance): Collect manifest statistics in 
single-writer table cycles (#13717)
abac270b35 is described below

commit abac270b35f5231895f6ff28b1ca0d60267d628b
Author: Akshay Thorat <[email protected]>
AuthorDate: Thu Oct 15 00:12:46 2026 -0700

    [#11196] feat(maintenance): Collect manifest statistics in single-writer 
table cycles (#13717)
    
    ### What changes were proposed in this pull request?
    
    Rebase the collector directly onto current main and remove the
    dependency on #13532. Collect every spec from one pinned table snapshot
    with one `allManifests` traversal. Publish both complete maps and
    `custom-manifest-snapshot-id` in one existing PUT/update call.
    
    Default to last-write-wins publication without a coordination directory.
    Consumers requiring fresh statistics must compare the published snapshot
    ID with the table's current snapshot and skip/recollect on mismatch. An
    optional shared coordination directory retains per-table locking through
    publication and explicit recovery after ambiguous writes. Collection
    failures are always rerunnable. The collector forces
    `gravitino.client.retryTimes=0`; other clients retain the default of one
    retry.
    
    ### Why are the changes needed?
    
    Follow the single-writer direction agreed in #13532. Avoid scanning the
    same manifest list once per spec and introducing a public merge API
    solely for this collector. Related to #11196 and design #12942.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Adds `builtin-iceberg-update-manifest-stats` with optional
    `coordination_directory`. Omitted or blank uses last-write-wins; opting
    in requires a shared filesystem supporting durable files and cross-host
    Java/POSIX locks. Collection is table-wide with no `spec_id` option.
    Defined specs without manifests publish zeroes; each update replaces the
    complete maps, removing stale entries. Snapshot ID is -1 when no
    snapshot exists. Automatic policy triggering remains separate.
    
    ### How was this patch tested?
    
    - 222 jobs tests, 19 optimizer API tests, and 30 focused Java client
    tests passed.
    - Coverage includes partition evolution, empty tables, one
    snapshot/manifest pass, independent processes contending for the lock,
    uncoordinated publication and reruns, safe reruns after collection
    failures, ambiguous PUT recovery, auth alias propagation, and HTTP retry
    counts of zero, one, and two.
    - Formatting, jobs Javadocs, `:docs:build`, `rat`, and `git diff
    --check` passed.
    - Cross-host filesystem failure behavior was not exercised; deployments
    must verify shared lock/durability semantics.
---
 .../client/GravitinoClientConfiguration.java       |  15 +
 .../org/apache/gravitino/client/HTTPClient.java    |   5 +
 .../client/TestGravitinoClientConfiguration.java   |  23 ++
 .../apache/gravitino/client/TestHTTPClient.java    |  29 ++
 docs/how-to-use-gravitino-client.md                |   1 +
 .../optimizer-cli-reference.md                     | 108 +++++-
 maintenance/jobs/build.gradle.kts                  |   2 +-
 .../jobs/BuiltInJobTemplateProvider.java           |   2 +
 .../iceberg/IcebergUpdateManifestStatsJob.java     | 306 ++++++++++++++++
 .../jobs/TestBuiltInJobTemplateProvider.java       |   8 +-
 .../iceberg/TestIcebergUpdateManifestStatsJob.java | 198 +++++++++++
 .../TestIcebergUpdateManifestStatsJobMain.java     |  91 +++++
 ...TestIcebergUpdateManifestStatsJobWithSpark.java | 383 +++++++++++++++++++++
 .../common/IcebergManifestStatistics.java          | 129 +++++++
 .../common/util/GravitinoClientUtils.java          |   8 +
 .../common/TestIcebergManifestStatistics.java      |  86 +++++
 .../common/util/TestGravitinoAuthSettings.java     |  17 +
 17 files changed, 1407 insertions(+), 4 deletions(-)

diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClientConfiguration.java
 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClientConfiguration.java
index 03326ed912..edad75e0f2 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClientConfiguration.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/GravitinoClientConfiguration.java
@@ -66,8 +66,12 @@ public class GravitinoClientConfiguration {
   public static final String CLIENT_SOCKET_TIMEOUT_MS =
       GRAVITINO_CLIENT_CONFIG_PREFIX + "socketTimeoutMs";
 
+  /** Maximum automatic HTTP retries after the initial request; zero disables 
retries. */
+  public static final String CLIENT_RETRY_TIMES = 
GRAVITINO_CLIENT_CONFIG_PREFIX + "retryTimes";
+
   private static final Set<String> SUPPORT_CLIENT_CONFIG_KEYS =
       ImmutableSet.of(
+          CLIENT_RETRY_TIMES,
           CLIENT_CONNECTION_TIMEOUT_MS,
           CLIENT_SOCKET_TIMEOUT_MS,
           CLIENT_MAX_CONNECTIONS,
@@ -96,6 +100,17 @@ public class GravitinoClientConfiguration {
     return config;
   }
 
+  /**
+   * Returns the maximum automatic HTTP retries after the initial request.
+   *
+   * @return nonnegative retry count, defaulting to one as in Apache HttpClient
+   */
+  public int getClientRetryTimes() {
+    int retryTimes = MapUtils.propertyAsInt(properties, CLIENT_RETRY_TIMES, 1);
+    checkValue(value -> value >= 0, CLIENT_RETRY_TIMES, retryTimes, 
POSITIVE_NUMBER_ERROR_MSG);
+    return retryTimes;
+  }
+
   /**
    * Extract connection timeout from the properties map
    *
diff --git 
a/clients/client-java/src/main/java/org/apache/gravitino/client/HTTPClient.java 
b/clients/client-java/src/main/java/org/apache/gravitino/client/HTTPClient.java
index f7ff051433..daf3cc50dd 100644
--- 
a/clients/client-java/src/main/java/org/apache/gravitino/client/HTTPClient.java
+++ 
b/clients/client-java/src/main/java/org/apache/gravitino/client/HTTPClient.java
@@ -48,6 +48,7 @@ import org.apache.gravitino.rest.RESTUtils;
 import org.apache.hc.client5.http.classic.methods.HttpUriRequest;
 import org.apache.hc.client5.http.classic.methods.HttpUriRequestBase;
 import org.apache.hc.client5.http.config.ConnectionConfig;
+import org.apache.hc.client5.http.impl.DefaultHttpRequestRetryStrategy;
 import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
 import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
 import org.apache.hc.client5.http.impl.classic.HttpClientBuilder;
@@ -68,6 +69,7 @@ import org.apache.hc.core5.http.message.BasicHeader;
 import org.apache.hc.core5.io.CloseMode;
 import org.apache.hc.core5.net.URIBuilder;
 import org.apache.hc.core5.reactor.ssl.SSLBufferMode;
+import org.apache.hc.core5.util.TimeValue;
 
 /**
  * An HttpClient for usage with the REST catalog.
@@ -148,6 +150,9 @@ public class HTTPClient implements RESTClient {
         GravitinoClientConfiguration.buildFromProperties(properties);
 
     HttpClientBuilder clientBuilder = HttpClients.custom();
+    clientBuilder.setRetryStrategy(
+        new DefaultHttpRequestRetryStrategy(
+            clientConfiguration.getClientRetryTimes(), 
TimeValue.ofSeconds(1)));
     clientBuilder.setConnectionManager(
         configureConnectionManager(clientConfiguration, tlsConfigurer));
 
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoClientConfiguration.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoClientConfiguration.java
index 20e10ab133..fb70b28e11 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoClientConfiguration.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestGravitinoClientConfiguration.java
@@ -24,6 +24,29 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 
 public class TestGravitinoClientConfiguration {
+  @Test
+  void testAutomaticRetryConfiguration() {
+    Assertions.assertEquals(
+        1,
+        
GravitinoClientConfiguration.buildFromProperties(ImmutableMap.of()).getClientRetryTimes());
+    for (int count : new int[] {0, 1, 3}) {
+      Assertions.assertEquals(
+          count,
+          GravitinoClientConfiguration.buildFromProperties(
+                  ImmutableMap.of(
+                      GravitinoClientConfiguration.CLIENT_RETRY_TIMES, 
Integer.toString(count)))
+              .getClientRetryTimes());
+    }
+    for (String invalid : new String[] {"-1", "invalid", "1.5", "2147483648"}) 
{
+      Assertions.assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              GravitinoClientConfiguration.buildFromProperties(
+                      
ImmutableMap.of(GravitinoClientConfiguration.CLIENT_RETRY_TIMES, invalid))
+                  .getClientRetryTimes());
+    }
+  }
+
   @Test
   void testValidConfig() {
     Map<String, String> properties =
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestHTTPClient.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestHTTPClient.java
index 3d8515be9b..dc5db152f5 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/TestHTTPClient.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/TestHTTPClient.java
@@ -54,6 +54,7 @@ import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import org.mockserver.integration.ClientAndServer;
+import org.mockserver.model.HttpError;
 import org.mockserver.model.HttpRequest;
 import org.mockserver.model.HttpResponse;
 
@@ -100,6 +101,34 @@ public class TestHTTPClient {
     testHttpMethodOnSuccess(Method.PUT, true, true);
   }
 
+  /** Verifies ambiguous PUT connection failures can be surfaced without an 
automatic resend. */
+  @Test
+  public void testPutRetryTimes() throws Exception {
+    for (int retryTimes : new int[] {0, 1, 2}) {
+      String path = "put-dropped-connection-" + retryTimes;
+      HttpRequest expected = request("/" + path).withMethod("PUT");
+      
mockServer.when(expected).error(HttpError.error().withDropConnection(true));
+      try (RESTClient client =
+          HTTPClient.builder(
+                  ImmutableMap.of(
+                      GravitinoClientConfiguration.CLIENT_RETRY_TIMES,
+                      Integer.toString(retryTimes)))
+              .uri("http://127.0.0.1:"; + mockServer.getPort())
+              .build()) {
+        Assertions.assertThrows(
+            RESTException.class,
+            () ->
+                client.put(
+                    path,
+                    new Item(1L, "stats"),
+                    Item.class,
+                    ImmutableMap.of(),
+                    mock(ErrorHandler.class)));
+      }
+      Assertions.assertEquals(retryTimes + 1, 
mockServer.retrieveRecordedRequests(expected).length);
+    }
+  }
+
   @Test
   public void testPutFailure() throws Exception {
     testHttpMethodOnFailure(Method.PUT, true, true);
diff --git a/docs/how-to-use-gravitino-client.md 
b/docs/how-to-use-gravitino-client.md
index 04bc0f2a6b..d0d1135f90 100644
--- a/docs/how-to-use-gravitino-client.md
+++ b/docs/how-to-use-gravitino-client.md
@@ -42,6 +42,7 @@ GravitinoAdminClient gravitinoAdminClient = 
GravitinoAdminClient.builder("http:/
 | Configuration item                     | Description                         
                 | Default value       | Required |
 
|----------------------------------------|------------------------------------------------------|---------------------|----------|
 | `gravitino.client.connectionTimeoutMs` | An optional http connection timeout 
in milliseconds. | `180000`(3 minutes) | No       |
+| `gravitino.client.retryTimes` | Maximum automatic HTTP retries after the 
initial request. Must be a nonnegative integer; `0` disables retries. | `1` | 
No |
 | `gravitino.client.socketTimeoutMs`     | An optional http socket timeout in 
milliseconds.     | `180000`(3 minutes) | No       |
 
 **Note:** Invalid configuration properties will result in exceptions.
diff --git a/docs/table-maintenance-service/optimizer-cli-reference.md 
b/docs/table-maintenance-service/optimizer-cli-reference.md
index 26c989d799..90614f575b 100644
--- a/docs/table-maintenance-service/optimizer-cli-reference.md
+++ b/docs/table-maintenance-service/optimizer-cli-reference.md
@@ -237,7 +237,7 @@ EvaluationResult{scopeType=TABLE, 
identifier=rest_catalog.db.t1, partitionPath=<
 
 ## Built-in Job Templates
 
-Five job templates ship with the service, and they are complementary rather 
than alternatives. A full maintenance pass collects statistics, compacts data 
files, expires the snapshot history that compaction just created, consolidates 
manifests, and removes old orphan files.
+The following Iceberg job templates ship with the service. A full maintenance 
pass collects statistics, compacts data files, expires the snapshot history 
that compaction just created, consolidates manifests, and removes old orphan 
files.
 
 | Job template                          | What it does                         
    |
 
|---------------------------------------|-------------------------------------------|
@@ -246,6 +246,7 @@ Five job templates ship with the service, and they are 
complementary rather than
 | `builtin-iceberg-expire-snapshots`    | Removes old snapshot metadata        
     |
 | `builtin-iceberg-remove-orphan-files` | Removes unreferenced files from 
storage   |
 | `builtin-iceberg-rewrite-manifests`   | Consolidates small manifest files    
     |
+| `builtin-iceberg-update-manifest-stats` | Collects complete per-spec 
manifest maps |
 
 Each can be submitted directly over REST, and the first two are also what the 
policy-driven workflow submits on your behalf. See [Quick 
Start](./optimizer.md#walkthrough) for the policy-driven path.
 
@@ -282,6 +283,111 @@ table, so it must be asked for explicitly.
 
 Its `jobConf` is documented in 
[Configuration](./optimizer-configuration.md#job-submission-configuration).
 
+## Update Manifest Statistics
+
+`builtin-iceberg-update-manifest-stats` is a separate Spark job that collects 
two table-level
+statistics, including for partitioned tables:
+
+| Statistic | Object entry for each decimal spec ID |
+| --- | --- |
+| `custom-manifest-number-by-spec` | Long manifest count |
+| `custom-avg-manifest-size-by-spec` | Double average manifest size in bytes |
+
+The job reads manifest metadata without scanning file statistics. It works 
after partition
+evolution, including for specs whose partition fields are absent from older 
files.
+`builtin-iceberg-update-stats` and the `submit-update-stats-job` CLI retain 
their existing behavior;
+they do not collect manifest statistics. Submit the new template directly 
through the jobs API.
+
+Each table cycle pins one current snapshot, calls `allManifests` once, and 
groups data and
+delete manifests by `partitionSpecId`. Every defined spec is included. A spec 
with no manifests,
+including a table without a snapshot, produces count `0` and average `0.0`.
+The job also publishes `custom-manifest-snapshot-id` in the same update (`-1` 
without a snapshot).
+Snapshot IDs identify measurements; they are not monotonically increasing 
version numbers.
+
+The template accepts `catalog_name`, `table_identifier`, `updater_options`, 
and optional
+`coordination_directory` and `spark_conf`, alongside the standard Spark 
template settings. There is no `spec_id` filter:
+collection always replaces the complete maps for the table.
+`updater_options` is a JSON string containing `gravitino_uri` and `metalake`; 
it can also select
+a `statistics_updater` implementing the existing table statistics update 
operation.
+For example:
+
+```bash
+curl -X POST http://localhost:8090/api/metalakes/test/jobs/runs \
+  -H 'Content-Type: application/json' \
+  -d '{
+    "jobTemplateName": "builtin-iceberg-update-manifest-stats",
+    "jobConf": {
+      "catalog_name": "rest_catalog",
+      "table_identifier": "db.t1",
+      "updater_options": 
"{\"gravitino_uri\":\"http://localhost:8090\",\"metalake\":\"test\"}";,
+      "spark_master": "local[2]",
+      "spark_executor_instances": "1",
+      "spark_executor_cores": "1",
+      "spark_executor_memory": "1g",
+      "spark_driver_memory": "1g",
+      "catalog_type": "rest",
+      "catalog_uri": "http://localhost:9001/iceberg";,
+      "warehouse_location": ""
+    }
+  }'
+```
+
+For example, one collection can produce complete maps `{"0": 620, "1": 120}` 
and
+`{"0": 10485760.0, "1": 4194304.0}`. The collector owns both statistic names 
and publishes
+them together with the snapshot ID in one existing PUT/update call. Old map 
entries are
+replaced, not merged. Other statistic names are unchanged. No PATCH or merge 
API is needed.
+
+By default, `coordination_directory` is omitted or blank and publications use 
last-write-wins
+semantics. Each update contains the complete maps and snapshot ID; a 
concurrent older collection
+may overwrite newer measurements. Failed jobs can simply be rerun, with no 
lock files or recovery.
+Consumers requiring fresh statistics must compare 
`custom-manifest-snapshot-id` with the table's
+current snapshot ID (`-1` if none) and skip/recollect on a mismatch. This is a 
consumer requirement,
+not a freshness check enforced by this collector. Snapshot IDs must be 
compared for equality,
+not numerical ordering.
+
+### Optional shared coordination and recovery
+
+For stricter serialization, provision `coordination_directory` 
(`--coordination-dir` for direct Spark invocation) as an
+existing absolute directory on a shared filesystem that supports durable files 
and cross-host
+Java/POSIX file locks. Every runner for a destination table must use the same 
shared directory,
+metalake, and canonical catalog/schema/table names. A host-local directory, 
object-store mount,
+or filesystem without reliable cross-host locking does not satisfy this 
requirement.
+All writers of these statistic names must opt into this mode and use the same 
directory;
+mixing coordinated and uncoordinated writers does not provide serialization.
+
+The job locks a persistent file keyed by metalake and table before refreshing 
the Iceberg table
+or choosing a snapshot, and holds it through the PUT. Concurrent runs for that 
table fail;
+other tables can proceed. Snapshot IDs are recorded for provenance, not 
compared numerically.
+The job forces `gravitino.client.retryTimes=0` for its Gravitino client.
+Custom statistics updaters and any intervening proxies must likewise avoid 
retrying PUT requests;
+an automatic resend could hide an original request that is still executing. 
There are no expiring
+leases, automatic retries, scheduling, or cooldowns.
+
+Collection failures can be rerun normally: the lock is released without 
leaving a recovery marker.
+After collection and immediately before calling the updater, the job durably 
writes an in-progress
+marker. It clears the marker only after a successful update. A crash after the 
marker is written or
+an update failure leaves the marker in place, even after the operating system 
releases the lock.
+The updater interface cannot distinguish a failed request from a request still 
executing on the
+server, so these failures require recovery. The error reports the recovery 
file path.
+
+For example, run A sends a PUT and loses its connection while the server is 
still processing it.
+An immediate rerun B could publish newer measurements before A's delayed PUT 
overwrites them.
+The file lock prevents concurrent collection across hosts; the marker prevents 
this overlap after
+an ambiguous failure. Snapshot IDs provide provenance, but the existing PUT 
does not enforce
+snapshot ordering or fencing. Automatic reruns after ambiguous writes would 
require server-side
+conditional publication or an equivalent guarantee that the earlier request 
has ended.
+
+For recovery, stop all runners for that table and verify the previous process 
has terminated
+and any outstanding Gravitino PUT has completed or been definitively 
cancelled. While holding
+an exclusive lock on the reported file, truncate it to zero bytes and flush it 
to durable storage,
+then resume collection. Never delete or replace the file: a new inode can 
permit two independent
+locks. If the previous PUT outcome cannot be established, leave the marker 
intact.
+
+Consumers must read both objects from one table-statistics response and use 
the same chosen
+spec key in each. A missing object or key means collection is required, not 
zero manifests.
+`IcebergManifestStatistics.fromStatistics` returns an empty result for an 
incomplete pair.
+Automatic manifest policy triggering is separate follow-up work.
+
 ## Rewrite Data Files
 
 `builtin-iceberg-rewrite-data-files` performs the compaction itself, merging 
small data files into larger ones. It is what a compaction policy submits when 
its thresholds are crossed.
diff --git a/maintenance/jobs/build.gradle.kts 
b/maintenance/jobs/build.gradle.kts
index 49615a7d97..ae5055bda8 100644
--- a/maintenance/jobs/build.gradle.kts
+++ b/maintenance/jobs/build.gradle.kts
@@ -36,6 +36,7 @@ val sparkMajorVersion = "3.5"
 
 dependencies {
   implementation(project(":api"))
+  implementation(project(":clients:client-java"))
   implementation(project(":maintenance:optimizer-api"))
   implementation(project(":maintenance:updaters"))
 
@@ -56,7 +57,6 @@ dependencies {
   testImplementation(project(":common"))
   // Used to check that the placeholders of the built-in templates resolve the 
way the server does.
   testImplementation(project(":core"))
-  testImplementation(project(":clients:client-java"))
   testImplementation(libs.bundles.log4j)
   testImplementation(libs.hadoop3.common) {
     exclude("org.slf4j")
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 df7cae0888..6b26dce55e 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
@@ -29,6 +29,7 @@ import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergExpireSnapshotsJob;
 import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergRemoveOrphanFilesJob;
 import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergRewriteDataFilesJob;
 import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergRewriteManifestsJob;
+import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergUpdateManifestStatsJob;
 import 
org.apache.gravitino.maintenance.jobs.iceberg.IcebergUpdateStatsAndMetricsJob;
 import org.apache.gravitino.maintenance.jobs.spark.SparkPiJob;
 import org.slf4j.Logger;
@@ -50,6 +51,7 @@ public class BuiltInJobTemplateProvider implements 
JobTemplateProvider {
           new IcebergRewriteDataFilesJob(),
           new IcebergRewriteManifestsJob(),
           new IcebergUpdateStatsAndMetricsJob(),
+          new IcebergUpdateManifestStatsJob(),
           new IcebergExpireSnapshotsJob(),
           new IcebergRemoveOrphanFilesJob());
 
diff --git 
a/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateManifestStatsJob.java
 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateManifestStatsJob.java
new file mode 100644
index 0000000000..2f398aa7d4
--- /dev/null
+++ 
b/maintenance/jobs/src/main/java/org/apache/gravitino/maintenance/jobs/iceberg/IcebergUpdateManifestStatsJob.java
@@ -0,0 +1,306 @@
+/*
+ * 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.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.hash.Hashing;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.client.GravitinoClientConfiguration;
+import org.apache.gravitino.job.JobTemplateProvider;
+import org.apache.gravitino.job.SparkJobTemplate;
+import org.apache.gravitino.maintenance.jobs.BuiltInJob;
+import org.apache.gravitino.maintenance.optimizer.api.common.StatisticEntry;
+import 
org.apache.gravitino.maintenance.optimizer.api.updater.StatisticsUpdater;
+import 
org.apache.gravitino.maintenance.optimizer.common.IcebergManifestStatistics;
+import org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
+import org.apache.gravitino.maintenance.optimizer.common.StatisticEntryImpl;
+import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
+import 
org.apache.gravitino.maintenance.optimizer.common.util.GravitinoAuthSettings;
+import 
org.apache.gravitino.maintenance.optimizer.common.util.IcebergSparkConfigUtils;
+import org.apache.gravitino.maintenance.optimizer.common.util.ProviderUtils;
+import org.apache.gravitino.stats.StatisticValue;
+import org.apache.gravitino.stats.StatisticValues;
+import org.apache.iceberg.ManifestFile;
+import org.apache.iceberg.Snapshot;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.spark.Spark3Util;
+import org.apache.spark.sql.SparkSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Built-in job for collecting all Iceberg manifest statistics in one table 
cycle. */
+public class IcebergUpdateManifestStatsJob implements BuiltInJob {
+  private static final Logger LOG = 
LoggerFactory.getLogger(IcebergUpdateManifestStatsJob.class);
+  private static final String NAME =
+      JobTemplateProvider.BUILTIN_NAME_PREFIX + 
"iceberg-update-manifest-stats";
+  private static final String DEFAULT_STATISTICS_UPDATER = 
"gravitino-statistics-updater";
+
+  @Override
+  public SparkJobTemplate jobTemplate() {
+    return SparkJobTemplate.builder()
+        .withName(NAME)
+        .withComment("Collect manifest count and average size for every 
Iceberg partition spec")
+        .withExecutable(resolveExecutable(IcebergUpdateManifestStatsJob.class))
+        .withClassName(IcebergUpdateManifestStatsJob.class.getName())
+        .withArguments(
+            Arrays.asList(
+                "--catalog",
+                "{{catalog_name}}",
+                "--table",
+                "{{table_identifier}}",
+                "--coordination-dir",
+                "{{coordination_directory:-}}",
+                "--updater-options",
+                "{{updater_options}}",
+                "--spark-conf",
+                "{{spark_conf:-}}"))
+        .withConfigs(IcebergSparkConfigUtils.buildTemplateSparkConfigs())
+        
.withCustomFields(Collections.singletonMap(JobTemplateProvider.PROPERTY_VERSION_KEY,
 "v1"))
+        .build();
+  }
+
+  /**
+   * Collects and persists manifest statistics using the configured Spark 
catalog and updater.
+   *
+   * @param args catalog, table, optional shared coordination directory, 
updater options, and Spark
+   *     configuration
+   * @throws Exception if collection, persistence, or resource cleanup fails
+   */
+  public static void main(String[] args) throws Exception {
+    Map<String, String> arguments =
+        IcebergJobUtils.parseArguments(
+            args,
+            ImmutableSet.of(
+                "catalog", "table", "updater-options", "spark-conf", 
"coordination-dir"),
+            ImmutableSet.of("catalog", "table", "updater-options"));
+    String coordinationDirectory = arguments.get("coordination-dir");
+    Path coordinationPath =
+        coordinationDirectory == null || coordinationDirectory.trim().isEmpty()
+            ? null
+            : Paths.get(coordinationDirectory);
+    String catalog = arguments.get("catalog");
+    String table = arguments.get("table");
+    tableIdentifier(catalog, table);
+    Map<String, String> options =
+        IcebergSparkConfigUtils.parseFlatJsonMap(
+            arguments.get("updater-options"), "updater-options");
+    Map<String, String> properties = buildOptimizerProperties(options);
+    Map<String, String> sparkConfigs =
+        IcebergSparkConfigUtils.parseFlatJsonMap(arguments.get("spark-conf"), 
"spark-conf");
+    try (StatisticsUpdater updater =
+        ProviderUtils.createStatisticsUpdaterInstance(
+            options.getOrDefault("statistics_updater", 
DEFAULT_STATISTICS_UPDATER).trim())) {
+      updater.initialize(new OptimizerEnv(new OptimizerConfig(properties)));
+      SparkSession.Builder builder =
+          SparkSession.builder().appName("Gravitino Iceberg Manifest 
Statistics");
+      sparkConfigs.forEach(builder::config);
+      SparkSession spark = builder.getOrCreate();
+      try {
+        IcebergJobUtils.requireIcebergSparkRuntime();
+        updateStatistics(
+            spark,
+            updater,
+            catalog,
+            table,
+            properties.get(OptimizerConfig.GRAVITINO_METALAKE),
+            coordinationPath);
+      } finally {
+        spark.stop();
+      }
+    }
+  }
+
+  /**
+   * Collects complete per-spec measurements from one pinned table snapshot.
+   *
+   * @param spark Spark session with the Iceberg catalog configured
+   * @param catalogName Spark catalog name
+   * @param tableIdentifier schema.table identifier
+   * @return both complete maps and the snapshot ID (-1 for a table without a 
snapshot)
+   */
+  public static List<StatisticEntry<?>> collectManifestStatistics(
+      SparkSession spark, String catalogName, String tableIdentifier) {
+    String identifier = buildTableIdentifier(catalogName, tableIdentifier);
+    final Table table;
+    try {
+      table = Spark3Util.loadIcebergTable(spark, identifier);
+      table.refresh();
+    } catch (Exception e) {
+      throw new IllegalArgumentException("Cannot load Iceberg table " + 
identifier, e);
+    }
+    return collectManifestStatistics(table);
+  }
+
+  @VisibleForTesting
+  static List<StatisticEntry<?>> collectManifestStatistics(Table table) {
+    Snapshot snapshot = table.currentSnapshot();
+    Map<Integer, Long> counts = new HashMap<>();
+    Map<Integer, Double> sizes = new HashMap<>();
+    table.specs().keySet().forEach(spec -> counts.put(spec, 0L));
+    if (snapshot != null) {
+      for (ManifestFile manifest : snapshot.allManifests(table.io())) {
+        int spec = manifest.partitionSpecId();
+        counts.merge(spec, 1L, Long::sum);
+        sizes.merge(spec, (double) manifest.length(), Double::sum);
+      }
+    }
+    Map<String, StatisticValue<?>> countValues = new HashMap<>();
+    Map<String, StatisticValue<?>> averageValues = new HashMap<>();
+    counts.forEach(
+        (spec, count) -> {
+          String key = Integer.toString(spec);
+          countValues.put(key, StatisticValues.longValue(count));
+          averageValues.put(
+              key, StatisticValues.doubleValue(count == 0 ? 0D : 
sizes.get(spec) / count));
+        });
+    return Arrays.asList(
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.MANIFEST_NUMBER, 
StatisticValues.objectValue(countValues)),
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.AVG_MANIFEST_SIZE,
+            StatisticValues.objectValue(averageValues)),
+        new StatisticEntryImpl<>(
+            IcebergManifestStatistics.SNAPSHOT_ID,
+            StatisticValues.longValue(snapshot == null ? -1L : 
snapshot.snapshotId())));
+  }
+
+  @VisibleForTesting
+  static void updateStatistics(
+      SparkSession spark,
+      StatisticsUpdater updater,
+      String catalogName,
+      String tableIdentifier,
+      String metalake,
+      @Nullable Path coordinationDirectory)
+      throws IOException {
+    NameIdentifier identifier = tableIdentifier(catalogName, tableIdentifier);
+    if (coordinationDirectory == null) {
+      updater.updateTableStatistics(
+          identifier, collectManifestStatistics(spark, catalogName, 
tableIdentifier));
+      LOG.info("Updated complete manifest statistics for {}", identifier);
+      return;
+    }
+    if (!coordinationDirectory.isAbsolute() || 
!Files.isDirectory(coordinationDirectory)) {
+      throw new IllegalArgumentException(
+          "coordination-dir must be an existing absolute shared directory");
+    }
+    String key =
+        Hashing.sha256()
+            .hashString(
+                metalake + "\0" + catalogName + "\0" + tableIdentifier, 
StandardCharsets.UTF_8)
+            .toString();
+    Path lockPath = coordinationDirectory.resolve(key + ".lock");
+    // Never delete lock files: replacing the inode lets two hosts lock 
different files.
+    try (FileChannel channel =
+            FileChannel.open(
+                lockPath,
+                StandardOpenOption.CREATE,
+                StandardOpenOption.READ,
+                StandardOpenOption.WRITE);
+        FileLock lock = channel.tryLock()) {
+      if (lock == null) {
+        throw new IllegalStateException("Manifest collection is already 
running for " + identifier);
+      }
+      if (channel.size() != 0) {
+        throw new IllegalStateException(
+            "Unfinished manifest collection requires recovery: " + lockPath);
+      }
+      List<StatisticEntry<?>> measurements =
+          collectManifestStatistics(spark, catalogName, tableIdentifier);
+      byte[] marker =
+          ("In-progress manifest collection for "
+                  + metalake
+                  + "."
+                  + identifier
+                  + "; verify the previous process and any outstanding PUT 
have finished before recovery.\n")
+              .getBytes(StandardCharsets.UTF_8);
+      ByteBuffer buffer = ByteBuffer.wrap(marker);
+      while (buffer.hasRemaining()) {
+        channel.write(buffer);
+      }
+      channel.force(true);
+      updater.updateTableStatistics(identifier, measurements);
+      // A failed or ambiguous PUT leaves the marker intact, preventing an 
unsafe automatic retry.
+      channel.truncate(0);
+      channel.force(true);
+      LOG.info("Updated complete manifest statistics for {}", identifier);
+    }
+  }
+
+  @VisibleForTesting
+  static Map<String, String> buildOptimizerProperties(Map<String, String> 
options) {
+    Map<String, String> properties = new HashMap<>(options);
+    if (options.containsKey("gravitino_uri")) {
+      properties.put(OptimizerConfig.GRAVITINO_URI, 
options.get("gravitino_uri"));
+    }
+    if (options.containsKey("metalake")) {
+      properties.put(OptimizerConfig.GRAVITINO_METALAKE, 
options.get("metalake"));
+    }
+    for (String key :
+        Arrays.asList(OptimizerConfig.GRAVITINO_URI, 
OptimizerConfig.GRAVITINO_METALAKE)) {
+      String value = properties.get(key);
+      if (value == null || value.trim().isEmpty()) {
+        throw new IllegalArgumentException("updater_options must configure " + 
key);
+      }
+      properties.put(key, value.trim());
+    }
+    // A retried PUT can hide an older request that is still executing after a 
connection failure.
+    properties.put(GravitinoClientConfiguration.CLIENT_RETRY_TIMES, "0");
+    GravitinoAuthSettings.copyAliases(properties);
+    return properties;
+  }
+
+  @VisibleForTesting
+  static String buildTableIdentifier(String catalogName, String tableName) {
+    NameIdentifier identifier = tableIdentifier(catalogName, tableName);
+    return IcebergJobUtils.escapeSqlIdentifier(identifier.namespace().level(0))
+        + "."
+        + IcebergJobUtils.escapeSqlIdentifier(identifier.namespace().level(1))
+        + "."
+        + IcebergJobUtils.escapeSqlIdentifier(identifier.name());
+  }
+
+  private static NameIdentifier tableIdentifier(
+      @Nullable String catalogName, @Nullable String tableName) {
+    if (catalogName == null || catalogName.trim().isEmpty() || tableName == 
null) {
+      throw new IllegalArgumentException("--catalog and --table are required");
+    }
+    String[] levels = tableName.split("\\.", -1);
+    if (levels.length != 2 || levels[0].trim().isEmpty() || 
levels[1].trim().isEmpty()) {
+      throw new IllegalArgumentException("--table must use schema.table 
format: " + tableName);
+    }
+    return NameIdentifier.of(catalogName, levels[0], levels[1]);
+  }
+}
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 7f38c34966..4ed6c75093 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
@@ -89,8 +89,9 @@ public class TestBuiltInJobTemplateProvider {
   @Test
   public void testTemplatesOnlyRequireTheParametersTheyHaveTo() {
     // A built-in template must let a caller run it with the catalog and the 
table alone, so every
-    // other parameter needs a default value. The catalog connection has no 
default that fits every
-    // catalog, and an empty where clause rewrites a whole table, so those 
stay required.
+    // other parameter needs a default value. Catalog and updater connections 
have no defaults that
+    // fit every deployment, and an empty where clause rewrites a whole table, 
so those stay
+    // required.
     Map<String, Set<String>> expected = new HashMap<>();
     Set<String> icebergCatalog =
         ImmutableSet.of(
@@ -101,6 +102,9 @@ public class TestBuiltInJobTemplateProvider {
             "warehouse_location");
     expected.put("builtin-sparkpi", ImmutableSet.of());
     expected.put("builtin-iceberg-update-stats", icebergCatalog);
+    expected.put(
+        "builtin-iceberg-update-manifest-stats",
+        
ImmutableSet.<String>builder().addAll(icebergCatalog).add("updater_options").build());
     expected.put("builtin-iceberg-expire-snapshots", icebergCatalog);
     expected.put("builtin-iceberg-remove-orphan-files", icebergCatalog);
     expected.put("builtin-iceberg-rewrite-manifests", icebergCatalog);
diff --git 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJob.java
 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJob.java
new file mode 100644
index 0000000000..24b1efe30e
--- /dev/null
+++ 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJob.java
@@ -0,0 +1,198 @@
+/*
+ * 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.assertNotNull;
+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.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.client.GravitinoClientConfiguration;
+import org.apache.gravitino.job.JobTemplate;
+import org.apache.gravitino.job.SparkJobTemplate;
+import org.apache.gravitino.maintenance.jobs.BuiltInJobTemplateProvider;
+import org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
+import org.apache.gravitino.maintenance.optimizer.common.StatisticEntryImpl;
+import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
+import 
org.apache.gravitino.maintenance.optimizer.updater.statistics.GravitinoStatisticsUpdater;
+import org.apache.gravitino.stats.StatisticValues;
+import org.junit.jupiter.api.Test;
+
+/** Tests the standalone manifest statistics template and argument validation. 
*/
+public class TestIcebergUpdateManifestStatsJob {
+  /** Verifies manifest statistics use an independent built-in template. */
+  @Test
+  public void testIndependentTemplateRegistration() {
+    Map<String, JobTemplate> templates =
+        new BuiltInJobTemplateProvider()
+            .jobTemplates().stream()
+                .collect(Collectors.toMap(JobTemplate::name, template -> 
template));
+    SparkJobTemplate template =
+        (SparkJobTemplate) 
templates.get("builtin-iceberg-update-manifest-stats");
+    assertNotNull(template);
+    assertEquals(IcebergUpdateManifestStatsJob.class.getName(), 
template.className());
+    assertEquals(10, template.arguments().size());
+    assertFalse(template.arguments().contains("--spec-id"));
+    assertTrue(template.arguments().contains("{{spark_conf:-}}"));
+    assertTrue(template.arguments().contains("{{coordination_directory:-}}"));
+    assertFalse(template.arguments().contains("--update-mode"));
+    assertEquals("v1", template.customFields().get("version"));
+    assertTrue(template.configs().containsKey("spark.sql.extensions"));
+    SparkJobTemplate existing = (SparkJobTemplate) 
templates.get("builtin-iceberg-update-stats");
+    assertNotNull(existing);
+    assertFalse(existing.arguments().contains("--spec-id"));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> IcebergUpdateStatsAndMetricsJob.parseUpdateMode("manifests"));
+  }
+
+  /** Verifies catalog and table identifiers are validated and escaped. */
+  @Test
+  public void testTableIdentifierValidationAndEscaping() {
+    assertEquals(
+        "`cat``alog`.`db`.`tbl``name`",
+        IcebergUpdateManifestStatsJob.buildTableIdentifier("cat`alog", 
"db.tbl`name"));
+    for (String invalid : Arrays.asList("table", "db.", ".table", 
"db.table.extra", " ")) {
+      assertThrows(
+          IllegalArgumentException.class,
+          () -> IcebergUpdateManifestStatsJob.buildTableIdentifier("catalog", 
invalid));
+    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> IcebergUpdateManifestStatsJob.main(new String[] {"--catalog", 
"catalog"}));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> IcebergUpdateManifestStatsJob.main(new String[] {"--table", 
"db.table"}));
+  }
+
+  /** Verifies short auth options reach HTTP requests made by the real 
statistics updater. */
+  @Test
+  public void testUpdaterSendsBasicAuthFromAliases() throws Exception {
+    AtomicReference<String> authorization = new AtomicReference<>();
+    HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 
0), 0);
+    server.createContext(
+        "/",
+        exchange -> {
+          
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
+          exchange.sendResponseHeaders(401, -1);
+          exchange.close();
+        });
+    server.start();
+    try (GravitinoStatisticsUpdater updater = new 
GravitinoStatisticsUpdater()) {
+      Map<String, String> options = new HashMap<>();
+      options.put("gravitino_uri", "http://127.0.0.1:"; + 
server.getAddress().getPort());
+      options.put("metalake", "test");
+      options.put("auth_type", "basic");
+      options.put("username", "alice");
+      options.put("password", "test-password");
+      updater.initialize(
+          new OptimizerEnv(
+              new OptimizerConfig(
+                  
IcebergUpdateManifestStatsJob.buildOptimizerProperties(options))));
+      // Stop at the HTTP boundary after capturing the credentials sent by the 
updater.
+      assertThrows(
+          RuntimeException.class,
+          () ->
+              updater.updateTableStatistics(
+                  NameIdentifier.of("catalog", "db", "table"),
+                  Collections.singletonList(
+                      new StatisticEntryImpl<>("custom-count", 
StatisticValues.longValue(1)))));
+      assertEquals(
+          "Basic "
+              + Base64.getEncoder()
+                  
.encodeToString("alice:test-password".getBytes(StandardCharsets.UTF_8)),
+          authorization.get());
+    } finally {
+      server.stop(0);
+    }
+  }
+
+  /** Verifies auth aliases are promoted without overriding canonical 
settings. */
+  @Test
+  public void testAuthAliases() {
+    Map<String, String> options = new HashMap<>();
+    options.put("gravitino_uri", "http://localhost:8090";);
+    options.put("metalake", "test");
+    options.put("auth_type", "oauth");
+    options.put("oauth_server_uri", "http://localhost:9000";);
+    options.put("oauth_path", "token");
+    options.put("oauth_credential", "client:secret");
+    options.put("oauth_scope", "catalog");
+    Map<String, String> properties =
+        IcebergUpdateManifestStatsJob.buildOptimizerProperties(options);
+    assertEquals("oauth", properties.get(OptimizerConfig.AUTH_TYPE));
+    assertEquals("http://localhost:9000";, 
properties.get(OptimizerConfig.AUTH_OAUTH_SERVER_URI));
+    assertEquals("token", properties.get(OptimizerConfig.AUTH_OAUTH_PATH));
+    assertEquals("client:secret", 
properties.get(OptimizerConfig.AUTH_OAUTH_CREDENTIAL));
+    assertEquals("catalog", properties.get(OptimizerConfig.AUTH_OAUTH_SCOPE));
+    assertFalse(options.containsKey(OptimizerConfig.AUTH_TYPE));
+    options.put(OptimizerConfig.AUTH_TYPE, "none");
+    assertEquals(
+        "none",
+        IcebergUpdateManifestStatsJob.buildOptimizerProperties(options)
+            .get(OptimizerConfig.AUTH_TYPE));
+  }
+
+  /** Verifies updater configuration aliases and required properties. */
+  @Test
+  public void testUpdaterConfiguration() {
+    Map<String, String> options = new HashMap<>();
+    options.put("gravitino_uri", " http://localhost:8090 ");
+    options.put("metalake", " test ");
+    options.put(GravitinoClientConfiguration.CLIENT_RETRY_TIMES, "2");
+    Map<String, String> properties =
+        IcebergUpdateManifestStatsJob.buildOptimizerProperties(options);
+    assertEquals("0", 
properties.get(GravitinoClientConfiguration.CLIENT_RETRY_TIMES));
+    assertEquals("http://localhost:8090";, 
properties.get(OptimizerConfig.GRAVITINO_URI));
+    assertEquals("test", properties.get(OptimizerConfig.GRAVITINO_METALAKE));
+    assertEquals(" test ", options.get("metalake"));
+    properties.remove("metalake");
+    properties.remove("gravitino_uri");
+    assertEquals(properties, 
IcebergUpdateManifestStatsJob.buildOptimizerProperties(properties));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> 
IcebergUpdateManifestStatsJob.buildOptimizerProperties(Collections.emptyMap()));
+    options.put("metalake", " ");
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> IcebergUpdateManifestStatsJob.buildOptimizerProperties(options));
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            IcebergUpdateManifestStatsJob.main(
+                new String[] {"--catalog", "catalog", "--table", "db.table", 
"--spec-id", "-1"}));
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            IcebergUpdateManifestStatsJob.main(
+                new String[] {"--catalog", "catalog", "--table", "db.table"}));
+  }
+}
diff --git 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJobMain.java
 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJobMain.java
new file mode 100644
index 0000000000..27a4c9ef95
--- /dev/null
+++ 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJobMain.java
@@ -0,0 +1,91 @@
+/*
+ * 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.assertTrue;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Tests manifest statistics job startup in a Spark environment without 
Iceberg. */
+public class TestIcebergUpdateManifestStatsJobMain {
+  @TempDir Path tempDir;
+
+  /** Verifies a missing Iceberg runtime fails with an actionable classpath 
error. */
+  @Test
+  public void testMissingRuntimeHasActionableError() throws Exception {
+    checkMissingRuntime(false);
+  }
+
+  /** Verifies an explicitly blank coordination directory reaches normal job 
startup. */
+  @Test
+  public void testBlankCoordinationDirectory() throws Exception {
+    checkMissingRuntime(true);
+  }
+
+  private void checkMissingRuntime(boolean blankCoordination) throws Exception 
{
+    String classpath =
+        
Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator))
+            .filter(entry -> !entry.contains("iceberg-spark-runtime"))
+            .collect(Collectors.joining(File.pathSeparator));
+    List<String> command = new ArrayList<>();
+    command.add(new File(System.getProperty("java.home"), 
"bin/java").toString());
+    command.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED");
+    command.add("-cp");
+    command.add(classpath);
+    command.add(IcebergUpdateManifestStatsJob.class.getName());
+    command.addAll(
+        Arrays.asList(
+            "--catalog",
+            "cli",
+            "--table",
+            "db.cli",
+            "--updater-options",
+            
"{\"gravitino_uri\":\"http://localhost:8090\",\"metalake\":\"test\"}";,
+            "--spark-conf",
+            "{\"spark.master\":\"local[1]\",\"spark.ui.enabled\":\"false\","
+                + "\"spark.sql.extensions\":"
+                + 
"\"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions\"}"));
+    if (blankCoordination) {
+      command.addAll(Arrays.asList("--coordination-dir", " "));
+    }
+    Path log = tempDir.resolve("missing-runtime.log");
+    Process process =
+        new 
ProcessBuilder(command).redirectErrorStream(true).redirectOutput(log.toFile()).start();
+    try {
+      assertTrue(process.waitFor(45, TimeUnit.SECONDS), "Manifest statistics 
job did not exit");
+      String output = new String(Files.readAllBytes(log), 
StandardCharsets.UTF_8);
+      assertEquals(1, process.exitValue(), output);
+      assertTrue(output.contains("Missing Iceberg Spark session extensions"), 
output);
+      assertTrue(output.contains("iceberg-spark-runtime"), output);
+    } finally {
+      process.destroyForcibly();
+    }
+  }
+}
diff --git 
a/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJobWithSpark.java
 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJobWithSpark.java
new file mode 100644
index 0000000000..ff05e76b59
--- /dev/null
+++ 
b/maintenance/jobs/src/test/java/org/apache/gravitino/maintenance/jobs/iceberg/TestIcebergUpdateManifestStatsJobWithSpark.java
@@ -0,0 +1,383 @@
+/*
+ * 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.io.File;
+import java.lang.reflect.Proxy;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Stream;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.maintenance.optimizer.api.common.PartitionPath;
+import org.apache.gravitino.maintenance.optimizer.api.common.StatisticEntry;
+import 
org.apache.gravitino.maintenance.optimizer.api.updater.StatisticsUpdater;
+import 
org.apache.gravitino.maintenance.optimizer.common.IcebergManifestStatistics;
+import org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
+import org.apache.gravitino.stats.StatisticValue;
+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.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/** Tests manifest statistics collection against real Spark and Iceberg 
tables. */
+public class TestIcebergUpdateManifestStatsJobWithSpark {
+  private static final String CATALOG_NAME = "manifest_catalog";
+  @TempDir static File tempDir;
+  private static SparkSession spark;
+
+  /** Starts Spark with a local Iceberg catalog for the tests. */
+  @BeforeAll
+  public static void setUp() {
+    spark =
+        SparkSession.builder()
+            .master("local[2]")
+            .appName("TestIcebergUpdateManifestStatsJob")
+            .config(
+                "spark.sql.extensions",
+                
"org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
+            .config("spark.sql.catalog." + CATALOG_NAME, 
"org.apache.iceberg.spark.SparkCatalog")
+            .config("spark.sql.catalog." + CATALOG_NAME + ".type", "hadoop")
+            .config(
+                "spark.sql.catalog." + CATALOG_NAME + ".warehouse",
+                new File(tempDir, "warehouse").getAbsolutePath())
+            .getOrCreate();
+    spark.sql("CREATE NAMESPACE " + CATALOG_NAME + ".db");
+  }
+
+  /** Stops the shared Spark session. */
+  @AfterAll
+  public static void tearDown() {
+    if (spark != null) {
+      spark.stop();
+    }
+  }
+
+  /** Verifies all specs are measured together and published in one 
replacement update. */
+  @Test
+  public void testManifestStatisticsAcrossPartitionEvolution() throws 
Exception {
+    String name = CATALOG_NAME + ".db.manifest_evolution";
+    spark.sql("CREATE TABLE " + name + " (id INT, ds STRING) USING iceberg 
PARTITIONED BY (ds)");
+    try {
+      spark.sql("INSERT INTO " + name + " VALUES (1, 'a'), (2, 'b')");
+      spark.sql("ALTER TABLE " + name + " ADD PARTITION FIELD bucket(4, id)");
+      spark.sql("INSERT INTO " + name + " VALUES (3, 'c')");
+      spark.sql("ALTER TABLE " + name + " ADD PARTITION FIELD truncate(2, 
ds)");
+      Table table = Spark3Util.loadIcebergTable(spark, name);
+      Snapshot pinned = table.currentSnapshot();
+      AtomicInteger snapshotReads = new AtomicInteger();
+      AtomicInteger manifestReads = new AtomicInteger();
+      Snapshot snapshot =
+          (Snapshot)
+              Proxy.newProxyInstance(
+                  Snapshot.class.getClassLoader(),
+                  new Class<?>[] {Snapshot.class},
+                  (proxy, method, args) -> {
+                    if (method.getName().equals("allManifests")) {
+                      manifestReads.incrementAndGet();
+                    }
+                    return method.invoke(pinned, args);
+                  });
+      Table countedTable =
+          (Table)
+              Proxy.newProxyInstance(
+                  Table.class.getClassLoader(),
+                  new Class<?>[] {Table.class},
+                  (proxy, method, args) -> {
+                    if (method.getName().equals("currentSnapshot")) {
+                      snapshotReads.incrementAndGet();
+                      return snapshot;
+                    }
+                    return method.invoke(table, args);
+                  });
+      IcebergUpdateManifestStatsJob.collectManifestStatistics(countedTable);
+      assertEquals(1, snapshotReads.get());
+      assertEquals(1, manifestReads.get());
+      RecordingStatisticsUpdater updater = new RecordingStatisticsUpdater();
+      IcebergUpdateManifestStatsJob.updateStatistics(
+          spark, updater, CATALOG_NAME, "db.manifest_evolution", "test", 
tempDir.toPath());
+      assertEquals(1, updater.updateCalls);
+      assertEquals(3, updater.manifestStatistics.size());
+      Map<String, StatisticValue<?>> values = new HashMap<>();
+      updater.manifestStatistics.forEach(stat -> values.put(stat.name(), 
stat.value()));
+      for (Row expected :
+          spark
+              .sql(
+                  "SELECT partition_spec_id, COUNT(*), AVG(length) FROM "
+                      + name
+                      + ".manifests GROUP BY partition_spec_id")
+              .collectAsList()) {
+        IcebergManifestStatistics actual =
+            IcebergManifestStatistics.fromStatistics(values, 
expected.getInt(0)).get();
+        assertEquals(expected.getLong(1), actual.count());
+        assertEquals(expected.getDouble(2), actual.averageSize());
+      }
+      IcebergManifestStatistics empty = 
IcebergManifestStatistics.fromStatistics(values, 2).get();
+      assertEquals(0L, empty.count());
+      assertEquals(0D, empty.averageSize());
+      assertFalse(IcebergManifestStatistics.fromStatistics(values, 
Integer.MAX_VALUE).isPresent());
+      long snapshotId =
+          spark
+              .sql("SELECT snapshot_id FROM " + name + ".snapshots ORDER BY 
committed_at DESC")
+              .first()
+              .getLong(0);
+      assertEquals(snapshotId, 
values.get(IcebergManifestStatistics.SNAPSHOT_ID).value());
+    } finally {
+      spark.sql("DROP TABLE " + name);
+    }
+  }
+
+  /** Verifies an empty table publishes a complete zero-valued measurement. */
+  @Test
+  public void testPublishesEmptyUnpartitionedTable() throws Exception {
+    String name = CATALOG_NAME + ".db.empty";
+    spark.sql("CREATE TABLE " + name + " (id INT) USING iceberg");
+    try {
+      RecordingStatisticsUpdater updater = new RecordingStatisticsUpdater();
+      IcebergUpdateManifestStatsJob.updateStatistics(
+          spark, updater, CATALOG_NAME, "db.empty", "test", tempDir.toPath());
+      assertEquals(NameIdentifier.of(CATALOG_NAME, "db", "empty"), 
updater.identifier);
+      assertEquals(1, updater.updateCalls);
+      Map<String, StatisticValue<?>> values = new HashMap<>();
+      updater.manifestStatistics.forEach(stat -> values.put(stat.name(), 
stat.value()));
+      IcebergManifestStatistics result = 
IcebergManifestStatistics.fromStatistics(values, 0).get();
+      assertEquals(0L, result.count());
+      assertEquals(0D, result.averageSize());
+      assertEquals(-1L, 
values.get(IcebergManifestStatistics.SNAPSHOT_ID).value());
+    } finally {
+      spark.sql("DROP TABLE " + name);
+    }
+  }
+
+  /** Verifies uncoordinated publications replace complete measurements and 
can be rerun. */
+  @Test
+  public void testPublicationWithoutCoordination() throws Exception {
+    String name = CATALOG_NAME + ".db.uncoordinated";
+    spark.sql("CREATE TABLE " + name + " (id INT) USING iceberg");
+    RecordingStatisticsUpdater updater = new RecordingStatisticsUpdater();
+    try {
+      IcebergUpdateManifestStatsJob.updateStatistics(
+          spark, updater, CATALOG_NAME, "db.uncoordinated", "test", null);
+      assertEquals(1, updater.updateCalls);
+      assertEquals(3, updater.manifestStatistics.size());
+      spark.sql("INSERT INTO " + name + " VALUES (1)");
+      updater.fail = true;
+      assertThrows(
+          IllegalStateException.class,
+          () ->
+              IcebergUpdateManifestStatsJob.updateStatistics(
+                  spark, updater, CATALOG_NAME, "db.uncoordinated", "test", 
null));
+      updater.fail = false;
+      IcebergUpdateManifestStatsJob.updateStatistics(
+          spark, updater, CATALOG_NAME, "db.uncoordinated", "test", null);
+      assertEquals(3, updater.updateCalls);
+      Map<String, StatisticValue<?>> values = new HashMap<>();
+      updater.manifestStatistics.forEach(stat -> values.put(stat.name(), 
stat.value()));
+      assertEquals(3, values.size());
+      assertEquals(
+          Spark3Util.loadIcebergTable(spark, 
name).currentSnapshot().snapshotId(),
+          values.get(IcebergManifestStatistics.SNAPSHOT_ID).value());
+      assertEquals(1L, IcebergManifestStatistics.fromStatistics(values, 
0).get().count());
+    } finally {
+      spark.sql("DROP TABLE " + name);
+    }
+  }
+
+  /** Verifies a collection failure can be rerun without manually recovering 
the lock file. */
+  @Test
+  public void testCollectionFailureCanBeRerun() throws Exception {
+    Path directory = 
Files.createDirectory(tempDir.toPath().resolve("collection-failure"));
+    RecordingStatisticsUpdater updater = new RecordingStatisticsUpdater();
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            IcebergUpdateManifestStatsJob.updateStatistics(
+                spark, updater, CATALOG_NAME, "db.retry_collection", "test", 
directory));
+    assertEquals(0, updater.updateCalls);
+    spark.sql("CREATE TABLE " + CATALOG_NAME + ".db.retry_collection (id INT) 
USING iceberg");
+    try {
+      IcebergUpdateManifestStatsJob.updateStatistics(
+          spark, updater, CATALOG_NAME, "db.retry_collection", "test", 
directory);
+      assertEquals(1, updater.updateCalls);
+    } finally {
+      spark.sql("DROP TABLE " + CATALOG_NAME + ".db.retry_collection");
+    }
+  }
+
+  /** Verifies separate job processes share the lock and failed writes block 
later cycles. */
+  @Test
+  public void testSharedCoordinationAndFailedWriteRecovery() throws Exception {
+    String name = CATALOG_NAME + ".db.coordinated";
+    spark.sql("CREATE TABLE " + name + " (id INT) USING iceberg");
+    Path directory = 
Files.createDirectory(tempDir.toPath().resolve("coordination"));
+    RecordingStatisticsUpdater updater = new RecordingStatisticsUpdater();
+    try {
+      assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              IcebergUpdateManifestStatsJob.updateStatistics(
+                  spark, updater, CATALOG_NAME, "db.coordinated", "test", 
Paths.get("relative")));
+      assertThrows(
+          IllegalArgumentException.class,
+          () ->
+              IcebergUpdateManifestStatsJob.updateStatistics(
+                  spark,
+                  updater,
+                  CATALOG_NAME,
+                  "db.coordinated",
+                  "test",
+                  directory.resolve("missing")));
+      assertEquals(0, updater.updateCalls);
+      IcebergUpdateManifestStatsJob.updateStatistics(
+          spark, updater, CATALOG_NAME, "db.coordinated", "test", directory);
+      Path lockPath;
+      try (Stream<Path> files = Files.list(directory)) {
+        lockPath = files.findFirst().get();
+      }
+      Path ready = directory.resolve("ready");
+      Process otherHost =
+          new ProcessBuilder(
+                  new File(System.getProperty("java.home"), 
"bin/java").toString(), "-cp",
+                  System.getProperty("java.class.path"), 
LockHolder.class.getName(),
+                  lockPath.toString(), ready.toString())
+              .redirectErrorStream(true)
+              .redirectOutput(directory.resolve("lock-holder.log").toFile())
+              .start();
+      try {
+        Awaitility.await().atMost(15, TimeUnit.SECONDS).until(() -> 
Files.exists(ready));
+        assertThrows(
+            IllegalStateException.class,
+            () ->
+                IcebergUpdateManifestStatsJob.updateStatistics(
+                    spark, updater, CATALOG_NAME, "db.coordinated", "test", 
directory));
+        assertEquals(1, updater.updateCalls);
+        // Independent table targets do not share a coordination lock.
+        spark.sql("CREATE TABLE " + CATALOG_NAME + ".db.independent (id INT) 
USING iceberg");
+        IcebergUpdateManifestStatsJob.updateStatistics(
+            spark, updater, CATALOG_NAME, "db.independent", "test", directory);
+        assertEquals(2, updater.updateCalls);
+      } finally {
+        otherHost.destroyForcibly();
+        assertTrue(otherHost.waitFor(10, TimeUnit.SECONDS));
+      }
+      updater.fail = true;
+      assertThrows(
+          IllegalStateException.class,
+          () ->
+              IcebergUpdateManifestStatsJob.updateStatistics(
+                  spark, updater, CATALOG_NAME, "db.coordinated", "test", 
directory));
+      assertEquals(3, updater.updateCalls);
+      updater.fail = false;
+      IllegalStateException unfinished =
+          assertThrows(
+              IllegalStateException.class,
+              () ->
+                  IcebergUpdateManifestStatsJob.updateStatistics(
+                      spark, updater, CATALOG_NAME, "db.coordinated", "test", 
directory));
+      assertTrue(unfinished.getMessage().contains("requires recovery"));
+      assertEquals(3, updater.updateCalls);
+      // Recovery is explicit and preserves the shared inode, after the prior 
write has ended.
+      try (FileChannel channel = FileChannel.open(lockPath, 
StandardOpenOption.WRITE);
+          FileLock lock = channel.lock()) {
+        channel.truncate(0);
+        channel.force(true);
+      }
+      IcebergUpdateManifestStatsJob.updateStatistics(
+          spark, updater, CATALOG_NAME, "db.coordinated", "test", directory);
+      assertEquals(4, updater.updateCalls);
+      assertEquals(0, Files.size(lockPath));
+    } finally {
+      spark.sql("DROP TABLE " + name);
+      spark.sql("DROP TABLE IF EXISTS " + CATALOG_NAME + ".db.independent");
+    }
+  }
+
+  /** Holds the shared filesystem lock from a separate JVM to model another 
job host. */
+  public static class LockHolder {
+    /**
+     * Holds the lock until this test subprocess is terminated.
+     *
+     * @param args lock path and readiness path
+     * @throws Exception if coordination fails
+     */
+    public static void main(String[] args) throws Exception {
+      try (FileChannel channel = FileChannel.open(Paths.get(args[0]), 
StandardOpenOption.WRITE);
+          FileLock lock = channel.lock()) {
+        Files.write(Paths.get(args[1]), new byte[] {1});
+        new CountDownLatch(1).await();
+      }
+    }
+  }
+
+  private static final class RecordingStatisticsUpdater implements 
StatisticsUpdater {
+    private NameIdentifier identifier;
+    private int updateCalls;
+    private boolean fail;
+    private List<StatisticEntry<?>> manifestStatistics = 
Collections.emptyList();
+
+    @Override
+    public String name() {
+      return "recording-updater";
+    }
+
+    @Override
+    public void initialize(OptimizerEnv env) {}
+
+    @Override
+    public void updateTableStatistics(
+        NameIdentifier identifier, List<StatisticEntry<?>> statistics) {
+      this.identifier = identifier;
+      this.manifestStatistics = statistics;
+      updateCalls++;
+      if (fail) {
+        throw new IllegalStateException("Ambiguous PUT failure");
+      }
+    }
+
+    @Override
+    public void updatePartitionStatistics(
+        NameIdentifier identifier, Map<PartitionPath, List<StatisticEntry<?>>> 
statistics) {
+      throw new AssertionError("Manifest statistics must be table-level 
statistics");
+    }
+
+    @Override
+    public void close() {}
+  }
+}
diff --git 
a/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/IcebergManifestStatistics.java
 
b/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/IcebergManifestStatistics.java
new file mode 100644
index 0000000000..11f7851bb7
--- /dev/null
+++ 
b/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/IcebergManifestStatistics.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.gravitino.maintenance.optimizer.common;
+
+import java.util.Map;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import org.apache.gravitino.stats.StatisticValue;
+import org.apache.gravitino.stats.StatisticValues;
+
+/** A complete pair of manifest measurements for one resolved Iceberg 
partition spec. */
+public final class IcebergManifestStatistics {
+  /** Object-valued manifest counts keyed by decimal spec ID. */
+  public static final String MANIFEST_NUMBER = 
"custom-manifest-number-by-spec";
+  /** Object-valued average manifest sizes in bytes keyed by decimal spec ID. 
*/
+  public static final String AVG_MANIFEST_SIZE = 
"custom-avg-manifest-size-by-spec";
+
+  /** Snapshot ID shared by both complete maps, or -1 for a table without a 
snapshot. */
+  public static final String SNAPSHOT_ID = "custom-manifest-snapshot-id";
+
+  private final int specId;
+  private final long count;
+  private final double averageSize;
+
+  /**
+   * Creates a complete measurement for a resolved spec.
+   *
+   * @param specId non-negative resolved spec ID
+   * @param count non-negative manifest count
+   * @param averageSize finite non-negative average size in bytes, zero for no 
manifests
+   */
+  public IcebergManifestStatistics(int specId, long count, double averageSize) 
{
+    if (specId < 0
+        || count < 0
+        || !Double.isFinite(averageSize)
+        || averageSize < 0
+        || (count == 0 && averageSize != 0)) {
+      throw new IllegalArgumentException("Invalid manifest measurements");
+    }
+    this.specId = specId;
+    this.count = count;
+    this.averageSize = averageSize;
+  }
+
+  /**
+   * Returns the partition spec used for collection.
+   *
+   * @return the resolved spec ID to retain through evaluation and job 
submission
+   */
+  public int specId() {
+    return specId;
+  }
+
+  /**
+   * Returns the collected manifest count.
+   *
+   * @return manifest count for this spec
+   */
+  public long count() {
+    return count;
+  }
+
+  /**
+   * Returns the collected average manifest size.
+   *
+   * @return average manifest size in bytes for this spec
+   */
+  public double averageSize() {
+    return averageSize;
+  }
+
+  /**
+   * Reads a complete pair from one table-statistics response. Callers must 
not combine responses
+   * from separate reads. An absent measurement means collection is required, 
not zero manifests.
+   *
+   * @param statistics values from one atomic table-statistics read
+   * @param specId the previously resolved spec ID
+   * @return a complete measurement, or empty when either spec entry is absent
+   */
+  public static Optional<IcebergManifestStatistics> fromStatistics(
+      Map<String, StatisticValue<?>> statistics, int specId) {
+    if (specId < 0) {
+      throw new IllegalArgumentException("Spec ID must be non-negative");
+    }
+    StatisticValue<?> count = entry(statistics.get(MANIFEST_NUMBER), specId);
+    StatisticValue<?> average = entry(statistics.get(AVG_MANIFEST_SIZE), 
specId);
+    if (count == null || average == null) {
+      return Optional.empty();
+    }
+    if (!(count instanceof StatisticValues.LongValue)
+        || !(average instanceof StatisticValues.DoubleValue)) {
+      throw new IllegalArgumentException(
+          "Manifest measurements must be a long count and double size");
+    }
+    return Optional.of(
+        new IcebergManifestStatistics(
+            specId,
+            ((StatisticValues.LongValue) count).value(),
+            ((StatisticValues.DoubleValue) average).value()));
+  }
+
+  @Nullable
+  private static StatisticValue<?> entry(@Nullable StatisticValue<?> value, 
int specId) {
+    if (value == null) {
+      return null;
+    }
+    if (!(value instanceof StatisticValues.ObjectValue)) {
+      throw new IllegalArgumentException("Manifest statistics must be object 
values");
+    }
+    return ((StatisticValues.ObjectValue) 
value).value().get(Integer.toString(specId));
+  }
+}
diff --git 
a/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/GravitinoClientUtils.java
 
b/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/GravitinoClientUtils.java
index 9154f8fc80..c9e014dd47 100644
--- 
a/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/GravitinoClientUtils.java
+++ 
b/maintenance/optimizer-api/src/main/java/org/apache/gravitino/maintenance/optimizer/common/util/GravitinoClientUtils.java
@@ -20,10 +20,12 @@
 package org.apache.gravitino.maintenance.optimizer.common.util;
 
 import com.google.common.base.Preconditions;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import javax.annotation.Nullable;
 import org.apache.gravitino.client.GravitinoClient;
+import org.apache.gravitino.client.GravitinoClientConfiguration;
 import org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
 import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
 
@@ -64,6 +66,12 @@ public final class GravitinoClientUtils {
     String metalake = config.get(OptimizerConfig.GRAVITINO_METALAKE_CONFIG);
     GravitinoClient.ClientBuilder builder = 
GravitinoClient.builder(uri).withMetalake(metalake);
 
+    String retryTimes = 
config.getRawString(GravitinoClientConfiguration.CLIENT_RETRY_TIMES);
+    if (retryTimes != null) {
+      builder.withClientConfig(
+          
Collections.singletonMap(GravitinoClientConfiguration.CLIENT_RETRY_TIMES, 
retryTimes));
+    }
+
     OptimizerConfig authConfig = config;
     if (authFromUpdaterOptions != null && !authFromUpdaterOptions.isEmpty()) {
       Map<String, String> authProperties = new 
HashMap<>(authFromUpdaterOptions);
diff --git 
a/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/TestIcebergManifestStatistics.java
 
b/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/TestIcebergManifestStatistics.java
new file mode 100644
index 0000000000..3ce7379c09
--- /dev/null
+++ 
b/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/TestIcebergManifestStatistics.java
@@ -0,0 +1,86 @@
+/*
+ * 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.optimizer.common;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.gravitino.stats.StatisticValue;
+import org.apache.gravitino.stats.StatisticValues;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests validation and per-spec lookup of manifest statistics. */
+public class TestIcebergManifestStatistics {
+  @Test
+  void testMissingAndEmptyMeasurements() {
+    Map<String, StatisticValue<?>> values = new HashMap<>();
+    Assertions.assertFalse(IcebergManifestStatistics.fromStatistics(values, 
2).isPresent());
+    values.putAll(measurement(2, 0L, 0D));
+    IcebergManifestStatistics empty = 
IcebergManifestStatistics.fromStatistics(values, 2).get();
+    Assertions.assertEquals(2, empty.specId());
+    Assertions.assertEquals(0L, empty.count());
+    Assertions.assertEquals(0D, empty.averageSize());
+    Assertions.assertFalse(IcebergManifestStatistics.fromStatistics(values, 
1).isPresent());
+    values.remove(IcebergManifestStatistics.AVG_MANIFEST_SIZE);
+    Assertions.assertFalse(IcebergManifestStatistics.fromStatistics(values, 
2).isPresent());
+    values.put(
+        IcebergManifestStatistics.AVG_MANIFEST_SIZE,
+        StatisticValues.objectValue(
+            Collections.singletonMap("1", StatisticValues.doubleValue(10D))));
+    Assertions.assertFalse(IcebergManifestStatistics.fromStatistics(values, 
2).isPresent());
+  }
+
+  @Test
+  void testInvalidMeasurements() {
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new 
IcebergManifestStatistics(-1, 1, 1));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new IcebergManifestStatistics(1, 
-1, 1));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new IcebergManifestStatistics(1, 
0, 1));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> new IcebergManifestStatistics(1, 
1, Double.NaN));
+    Map<String, StatisticValue<?>> values = new HashMap<>();
+    values.putAll(measurement(1, 3L, 20D));
+    Assertions.assertEquals(3L, 
IcebergManifestStatistics.fromStatistics(values, 1).get().count());
+    values.put(
+        IcebergManifestStatistics.MANIFEST_NUMBER,
+        StatisticValues.objectValue(
+            Collections.singletonMap("1", StatisticValues.doubleValue(3D))));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
IcebergManifestStatistics.fromStatistics(values, 1));
+    values.put(IcebergManifestStatistics.MANIFEST_NUMBER, 
StatisticValues.longValue(3));
+    Assertions.assertThrows(
+        IllegalArgumentException.class, () -> 
IcebergManifestStatistics.fromStatistics(values, 1));
+  }
+
+  private static Map<String, StatisticValue<?>> measurement(int spec, long 
count, double size) {
+    Map<String, StatisticValue<?>> values = new HashMap<>();
+    values.put(
+        IcebergManifestStatistics.MANIFEST_NUMBER,
+        StatisticValues.objectValue(
+            Collections.singletonMap(Integer.toString(spec), 
StatisticValues.longValue(count))));
+    values.put(
+        IcebergManifestStatistics.AVG_MANIFEST_SIZE,
+        StatisticValues.objectValue(
+            Collections.singletonMap(Integer.toString(spec), 
StatisticValues.doubleValue(size))));
+    return values;
+  }
+}
diff --git 
a/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/util/TestGravitinoAuthSettings.java
 
b/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/util/TestGravitinoAuthSettings.java
index b6cc121923..bf2ef0ddd2 100644
--- 
a/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/util/TestGravitinoAuthSettings.java
+++ 
b/maintenance/optimizer-api/src/test/java/org/apache/gravitino/maintenance/optimizer/common/util/TestGravitinoAuthSettings.java
@@ -22,6 +22,8 @@ package 
org.apache.gravitino.maintenance.optimizer.common.util;
 import java.util.HashMap;
 import java.util.Map;
 import org.apache.gravitino.client.GravitinoClient;
+import org.apache.gravitino.client.GravitinoClientConfiguration;
+import org.apache.gravitino.maintenance.optimizer.common.OptimizerEnv;
 import org.apache.gravitino.maintenance.optimizer.common.conf.OptimizerConfig;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
@@ -29,6 +31,21 @@ import org.mockito.Mockito;
 
 class TestGravitinoAuthSettings {
 
+  @Test
+  void testClientReceivesRetryConfiguration() {
+    Map<String, String> properties = new HashMap<>();
+    properties.put(OptimizerConfig.GRAVITINO_URI, "http://localhost:1";);
+    properties.put(OptimizerConfig.GRAVITINO_METALAKE, "test");
+    properties.put(GravitinoClientConfiguration.CLIENT_RETRY_TIMES, "invalid");
+    IllegalArgumentException error =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                GravitinoClientUtils.createClient(
+                    new OptimizerEnv(new OptimizerConfig(properties))));
+    Assertions.assertTrue(error.getMessage().contains("retryTimes"));
+  }
+
   @Test
   void fromReturnsNoneWhenUnset() {
     GravitinoAuthSettings settings = GravitinoAuthSettings.from(new 
OptimizerConfig());

Reply via email to