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

yuqi1129 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 8bd56bd493 [#13177] fix(core): Add statistic CAS and live-target 
checks (#13487)
8bd56bd493 is described below

commit 8bd56bd49308b7d79277a62e7899e8ce38177de7
Author: Qi Yu <[email protected]>
AuthorDate: Mon Oct 12 14:39:25 2026 +0800

    [#13177] fix(core): Add statistic CAS and live-target checks (#13487)
    
    ### What changes were proposed in this pull request?
    
    - Add version CAS to statistic updates and deletes, and fence statistic
    writes with `lockLiveEndpoint` from #13004 (replacing the schema-only
    write lock).
    - Report concurrent first creation of the same statistic as an OCC
    conflict.
    - A drop that loses to a concurrent drop of the same statistic is not a
    conflict; it is simply not counted, as before.
    - Load only the requested statistic names for write snapshots.
    - Add regression tests for batch rollback and same-name replacement
    conflicts, and document OCC outcomes and caller-controlled recovery in
    REST and Java API contracts.
    - Rename the service write method to `writeStatisticsWithVersion` while
    preserving its explicit metric name. Make update/delete identity
    predicates symmetric.
    - Write each batch with one statement per kind inside the fenced
    transaction: one multi-row insert for new statistics, one
    version-checked update for replacements, and one version-checked soft
    delete for drops. Any row-count mismatch fails and rolls back the whole
    batch. The insert takes versions and `deleted_at` from the PO instead of
    hard-coded values.
    - A batch statement locks its rows in the order of the database plan, so
    concurrent batches on another node can deadlock on MySQL. Deadlocks,
    serialization failures and lock wait timeouts anywhere in the fenced
    transaction (SQLState 40001/40P01/55P03, MySQL error codes 1213 and
    1205, H2 error code 50200) are reported as the same OCC conflict (409)
    instead of a 500, on both write and drop paths. This accepted code set
    intentionally covers more than the defaults: PostgreSQL READ COMMITTED
    emits 40P01 for deadlocks; 40001 also covers stricter isolation levels
    and H2 deadlocks, while 55P03 covers a server-configured PostgreSQL
    lock_timeout.
    
    ### Why are the changes needed?
    
    Statistic writes previously used an unconditional upsert after resolving
    the target ID outside the transaction. A concurrent target deletion
    could leave an orphan, and concurrent statistic updates could silently
    overwrite each other. Sequential replacement by name remains supported.
    
    Fix: #13177
    
    ### Does this PR introduce _any_ user-facing change?
    
    Concurrent statistic writes can return an OCC conflict (HTTP 409), and
    writes to a deleted or replaced target fail with not found. No API or
    configuration fields change.
    
    Statistic writes and deletes now take shared locks on the metalake,
    catalog, schema and target rows (the same fence as tag/policy relations)
    instead of only the schema row (H2 takes exclusive row locks for this
    fence). While a schema drop or nested schema create holds its catalog
    lock, statistic writes in that catalog wait for it.
    
    On MySQL, whose collation ignores trailing spaces, writing `name ` when
    `name` already exists is now rejected with HTTP 400
    (`IllegalStatisticNameException`) instead of silently overwriting
    `name`, and dropping `name ` no longer drops `name`; H2 and PostgreSQL
    store two distinct statistics. The same applies within one request: on
    MySQL, a request containing both `name` and `name ` is rejected with
    HTTP 400 before any statement runs. Because both kinds of alias are
    rejected up front, any duplicate key that reaches the insert comes from
    a concurrent creator and is a 409. Rejecting trailing spaces at the REST
    layer for every backend could be considered separately.
    
    OCC is an expected conflict outcome, and the whole conflicting batch is
    rolled back. Clients decide whether to abort or retry against the
    current state according to their application semantics. The built-in
    statistics updater makes one write attempt and propagates the exception;
    a maintenance job can therefore fail under its existing error handling.
    This PR leaves recovery policy to callers.
    
    ### How was this patch tested?
    
    - CI follow-up head `a509c8028c`: merged current main and removed the
    obsolete Ossie import verification introduced by #12627 after #13512
    changed the REST call path. Full non-Docker server suite: 641 passed; H2
    `TestStatisticMetaService`: 20 passed; server/core Spotless and diff
    checks passed. Fresh-context full-diff review passed. The isolated main
    repair (`9a749a52dd`) also passed all 640 server tests, including 73
    Semantic Model REST cases.
    
    Added tests for deadlock/serialization/lock-timeout/duplicate-key
    classification on both write and drop paths (including a real H2
    lock-wait timeout), trailing-space aliases on write (against an existing
    row and within one request), exact-name drops on a padding collation,
    statistic 409 mapping in REST, per-statistic values in one batch
    replacement, a batch drop that partly loses to a concurrent drop,
    concurrent first creation, stale update/delete versions (mapper and
    service layer), a target dropped after the version snapshot (write and
    delete), two concurrent drops of the same statistic, requested-name
    snapshots, whole-batch rollback after a later conflict, and same-name
    statistic recreation at version one.
    
    - Current head `036f5077f2`: full non-Docker core suite 2,680 passed / 2
    existing disabled tests; server 577 passed; optimizer 234 passed / 5
    existing environment-IT cases skipped. Core Spotless and Javadoc checks
    passed, and `:docs:build` passed with a writable temporary npm cache.
    - Current head: the real H2/MySQL/PostgreSQL `TestStatisticMetaService`
    suite ran 60 invocations (20 per backend): 58 passed, 2 skipped because
    the real lock-timeout test is H2-only. Mutation verification removed all
    three trailing-space normalizations: both MySQL alias cases failed their
    intended assertions, while the four H2/PostgreSQL cases passed. The
    mutation was restored in the isolated validation checkout.
    - `212b8f151c`: `TestStatisticMetaService` 60 run on H2, MySQL and
    PostgreSQL (58 passed, 2 skipped: the H2-only real lock-timeout test on
    MySQL and PostgreSQL), `TestStatisticManager` 3 and server
    `TestStatisticOperations` 17 passed, `:docs:build` passed. Removing H2
    50200 from the conflict codes fails both the synthetic and the real H2
    lock-timeout test.
    - Earlier head: `TestStatisticMetaService` 57 passed on H2, MySQL and
    PostgreSQL (19 each), `TestStatisticManager` 3,
    `TestOrphanedMetadataObjectRelationService` 9, all passed; server
    `TestStatisticOperations` 17 passed (server code unchanged since).
    Mutation checks: taking the replacement value from the old row or
    disabling the live-name conflict check failed 7 methods on all three
    backends; skipping conflict translation on the drop path or matching
    MySQL 1205 only through the SQLState failed the classification test on
    all three backends; disabling the trailing-space alias checks failed
    their tests on MySQL (the only padding collation).
    - An independent review reproduced the plan-dependent deadlock on MySQL
    8.0 with the same statement shape; the fix maps it to 409. It was not
    reproduced end to end through Gravitino.
    
    The updater regression asserts that an OCC exception is propagated
    unchanged after exactly one write call. The mapper regression verifies
    that a mismatched statistic name cannot delete a row even when other
    observed fields match. No full Spark/client-server concurrent job run
    was executed; existing environment ITs remain gated separately.
---
 .../apache/gravitino/stats/SupportsStatistics.java |  16 +
 .../gravitino/api/stats/supports_statistics.py     |  14 +
 .../gravitino/storage/relational/JDBCBackend.java  |   2 +-
 .../relational/mapper/StatisticMetaMapper.java     |  44 +-
 .../mapper/StatisticSQLProviderFactory.java        |  44 +-
 .../provider/base/StatisticBaseSQLProvider.java    | 104 ++-
 .../postgresql/StatisticPostgresSQLProvider.java   |  31 -
 .../relational/service/LiveEndpointService.java    |   4 +-
 .../relational/service/StatisticMetaService.java   | 351 ++++++++--
 .../service/TestStatisticMetaService.java          | 778 ++++++++++++++++++++-
 docs/manage-statistics-in-gravitino.md             |  18 +
 docs/open-api/statistics.yaml                      |   4 +-
 .../statistics/TestGravitinoStatisticsUpdater.java |  26 +
 .../web/rest/TestSemanticModelOperations.java      |   1 -
 .../server/web/rest/TestStatisticOperations.java   |  47 ++
 15 files changed, 1342 insertions(+), 142 deletions(-)

diff --git 
a/api/src/main/java/org/apache/gravitino/stats/SupportsStatistics.java 
b/api/src/main/java/org/apache/gravitino/stats/SupportsStatistics.java
index 5923b4a089..0f9bf890a6 100644
--- a/api/src/main/java/org/apache/gravitino/stats/SupportsStatistics.java
+++ b/api/src/main/java/org/apache/gravitino/stats/SupportsStatistics.java
@@ -22,6 +22,7 @@ import java.util.List;
 import java.util.Map;
 import org.apache.gravitino.annotation.Evolving;
 import org.apache.gravitino.exceptions.IllegalStatisticNameException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.exceptions.UnmodifiableStatisticException;
 
 /**
@@ -44,7 +45,14 @@ public interface SupportsStatistics {
    * unmodifiable, it will throw an UnmodifiableStatisticException. If the 
statistic name is
    * illegal, it will throw an IllegalStatisticNameException.
    *
+   * <p>For Gravitino-managed table statistics, a concurrent creation, 
modification or deletion can
+   * fail the entire update with {@link OptimisticLockException}. No part of 
that update is
+   * committed. The caller decides whether to abort or retry against the 
current state; Gravitino
+   * does not automatically retry the write.
+   *
    * @param statistics a map of statistic names to their values
+   * @throws OptimisticLockException if a Gravitino-managed table statistic 
was concurrently
+   *     created, modified or deleted
    */
   void updateStatistics(Map<String, StatisticValue<?>> statistics)
       throws UnmodifiableStatisticException, IllegalStatisticNameException;
@@ -53,9 +61,17 @@ public interface SupportsStatistics {
    * Drop statistics by their names. If the statistic is unmodifiable, it will 
throw an
    * UnmodifiableStatisticException.
    *
+   * <p>For Gravitino-managed table statistics, a concurrent modification or 
same-name replacement
+   * can fail the entire drop with {@link OptimisticLockException}. No part of 
that drop is
+   * committed. The caller decides whether to abort or retry against the 
current state; Gravitino
+   * does not automatically retry the write. A statistic already removed by 
another drop is not a
+   * conflict and is not counted as dropped by this call.
+   *
    * @param statistics a list of statistic names to be dropped
    * @return true if the statistics were successfully dropped, false if no 
statistics were dropped
    * @throws UnmodifiableStatisticException if any of the statistics to be 
dropped are unmodifiable
+   * @throws OptimisticLockException if a Gravitino-managed table statistic 
was concurrently
+   *     modified or replaced
    */
   boolean dropStatistics(List<String> statistics) throws 
UnmodifiableStatisticException;
 }
diff --git a/clients/client-python/gravitino/api/stats/supports_statistics.py 
b/clients/client-python/gravitino/api/stats/supports_statistics.py
index a0fcc28a02..c389a0be73 100644
--- a/clients/client-python/gravitino/api/stats/supports_statistics.py
+++ b/clients/client-python/gravitino/api/stats/supports_statistics.py
@@ -49,9 +49,16 @@ class SupportsStatistics(ABC):
         Args:
             statistics: a map of statistic names to their values
 
+        For Gravitino-managed table statistics, a concurrent creation, 
modification or deletion
+        can fail the entire update with `OptimisticLockException`. No part of 
that update is
+        committed. The caller decides whether to abort or retry against the 
current state;
+        Gravitino does not automatically retry the write.
+
         Raises:
             IllegalStatisticNameException: If the statistic name is illegal
             UnmodifiableStatisticException: If the statistic is unmodifiable
+            OptimisticLockException: If a Gravitino-managed table statistic 
was concurrently
+                created, modified or deleted
         """
 
     @abstractmethod
@@ -68,7 +75,14 @@ class SupportsStatistics(ABC):
                 `True` if the statistics were successfully dropped,
                 `False` if no statistics were dropped
 
+        For Gravitino-managed table statistics, a concurrent modification or 
same-name
+        replacement can fail the entire drop with `OptimisticLockException`. 
No part of that drop
+        is committed. A statistic already removed by another drop is not a 
conflict and is not
+        counted as dropped by this call.
+
         Raises:
             UnmodifiableStatisticException:
                 if any of the statistics to be dropped are unmodifiable
+            OptimisticLockException:
+                if a Gravitino-managed table statistic was concurrently 
modified or replaced
         """
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
index 995b385a4e..096bbdadd8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
@@ -671,7 +671,7 @@ public class JDBCBackend implements RelationalBackend, 
SupportsOrphanedRelationC
             "All entities must be in the same namespace for batchPut 
operation.");
 
         StatisticMetaService.getInstance()
-            .batchInsertStatisticPOsOnDuplicateKeyUpdate(
+            .writeStatisticsWithVersion(
                 statisticEntities,
                 
NameIdentifier.parse(statisticEntities.get(0).namespace().toString()),
                 Entity.EntityType.TABLE);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/StatisticMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/StatisticMetaMapper.java
index 7c25383bed..df050e8e0f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/StatisticMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/StatisticMetaMapper.java
@@ -34,15 +34,45 @@ public interface StatisticMetaMapper {
   List<StatisticPO> listStatisticPOsByEntityId(
       @Param("metalakeId") Long metalakeId, @Param("entityId") long entityId);
 
-  @InsertProvider(
+  /** Lists only the named live statistics for a metadata object. */
+  @SelectProvider(type = StatisticSQLProviderFactory.class, method = 
"listStatisticPOsByNames")
+  List<StatisticPO> listStatisticPOsByNames(
+      @Param("metalakeId") Long metalakeId,
+      @Param("entityId") long entityId,
+      @Param("names") List<String> names);
+
+  /**
+   * Inserts statistics in one statement. The whole statement fails with a 
unique-key violation if
+   * any of them already has a live row with the same name and target.
+   *
+   * @param statisticPOs the statistics to insert, with their initial versions 
and deleted_at
+   * @return the number of inserted rows
+   */
+  @InsertProvider(type = StatisticSQLProviderFactory.class, method = 
"batchInsertStatisticPOs")
+  Integer batchInsertStatisticPOs(@Param("statisticPOs") List<StatisticPO> 
statisticPOs);
+
+  /**
+   * Replaces statistic values in one statement, each only if its observed 
version is still current.
+   *
+   * @param statisticPOs one PO per observed row: its ID, target, name and 
current version identify
+   *     the row, and its value and audit info are the replacement
+   * @return the number of replaced rows; fewer than requested means some rows 
changed meanwhile
+   */
+  @UpdateProvider(
       type = StatisticSQLProviderFactory.class,
-      method = "batchInsertStatisticPOsOnDuplicateKeyUpdate")
-  void batchInsertStatisticPOsOnDuplicateKeyUpdate(
-      @Param("statisticPOs") List<StatisticPO> statisticPOs);
+      method = "batchUpdateStatisticPOsWithVersion")
+  Integer batchUpdateStatisticPOsWithVersion(@Param("statisticPOs") 
List<StatisticPO> statisticPOs);
 
-  @UpdateProvider(type = StatisticSQLProviderFactory.class, method = 
"batchDeleteStatisticPOs")
-  Integer batchDeleteStatisticPOs(
-      @Param("entityId") Long entityId, @Param("statisticNames") List<String> 
statisticNames);
+  /**
+   * Soft-deletes statistics in one statement, each only if its observed 
version is still current.
+   *
+   * @param statisticPOs the observed rows, identified by ID, target, name and 
current version
+   * @return the number of deleted rows; fewer than requested means some rows 
changed meanwhile
+   */
+  @UpdateProvider(
+      type = StatisticSQLProviderFactory.class,
+      method = "batchDeleteStatisticPOsWithVersion")
+  Integer batchDeleteStatisticPOsWithVersion(@Param("statisticPOs") 
List<StatisticPO> statisticPOs);
 
   @UpdateProvider(
       type = StatisticSQLProviderFactory.class,
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/StatisticSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/StatisticSQLProviderFactory.java
index c15dc7ec98..c9608b8b1c 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/StatisticSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/StatisticSQLProviderFactory.java
@@ -31,7 +31,13 @@ import org.apache.ibatis.annotations.Param;
 
 public class StatisticSQLProviderFactory {
 
-  static class StatisticMySQLProvider extends StatisticBaseSQLProvider {}
+  static class StatisticMySQLProvider extends StatisticBaseSQLProvider {
+    // The shipped MySQL schema uses a PAD SPACE collation for statistic_meta.
+    @Override
+    public boolean namesIgnoreTrailingSpaces() {
+      return true;
+    }
+  }
 
   static class StatisticH2Provider extends StatisticBaseSQLProvider {}
 
@@ -56,14 +62,32 @@ public class StatisticSQLProviderFactory {
     return STATISTIC_SQL_PROVIDERS.get(jdbcBackendType);
   }
 
-  public static String batchInsertStatisticPOsOnDuplicateKeyUpdate(
+  /**
+   * Returns whether the current backend treats statistic names that differ 
only in trailing spaces
+   * as the same statistic.
+   *
+   * @return {@code true} for backends whose statistic key ignores trailing 
spaces
+   */
+  public static boolean namesIgnoreTrailingSpaces() {
+    return getProvider().namesIgnoreTrailingSpaces();
+  }
+
+  /** Returns SQL for a strict multi-row statistic insert. */
+  public static String batchInsertStatisticPOs(
       @Param("statisticPOs") List<StatisticPO> statisticPOs) {
-    return 
getProvider().batchInsertStatisticPOsOnDuplicateKeyUpdate(statisticPOs);
+    return getProvider().batchInsertStatisticPOs(statisticPOs);
   }
 
-  public static String batchDeleteStatisticPOs(
-      @Param("entityId") Long entityId, @Param("statisticNames") List<String> 
statisticNames) {
-    return getProvider().batchDeleteStatisticPOs(entityId, statisticNames);
+  /** Returns SQL for a version-checked batch statistic update. */
+  public static String batchUpdateStatisticPOsWithVersion(
+      @Param("statisticPOs") List<StatisticPO> statisticPOs) {
+    return getProvider().batchUpdateStatisticPOsWithVersion(statisticPOs);
+  }
+
+  /** Returns SQL for a version-checked batch statistic soft delete. */
+  public static String batchDeleteStatisticPOsWithVersion(
+      @Param("statisticPOs") List<StatisticPO> statisticPOs) {
+    return getProvider().batchDeleteStatisticPOsWithVersion(statisticPOs);
   }
 
   public static String softDeleteStatisticsByEntityId(@Param("entityId") Long 
entityId) {
@@ -75,6 +99,14 @@ public class StatisticSQLProviderFactory {
     return getProvider().listStatisticPOsByEntityId(metalakeId, entityId);
   }
 
+  /** Returns SQL to select only the named live statistics. */
+  public static String listStatisticPOsByNames(
+      @Param("metalakeId") Long metalakeId,
+      @Param("entityId") Long entityId,
+      @Param("names") List<String> names) {
+    return getProvider().listStatisticPOsByNames(metalakeId, entityId, names);
+  }
+
   public static String softDeleteStatisticsByMetalakeId(@Param("metalakeId") 
Long metalakeId) {
     return getProvider().softDeleteStatisticsByMetalakeId(metalakeId);
   }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/StatisticBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/StatisticBaseSQLProvider.java
index 8a0c3d5b67..dd20247b89 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/StatisticBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/StatisticBaseSQLProvider.java
@@ -33,49 +33,75 @@ import org.apache.ibatis.annotations.Param;
 
 public class StatisticBaseSQLProvider {
 
-  public String batchInsertStatisticPOsOnDuplicateKeyUpdate(
-      @Param("statisticPOs") List<StatisticPO> statisticPOs) {
-    return "<script>"
-        + "INSERT INTO "
+  /**
+   * Returns whether the backend's {@code statistic_meta} key ignores trailing 
spaces, so that
+   * "name" and "name " are the same statistic.
+   *
+   * @return {@code true} if names that differ only in trailing spaces collide
+   */
+  public boolean namesIgnoreTrailingSpaces() {
+    return false;
+  }
+
+  /** Inserts new live statistics in one statement without upsert fallback. */
+  public String batchInsertStatisticPOs(@Param("statisticPOs") 
List<StatisticPO> statisticPOs) {
+    return "<script>INSERT INTO "
         + STATISTIC_META_TABLE_NAME
         + " (statistic_id, statistic_name, statistic_value, metalake_id, 
metadata_object_id,"
-        + " metadata_object_type, audit_info, current_version, last_version, 
deleted_at) VALUES "
+        + " metadata_object_type, audit_info, current_version, last_version, 
deleted_at) VALUES"
         + "<foreach collection='statisticPOs' item='item' separator=','>"
-        + "(#{item.statisticId}, "
-        + "#{item.statisticName}, "
-        + "#{item.statisticValue}, "
-        + "#{item.metalakeId}, "
-        + "#{item.metadataObjectId}, "
-        + "#{item.metadataObjectType}, "
-        + "#{item.auditInfo}, "
-        + "#{item.currentVersion}, "
-        + "#{item.lastVersion}, "
-        + "#{item.deletedAt})"
-        + "</foreach>"
-        + " ON DUPLICATE KEY UPDATE "
-        + "  statistic_value = VALUES(statistic_value),"
-        + "  audit_info = VALUES(audit_info),"
-        + "  current_version = VALUES(current_version),"
-        + "  last_version = VALUES(last_version),"
-        + "  deleted_at = VALUES(deleted_at)"
+        + " (#{item.statisticId}, #{item.statisticName}, 
#{item.statisticValue},"
+        + " #{item.metalakeId}, #{item.metadataObjectId}, 
#{item.metadataObjectType},"
+        + " #{item.auditInfo}, #{item.currentVersion}, #{item.lastVersion}, 
#{item.deletedAt})"
+        + "</foreach></script>";
+  }
+
+  /**
+   * Replaces the values of the observed statistics in one statement and 
advances their versions.
+   * Each PO identifies an observed row by ID, target, name and current 
version, and carries the new
+   * value and audit info.
+   */
+  public String batchUpdateStatisticPOsWithVersion(
+      @Param("statisticPOs") List<StatisticPO> statisticPOs) {
+    return "<script>UPDATE "
+        + STATISTIC_META_TABLE_NAME
+        + " SET statistic_value = CASE statistic_id"
+        + "<foreach collection='statisticPOs' item='item'>"
+        + " WHEN #{item.statisticId} THEN #{item.statisticValue}"
+        + "</foreach> END,"
+        + " audit_info = CASE statistic_id"
+        + "<foreach collection='statisticPOs' item='item'>"
+        + " WHEN #{item.statisticId} THEN #{item.auditInfo}"
+        + "</foreach> END,"
+        + " last_version = current_version, current_version = current_version 
+ 1"
+        + observedRowsPredicate()
         + "</script>";
   }
 
-  public String batchDeleteStatisticPOs(
-      @Param("entityId") Long entityId, @Param("statisticNames") List<String> 
statisticNames) {
-    return "<script>"
-        + "UPDATE "
+  /**
+   * Soft-deletes the observed statistics in one statement and advances their 
versions. Each PO
+   * identifies an observed row by ID, target, name and current version.
+   */
+  public String batchDeleteStatisticPOsWithVersion(
+      @Param("statisticPOs") List<StatisticPO> statisticPOs) {
+    return "<script>UPDATE "
         + STATISTIC_META_TABLE_NAME
         + softDeleteSQL()
-        + " WHERE "
-        + " statistic_name IN ("
-        + "<foreach collection='statisticNames' item='item' separator=','>"
-        + " #{item}"
-        + "</foreach>"
-        + " ) AND deleted_at = 0 AND metadata_object_id = #{entityId}"
+        + ", last_version = current_version, current_version = current_version 
+ 1"
+        + observedRowsPredicate()
         + "</script>";
   }
 
+  private static String observedRowsPredicate() {
+    return " WHERE deleted_at = 0 AND ("
+        + "<foreach collection='statisticPOs' item='item' separator=' OR '>"
+        + "(statistic_id = #{item.statisticId}"
+        + " AND metadata_object_id = #{item.metadataObjectId}"
+        + " AND statistic_name = #{item.statisticName}"
+        + " AND current_version = #{item.currentVersion})"
+        + "</foreach>)";
+  }
+
   public String softDeleteStatisticsByEntityId(@Param("entityId") Long 
entityId) {
     return "UPDATE "
         + STATISTIC_META_TABLE_NAME
@@ -93,6 +119,20 @@ public class StatisticBaseSQLProvider {
         + " WHERE metadata_object_id = #{entityId} AND deleted_at = 0 AND 
metalake_id = #{metalakeId}";
   }
 
+  /** Selects full rows of only the requested live statistics of one metadata 
object. */
+  public String listStatisticPOsByNames(
+      @Param("metalakeId") Long metalakeId,
+      @Param("entityId") Long entityId,
+      @Param("names") List<String> names) {
+    return "<script>"
+        + listStatisticPOsByEntityId(metalakeId, entityId)
+        + " AND statistic_name IN "
+        + "<foreach collection='names' item='name' open='(' separator=',' 
close=')'>"
+        + "#{name}"
+        + "</foreach>"
+        + "</script>";
+  }
+
   public String softDeleteStatisticsByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
     return "UPDATE "
         + STATISTIC_META_TABLE_NAME
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/StatisticPostgresSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/StatisticPostgresSQLProvider.java
index ed6c5de7b7..de06021828 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/StatisticPostgresSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/StatisticPostgresSQLProvider.java
@@ -20,10 +20,8 @@ package 
org.apache.gravitino.storage.relational.mapper.provider.postgresql;
 
 import static 
org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper.STATISTIC_META_TABLE_NAME;
 
-import java.util.List;
 import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.StatisticBaseSQLProvider;
-import org.apache.gravitino.storage.relational.po.StatisticPO;
 
 public class StatisticPostgresSQLProvider extends StatisticBaseSQLProvider {
   @Override
@@ -31,35 +29,6 @@ public class StatisticPostgresSQLProvider extends 
StatisticBaseSQLProvider {
     return " SET deleted_at = " + DatabaseTimeSQL.POSTGRESQL;
   }
 
-  @Override
-  public String batchInsertStatisticPOsOnDuplicateKeyUpdate(List<StatisticPO> 
statisticPOs) {
-    return "<script>"
-        + "INSERT INTO "
-        + STATISTIC_META_TABLE_NAME
-        + " (statistic_id, statistic_name, statistic_value, metalake_id, 
metadata_object_id,"
-        + " metadata_object_type, audit_info, current_version, last_version, 
deleted_at) VALUES "
-        + "<foreach collection='statisticPOs' item='item' separator=','>"
-        + "(#{item.statisticId}, "
-        + "#{item.statisticName}, "
-        + "#{item.statisticValue}, "
-        + "#{item.metalakeId}, "
-        + "#{item.metadataObjectId}, "
-        + "#{item.metadataObjectType}, "
-        + "#{item.auditInfo}, "
-        + "#{item.currentVersion}, "
-        + "#{item.lastVersion}, "
-        + "#{item.deletedAt})"
-        + "</foreach>"
-        + " ON CONFLICT (statistic_name, metadata_object_id, deleted_at)"
-        + " DO UPDATE SET "
-        + "  statistic_value = EXCLUDED.statistic_value,"
-        + "  audit_info = EXCLUDED.audit_info,"
-        + "  current_version = EXCLUDED.current_version,"
-        + "  last_version = EXCLUDED.last_version,"
-        + "  deleted_at = EXCLUDED.deleted_at"
-        + "</script>";
-  }
-
   @Override
   public String deleteStatisticsByLegacyTimeline(Long legacyTimeline, int 
limit) {
     return "DELETE FROM "
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/LiveEndpointService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/LiveEndpointService.java
index 9f90b60f59..2b7d1ad9a9 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/LiveEndpointService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/LiveEndpointService.java
@@ -33,8 +33,8 @@ import org.apache.ibatis.session.SqlSession;
 /**
  * Fences metadata-object endpoints while a relation write is in progress.
  *
- * <p>Statistic writers take a schema lock of their own today and adopt this 
fence in #13177, so
- * nothing here covers them yet.
+ * <p>Statistic writes and deletes also take this fence, so a statistic cannot 
be committed against
+ * a target that a concurrent drop has already removed.
  */
 public final class LiveEndpointService {
 
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/StatisticMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/StatisticMetaService.java
index 2a429efb8a..7636af1356 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/StatisticMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/StatisticMetaService.java
@@ -20,14 +20,31 @@ package org.apache.gravitino.storage.relational.service;
 
 import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
 
+import com.google.common.annotations.VisibleForTesting;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.function.Function;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.IllegalStatisticNameException;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.meta.NamespacedEntityId;
 import org.apache.gravitino.meta.StatisticEntity;
 import org.apache.gravitino.metrics.Monitored;
 import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
+import 
org.apache.gravitino.storage.relational.mapper.StatisticSQLProviderFactory;
 import org.apache.gravitino.storage.relational.po.StatisticPO;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -38,13 +55,18 @@ import org.apache.gravitino.utils.NameIdentifierUtil;
  */
 public class StatisticMetaService {
 
+  private static final int MYSQL_LOCK_WAIT_TIMEOUT = 1205;
+  private static final int MYSQL_DEADLOCK = 1213;
+  private static final int H2_LOCK_TIMEOUT = 50200;
+
   private static final StatisticMetaService INSTANCE = new 
StatisticMetaService();
 
   public static StatisticMetaService getInstance() {
     return INSTANCE;
   }
 
-  private StatisticMetaService() {}
+  @VisibleForTesting
+  StatisticMetaService() {}
 
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
@@ -63,10 +85,24 @@ public class StatisticMetaService {
         .collect(Collectors.toList());
   }
 
+  /**
+   * Creates or replaces statistics of a metadata object by name.
+   *
+   * <p>Existing statistics are replaced only if their version is unchanged 
since this call read
+   * them, and missing statistics are inserted only if nobody created them 
meanwhile; both cases
+   * otherwise fail the whole batch with {@link OptimisticLockException}. The 
target is fenced in
+   * the same transaction, so a target dropped or replaced after its ID was 
resolved fails with
+   * {@link NoSuchEntityException}.
+   *
+   * @param statisticEntities the statistics to write; names must be unique in 
the batch
+   * @param entity the metadata object that owns the statistics
+   * @param type the metadata object type
+   */
+  // Preserve the historical metric name for compatibility with existing 
monitoring.
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "batchInsertStatisticPOsOnDuplicateKeyUpdate")
-  public void batchInsertStatisticPOsOnDuplicateKeyUpdate(
+  public void writeStatisticsWithVersion(
       List<StatisticEntity> statisticEntities, NameIdentifier entity, 
Entity.EntityType type) {
     if (statisticEntities == null || statisticEntities.isEmpty()) {
       return;
@@ -79,19 +115,86 @@ public class StatisticMetaService {
             namespacedEntityId.namespaceIds()[0],
             namespacedEntityId.entityId(),
             NameIdentifierUtil.toMetadataObject(entity, type).type());
-    // Statistics have their own write API, so they do not inherit the schema 
fence from the
-    // metadata object update path. Fence schema-scoped targets explicitly to 
keep a schema cascade
-    // from deleting the target and then missing this independently committed 
statistic upsert.
-    doWithSchemaWriteLockIfNeeded(
-        entity,
-        type,
-        namespacedEntityId,
-        () ->
-            SessionUtils.doWithoutCommit(
-                StatisticMetaMapper.class,
-                mapper -> 
mapper.batchInsertStatisticPOsOnDuplicateKeyUpdate(pos)));
+    Set<String> names = new HashSet<>();
+    for (StatisticPO po : pos) {
+      if (!names.add(po.getStatisticName())) {
+        throw new IllegalArgumentException(
+            "Duplicate statistic name in batch: " + po.getStatisticName());
+      }
+    }
+    pos.sort(Comparator.comparing(StatisticPO::getStatisticName));
+    if (StatisticSQLProviderFactory.namesIgnoreTrailingSpaces()) {
+      rejectTrailingSpaceAliases(pos, entity);
+    }
+    Map<String, StatisticPO> previous =
+        listStatisticPOs(
+                namespacedEntityId,
+                
pos.stream().map(StatisticPO::getStatisticName).collect(Collectors.toList()))
+            .stream()
+            .collect(Collectors.toMap(StatisticPO::getStatisticName, 
Function.identity()));
+    // A collation that ignores trailing spaces (MySQL) returns the row of 
"name" for "name ", and
+    // its unique key treats them as the same statistic. Writing "name " can 
never succeed there, so
+    // it is rejected as an illegal name instead of a conflict that suggests a 
retry.
+    for (String stored : previous.keySet()) {
+      if (!names.contains(stored)) {
+        String requested =
+            names.stream()
+                .filter(
+                    name ->
+                        StringUtils.stripEnd(name, " 
").equals(StringUtils.stripEnd(stored, " ")))
+                .findFirst()
+                .orElse(stored);
+        throw new IllegalStatisticNameException(
+            "Statistic name '%s' is equivalent to the existing statistic '%s' 
of %s in the backend"
+                + " collation; names must not differ only in trailing spaces",
+            requested, stored, entity);
+      }
+    }
+    List<StatisticPO> inserts = new ArrayList<>();
+    List<StatisticPO> updates = new ArrayList<>();
+    for (StatisticPO po : pos) {
+      StatisticPO old = previous.get(po.getStatisticName());
+      if (old == null) {
+        inserts.add(po);
+      } else {
+        updates.add(replacementOf(old, po));
+      }
+    }
+    // Each kind of write is one statement. Any mismatch fails the whole 
transaction, so the batch
+    // either applies completely or not at all. Trailing-space aliases were 
rejected above, so a
+    // duplicate key on the insert can only come from a concurrent creator and 
is a conflict.
+    runFencedTransaction(
+        () -> {
+          LiveEndpointService.lockLiveEndpoint(entity, type, 
namespacedEntityId);
+          if (!inserts.isEmpty()
+              && executeStatement(mapper -> 
mapper.batchInsertStatisticPOs(inserts))
+                  != inserts.size()) {
+            throw statisticConflict(null, names(inserts), entity);
+          }
+          if (!updates.isEmpty()
+              && executeStatement(mapper -> 
mapper.batchUpdateStatisticPOsWithVersion(updates))
+                  != updates.size()) {
+            throw statisticConflict(null, names(updates), entity);
+          }
+        },
+        pos,
+        entity);
   }
 
+  /**
+   * Soft-deletes the named statistics of a metadata object.
+   *
+   * <p>Each statistic is deleted only at the version this call read. A 
statistic that was changed
+   * meanwhile fails the whole batch with {@link OptimisticLockException}; one 
that a concurrent
+   * drop already removed is not counted, like a name that does not exist. A 
same-name replacement
+   * is a conflict even if its version matches the deleted row. A target 
dropped or replaced after
+   * its ID was resolved fails with {@link NoSuchEntityException} when there 
are rows to delete.
+   *
+   * @param identifier the metadata object that owns the statistics
+   * @param type the metadata object type
+   * @param statisticNames the statistic names to delete
+   * @return the number of statistics this call deleted
+   */
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "batchDeleteStatisticPOs")
@@ -100,11 +203,197 @@ public class StatisticMetaService {
     if (statisticNames == null || statisticNames.isEmpty()) {
       return 0;
     }
-    Long entityId = EntityIdService.getEntityId(identifier, type);
+    NamespacedEntityId observed = EntityIdService.getEntityIds(identifier, 
type);
+    Set<String> orderedNames =
+        statisticNames.stream()
+            .filter(Objects::nonNull)
+            .collect(Collectors.toCollection(TreeSet::new));
+    if (orderedNames.isEmpty()) {
+      return 0;
+    }
+    Map<String, StatisticPO> previous =
+        listStatisticPOs(observed, new ArrayList<>(orderedNames)).stream()
+            .collect(Collectors.toMap(StatisticPO::getStatisticName, 
Function.identity()));
+    if (previous.isEmpty()) {
+      return 0;
+    }
+    // Look up by the exact requested names. A collation that ignores trailing 
spaces can return the
+    // row of "name" for a request of "name "; dropping "name " must not drop 
"name".
+    List<StatisticPO> observedRows =
+        orderedNames.stream()
+            .map(previous::get)
+            .filter(Objects::nonNull)
+            .collect(Collectors.toList());
+    if (observedRows.isEmpty()) {
+      return 0;
+    }
+    int[] deleted = new int[] {0};
+    runFencedTransaction(
+        () -> {
+          LiveEndpointService.lockLiveEndpoint(identifier, type, observed);
+          deleted[0] =
+              executeStatement(mapper -> 
mapper.batchDeleteStatisticPOsWithVersion(observedRows));
+          if (deleted[0] == observedRows.size()) {
+            return;
+          }
+          // Some observed rows were not deleted. A name that is still live 
was changed or
+          // replaced meanwhile, which is a conflict. Otherwise a concurrent 
drop already removed
+          // it; like a drop of a missing name, that is not a conflict and is 
simply not counted.
+          List<StatisticPO> live = liveStatistics(observed, 
names(observedRows));
+          if (!live.isEmpty()) {
+            throw statisticConflict(null, names(live), identifier);
+          }
+        },
+        observedRows,
+        identifier);
+    return deleted[0];
+  }
 
-    return SessionUtils.doWithCommitAndFetchResult(
+  @VisibleForTesting
+  List<StatisticPO> listStatisticPOs(NamespacedEntityId endpoint, List<String> 
names) {
+    return SessionUtils.getWithoutCommit(
+        StatisticMetaMapper.class,
+        mapper ->
+            mapper.listStatisticPOsByNames(endpoint.namespaceIds()[0], 
endpoint.entityId(), names));
+  }
+
+  /** Runs one batch statement in the current transaction and returns its row 
count. */
+  @VisibleForTesting
+  int executeStatement(Function<StatisticMetaMapper, Integer> statement) {
+    return SessionUtils.getWithoutCommit(StatisticMetaMapper.class, statement);
+  }
+
+  /**
+   * Runs a fenced statistic transaction. A failure caused by a concurrent 
writer anywhere in the
+   * transaction, including the fence's locking reads, is reported as a 
conflict on {@code pos}.
+   */
+  private static void runFencedTransaction(
+      Runnable operations, List<StatisticPO> pos, NameIdentifier target) {
+    try {
+      SessionUtils.doMultipleWithCommit(operations);
+    } catch (RuntimeException e) {
+      throw translateConflict(e, pos, target);
+    }
+  }
+
+  /**
+   * Converts a failure caused by a concurrent writer into a conflict. A 
duplicate insert means a
+   * statistic was created after the snapshot. A deadlock, serialization 
failure or lock wait
+   * timeout can happen because a batch statement locks its rows in the order 
of the database's
+   * plan, which differs between batches. The whole batch is rolled back 
either way, so these are
+   * reported as a conflict rather than an internal server error.
+   */
+  private static RuntimeException translateConflict(
+      RuntimeException failure, List<StatisticPO> pos, NameIdentifier target) {
+    if (failure instanceof OptimisticLockException) {
+      return failure;
+    }
+    if (isDuplicateKey(failure)) {
+      return statisticConflict(failure, names(pos), target);
+    }
+    if (isConcurrencyFailure(failure)) {
+      return new OptimisticLockException(
+          failure,
+          "The statistics %s of %s could not be changed because of a 
concurrent operation"
+              + " (deadlock or lock wait timeout); retry the operation",
+          names(pos),
+          target);
+    }
+    return failure;
+  }
+
+  /**
+   * Rejects requested names that the backend key treats as one statistic 
because they differ only
+   * in trailing spaces. Such a request can never succeed, so it fails as an 
illegal name before any
+   * write instead of as a conflict that suggests a retry.
+   */
+  private static void rejectTrailingSpaceAliases(List<StatisticPO> pos, 
NameIdentifier target) {
+    Map<String, String> requestedByKey = new HashMap<>();
+    for (StatisticPO po : pos) {
+      String name = po.getStatisticName();
+      String other = requestedByKey.putIfAbsent(StringUtils.stripEnd(name, " 
"), name);
+      if (other != null) {
+        throw new IllegalStatisticNameException(
+            "Statistic names '%s' and '%s' of %s are the same statistic in the 
backend collation;"
+                + " names must not differ only in trailing spaces",
+            other, name, target);
+      }
+    }
+  }
+
+  private static OptimisticLockException statisticConflict(
+      @Nullable Throwable cause, List<String> names, NameIdentifier target) {
+    return new OptimisticLockException(
+        cause,
+        "One or more of the statistics %s of %s were changed concurrently; 
retry the operation",
+        names,
+        target);
+  }
+
+  private static List<StatisticPO> liveStatistics(NamespacedEntityId endpoint, 
List<String> names) {
+    return SessionUtils.getWithoutCommit(
         StatisticMetaMapper.class,
-        mapper -> mapper.batchDeleteStatisticPOs(entityId, statisticNames));
+        mapper ->
+            mapper.listStatisticPOsByNames(endpoint.namespaceIds()[0], 
endpoint.entityId(), names));
+  }
+
+  private static List<String> names(List<StatisticPO> pos) {
+    return 
pos.stream().map(StatisticPO::getStatisticName).sorted().collect(Collectors.toList());
+  }
+
+  /** Returns a PO that identifies the observed row and carries the 
replacement value. */
+  private static StatisticPO replacementOf(StatisticPO observed, StatisticPO 
replacement) {
+    return StatisticPO.builder()
+        .withStatisticId(observed.getStatisticId())
+        .withStatisticName(observed.getStatisticName())
+        .withMetalakeId(observed.getMetalakeId())
+        .withMetadataObjectId(observed.getMetadataObjectId())
+        .withMetadataObjectType(observed.getMetadataObjectType())
+        .withCurrentVersion(observed.getCurrentVersion())
+        .withLastVersion(observed.getLastVersion())
+        .withDeletedAt(observed.getDeletedAt())
+        .withStatisticValue(replacement.getStatisticValue())
+        .withAuditInfo(replacement.getAuditInfo())
+        .build();
+  }
+
+  /**
+   * Returns whether a failure is a unique-key violation. PostgreSQL and H2 
report SQLState 23505,
+   * and MySQL reports error code 1062, the same codes the backend exception 
converters match.
+   */
+  private static boolean isDuplicateKey(Throwable failure) {
+    return hasSqlException(failure, Set.of("23505"), Set.of(1062));
+  }
+
+  /**
+   * Returns whether a failure is a deadlock, serialization failure or lock 
wait timeout caused by a
+   * concurrent writer. The accepted codes intentionally cover more than the 
current default
+   * configuration emits. PostgreSQL reports 40P01 for deadlocks at our READ 
COMMITTED isolation;
+   * 40001 also covers serialization failures at stricter isolation levels, 
and 55P03 covers a
+   * server-configured lock_timeout. H2 reports 40001 for a deadlock and error 
code 50200 (SQLState
+   * HYT00) for a lock wait timeout. MySQL reports error code 1213 for a 
deadlock and 1205 for a
+   * lock wait timeout. Vendor error codes are matched because a timeout's 
SQLState depends on the
+   * driver.
+   */
+  private static boolean isConcurrencyFailure(Throwable failure) {
+    return hasSqlException(
+        failure,
+        Set.of("40001", "40P01", "55P03"),
+        Set.of(MYSQL_DEADLOCK, MYSQL_LOCK_WAIT_TIMEOUT, H2_LOCK_TIMEOUT));
+  }
+
+  private static boolean hasSqlException(
+      Throwable failure, Set<String> sqlStates, Set<Integer> vendorErrorCodes) 
{
+    for (Throwable cause = failure; cause != null; cause = cause.getCause()) {
+      if (cause instanceof SQLException) {
+        SQLException sql = (SQLException) cause;
+        if (sqlStates.contains(sql.getSQLState())
+            || vendorErrorCodes.contains(sql.getErrorCode())) {
+          return true;
+        }
+      }
+    }
+    return false;
   }
 
   @Monitored(
@@ -115,34 +404,4 @@ public class StatisticMetaService {
         StatisticMetaMapper.class,
         mapper -> mapper.deleteStatisticsByLegacyTimeline(legacyTimeline, 
limit));
   }
-
-  private void doWithSchemaWriteLockIfNeeded(
-      NameIdentifier identifier,
-      Entity.EntityType type,
-      NamespacedEntityId namespacedEntityId,
-      Runnable writeOperation) {
-    long[] namespaceIds = namespacedEntityId.namespaceIds();
-    Long schemaId;
-    switch (type) {
-      case SCHEMA:
-        schemaId = namespacedEntityId.entityId();
-        break;
-      case TABLE:
-      case VIEW:
-      case COLUMN:
-      case FILESET:
-      case TOPIC:
-      case MODEL:
-      case FUNCTION:
-        schemaId = namespaceIds[2];
-        break;
-      default:
-        SessionUtils.doMultipleWithCommit(writeOperation);
-        return;
-    }
-
-    SchemaMetaService.getInstance()
-        .doWithSchemaWriteLock(
-            identifier, schemaId, namespaceIds[1], namespaceIds[0], 
writeOperation);
-  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestStatisticMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestStatisticMetaService.java
index a079abf5d8..014e4c7319 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestStatisticMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestStatisticMetaService.java
@@ -25,21 +25,29 @@ import java.sql.SQLException;
 import java.sql.Statement;
 import java.time.Instant;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
+import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.exceptions.IllegalStatisticNameException;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.FilesetEntity;
 import org.apache.gravitino.meta.ModelEntity;
+import org.apache.gravitino.meta.NamespacedEntityId;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.StatisticEntity;
 import org.apache.gravitino.meta.TableEntity;
@@ -49,16 +57,758 @@ import org.apache.gravitino.stats.StatisticValues;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
 import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.StatisticMetaMapper;
 import org.apache.gravitino.storage.relational.po.SchemaPO;
+import org.apache.gravitino.storage.relational.po.StatisticPO;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
+import org.apache.ibatis.exceptions.PersistenceException;
 import org.apache.ibatis.session.SqlSession;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.TestTemplate;
 
 public class TestStatisticMetaService extends TestJDBCBackend {
   private final StatisticMetaService statisticMetaService = 
StatisticMetaService.getInstance();
 
+  /** Verifies the version snapshot query excludes unrelated statistic rows. */
+  @TestTemplate
+  public void testStatisticSnapshotLoadsOnlyRequestedNames() throws Exception {
+    String metalake = "statistic_filtered_metalake";
+    String catalog = "statistic_filtered_catalog";
+    String schema = "statistic_filtered_schema";
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createParentEntities(metalake, catalog, schema, auditInfo);
+    TableEntity table =
+        createAndInsertTableEntity(Namespace.of(metalake, catalog, schema), 
"statistic_filtered");
+    StatisticEntity unrelated =
+        TableStatisticEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("unrelated")
+            .withValue(StatisticValues.longValue(2L))
+            .withAuditInfo(auditInfo)
+            .build();
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createStatisticEntity(auditInfo, 1L), unrelated),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+
+    NamespacedEntityId endpoint =
+        EntityIdService.getEntityIds(table.nameIdentifier(), 
Entity.EntityType.TABLE);
+    List<StatisticPO> selected = 
statisticMetaService.listStatisticPOs(endpoint, List.of("test"));
+    Assertions.assertEquals(1, selected.size());
+    Assertions.assertEquals("test", selected.get(0).getStatisticName());
+  }
+
+  /** Verifies two writers that observe a missing statistic get one conflict 
rather than a 500. */
+  @TestTemplate
+  public void testConcurrentFirstStatisticWritesReportConflict() throws 
Exception {
+    String metalake = "statistic_first_write_metalake";
+    String catalog = "statistic_first_write_catalog";
+    String schema = "statistic_first_write_schema";
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createParentEntities(metalake, catalog, schema, auditInfo);
+    TableEntity table =
+        createAndInsertTableEntity(
+            Namespace.of(metalake, catalog, schema), "statistic_first_write");
+
+    CyclicBarrier bothReadMissing = new CyclicBarrier(2);
+    StatisticMetaService racingService =
+        new StatisticMetaService() {
+          @Override
+          List<StatisticPO> listStatisticPOs(NamespacedEntityId endpoint, 
List<String> names) {
+            List<StatisticPO> rows = super.listStatisticPOs(endpoint, names);
+            try {
+              bothReadMissing.await(30, TimeUnit.SECONDS);
+            } catch (Exception e) {
+              throw new RuntimeException(e);
+            }
+            return rows;
+          }
+        };
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    try {
+      Future<Throwable> first =
+          executor.submit(() -> writeFirstStatistic(racingService, table, 
auditInfo, 1L));
+      Future<Throwable> second =
+          executor.submit(() -> writeFirstStatistic(racingService, table, 
auditInfo, 2L));
+
+      Throwable firstFailure = first.get(30, TimeUnit.SECONDS);
+      Throwable secondFailure = second.get(30, TimeUnit.SECONDS);
+      Assertions.assertTrue((firstFailure == null) != (secondFailure == null));
+      Throwable conflict = firstFailure == null ? secondFailure : firstFailure;
+      Assertions.assertInstanceOf(OptimisticLockException.class, conflict);
+      // The duplicate-key failure stays attached for diagnosis.
+      Assertions.assertNotNull(conflict.getCause());
+      Assertions.assertEquals(
+          1,
+          statisticMetaService
+              .listStatisticsByEntity(table.nameIdentifier(), 
Entity.EntityType.TABLE)
+              .size());
+    } finally {
+      executor.shutdownNow();
+    }
+  }
+
+  private Throwable writeFirstStatistic(
+      StatisticMetaService service, TableEntity table, AuditInfo auditInfo, 
long value) {
+    try {
+      service.writeStatisticsWithVersion(
+          List.of(createStatisticEntity(auditInfo, value)),
+          table.nameIdentifier(),
+          Entity.EntityType.TABLE);
+      return null;
+    } catch (Throwable failure) {
+      return failure;
+    }
+  }
+
+  /** Verifies stale mapper updates and deletes cannot change a newer 
statistic version. */
+  @TestTemplate
+  public void testStatisticVersionCompareAndSet() throws Exception {
+    String metalake = "statistic_cas_metalake";
+    String catalog = "statistic_cas_catalog";
+    String schema = "statistic_cas_schema";
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createParentEntities(metalake, catalog, schema, auditInfo);
+    TableEntity table =
+        createAndInsertTableEntity(Namespace.of(metalake, catalog, schema), 
"statistic_cas_table");
+    StatisticEntity initial = createStatisticEntity(auditInfo, 10L);
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(initial), table.nameIdentifier(), Entity.EntityType.TABLE);
+    Long metalakeId =
+        EntityIdService.getEntityId(NameIdentifier.of(metalake), 
Entity.EntityType.METALAKE);
+    StatisticPO stale =
+        SessionUtils.getWithoutCommit(
+                StatisticMetaMapper.class,
+                mapper -> mapper.listStatisticPOsByEntityId(metalakeId, 
table.id()))
+            .get(0);
+    Assertions.assertEquals(1L, stale.getCurrentVersion());
+
+    // Identity, target and version alone must not permit a delete for a 
different name.
+    StatisticPO wrongName =
+        StatisticPO.builder()
+            .withMetalakeId(stale.getMetalakeId())
+            .withStatisticId(stale.getStatisticId())
+            .withMetadataObjectId(stale.getMetadataObjectId())
+            .withMetadataObjectType(stale.getMetadataObjectType())
+            .withStatisticName("different_name")
+            .withStatisticValue(stale.getStatisticValue())
+            .withAuditInfo(stale.getAuditInfo())
+            .withCurrentVersion(stale.getCurrentVersion())
+            .withLastVersion(stale.getLastVersion())
+            .withDeletedAt(stale.getDeletedAt())
+            .build();
+    int wrongNameDeleted =
+        SessionUtils.getWithoutCommit(
+            StatisticMetaMapper.class,
+            mapper -> 
mapper.batchDeleteStatisticPOsWithVersion(List.of(wrongName)));
+    Assertions.assertEquals(0, wrongNameDeleted);
+
+    StatisticEntity replacement = createStatisticEntity(auditInfo, 20L);
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(replacement), table.nameIdentifier(), Entity.EntityType.TABLE);
+    StatisticPO current =
+        SessionUtils.getWithoutCommit(
+                StatisticMetaMapper.class,
+                mapper -> mapper.listStatisticPOsByEntityId(metalakeId, 
table.id()))
+            .get(0);
+    Assertions.assertEquals(stale.getStatisticId(), current.getStatisticId());
+    Assertions.assertEquals(2L, current.getCurrentVersion());
+    Assertions.assertEquals(1L, current.getLastVersion());
+    String staleValue =
+        StatisticPO.initializeStatisticPOs(
+                List.of(createStatisticEntity(auditInfo, 30L)),
+                metalakeId,
+                table.id(),
+                MetadataObject.Type.TABLE)
+            .get(0)
+            .getStatisticValue();
+    StatisticPO staleReplacement =
+        StatisticPO.builder()
+            .withMetalakeId(stale.getMetalakeId())
+            .withStatisticId(stale.getStatisticId())
+            .withMetadataObjectId(stale.getMetadataObjectId())
+            .withMetadataObjectType(stale.getMetadataObjectType())
+            .withStatisticName(stale.getStatisticName())
+            .withStatisticValue(staleValue)
+            .withAuditInfo(stale.getAuditInfo())
+            .withCurrentVersion(stale.getCurrentVersion())
+            .withLastVersion(stale.getLastVersion())
+            .withDeletedAt(stale.getDeletedAt())
+            .build();
+    int staleUpdated =
+        SessionUtils.getWithoutCommit(
+            StatisticMetaMapper.class,
+            mapper -> 
mapper.batchUpdateStatisticPOsWithVersion(List.of(staleReplacement)));
+    Assertions.assertEquals(0, staleUpdated);
+    int staleDeleted =
+        SessionUtils.getWithoutCommit(
+            StatisticMetaMapper.class,
+            mapper -> 
mapper.batchDeleteStatisticPOsWithVersion(List.of(stale)));
+    Assertions.assertEquals(0, staleDeleted);
+    Assertions.assertEquals(
+        20L,
+        statisticMetaService
+            .listStatisticsByEntity(table.nameIdentifier(), 
Entity.EntityType.TABLE)
+            .get(0)
+            .value()
+            .value());
+  }
+
+  /** Verifies a write or delete fails when its target is dropped after the 
version snapshot. */
+  @TestTemplate
+  public void testStatisticWritesFailWhenTargetDroppedAfterSnapshot() throws 
Exception {
+    String metalake = "statistic_dropped_target_metalake";
+    String catalog = "statistic_dropped_target_catalog";
+    String schema = "statistic_dropped_target_schema";
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createParentEntities(metalake, catalog, schema, auditInfo);
+    Long metalakeId =
+        EntityIdService.getEntityId(NameIdentifier.of(metalake), 
Entity.EntityType.METALAKE);
+
+    TableEntity writeTarget =
+        createAndInsertTableEntity(Namespace.of(metalake, catalog, schema), 
"dropped_write");
+    Assertions.assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            dropTableAfterSnapshot(writeTarget)
+                .writeStatisticsWithVersion(
+                    List.of(createStatisticEntity(auditInfo, 1L)),
+                    writeTarget.nameIdentifier(),
+                    Entity.EntityType.TABLE));
+    Assertions.assertEquals(0, countActiveStats(metalakeId));
+
+    TableEntity deleteTarget =
+        createAndInsertTableEntity(Namespace.of(metalake, catalog, schema), 
"dropped_delete");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createStatisticEntity(auditInfo, 1L)),
+        deleteTarget.nameIdentifier(),
+        Entity.EntityType.TABLE);
+    Assertions.assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            dropTableAfterSnapshot(deleteTarget)
+                .batchDeleteStatisticPOs(
+                    deleteTarget.nameIdentifier(), Entity.EntityType.TABLE, 
List.of("test")));
+    Assertions.assertEquals(0, countActiveStats(metalakeId));
+  }
+
+  /** Verifies a drop that loses to a concurrent drop of the same name is not 
a conflict. */
+  @TestTemplate
+  public void testConcurrentDropOfSameStatisticIsNotConflict() throws 
Exception {
+    String metalake = "statistic_double_drop_metalake";
+    String catalog = "statistic_double_drop_catalog";
+    String schema = "statistic_double_drop_schema";
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createParentEntities(metalake, catalog, schema, auditInfo);
+    TableEntity table =
+        createAndInsertTableEntity(Namespace.of(metalake, catalog, schema), 
"double_drop");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createStatisticEntity(auditInfo, 1L)),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+
+    StatisticMetaService losingDrop =
+        new StatisticMetaService() {
+          @Override
+          List<StatisticPO> listStatisticPOs(NamespacedEntityId endpoint, 
List<String> names) {
+            List<StatisticPO> rows = super.listStatisticPOs(endpoint, names);
+            Assertions.assertEquals(
+                1,
+                statisticMetaService.batchDeleteStatisticPOs(
+                    table.nameIdentifier(), Entity.EntityType.TABLE, names));
+            return rows;
+          }
+        };
+
+    Assertions.assertEquals(
+        0,
+        losingDrop.batchDeleteStatisticPOs(
+            table.nameIdentifier(), Entity.EntityType.TABLE, List.of("test")));
+    Assertions.assertTrue(
+        statisticMetaService
+            .listStatisticsByEntity(table.nameIdentifier(), 
Entity.EntityType.TABLE)
+            .isEmpty());
+  }
+
+  /** Verifies one batch statement replaces each statistic with its own value. 
*/
+  @TestTemplate
+  public void testBatchReplacementKeepsValuesPerStatistic() throws Exception {
+    TableEntity table = createBatchConflictTable("replace");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(
+            createNamedStatistic("a", 1L),
+            createNamedStatistic("b", 2L),
+            createNamedStatistic("c", 3L)),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(
+            createNamedStatistic("c", 30L),
+            createNamedStatistic("d", 40L),
+            createNamedStatistic("a", 10L)),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+
+    Map<String, StatisticEntity> current = statisticsByName(table);
+    Assertions.assertEquals(4, current.size());
+    Assertions.assertEquals(10L, current.get("a").value().value());
+    Assertions.assertEquals(2L, current.get("b").value().value());
+    Assertions.assertEquals(30L, current.get("c").value().value());
+    Assertions.assertEquals(40L, current.get("d").value().value());
+  }
+
+  /** Verifies a batch drop counts only its own deletes when a concurrent drop 
removed one name. */
+  @TestTemplate
+  public void testBatchDropCountsOnlyItsOwnDeletes() throws Exception {
+    TableEntity table = createBatchConflictTable("partial_drop");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createNamedStatistic("a", 1L), createNamedStatistic("b", 2L)),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+
+    StatisticMetaService losingDrop =
+        new StatisticMetaService() {
+          @Override
+          List<StatisticPO> listStatisticPOs(NamespacedEntityId endpoint, 
List<String> names) {
+            List<StatisticPO> rows = super.listStatisticPOs(endpoint, names);
+            Assertions.assertEquals(
+                1,
+                statisticMetaService.batchDeleteStatisticPOs(
+                    table.nameIdentifier(), Entity.EntityType.TABLE, 
List.of("a")));
+            return rows;
+          }
+        };
+
+    Assertions.assertEquals(
+        1,
+        losingDrop.batchDeleteStatisticPOs(
+            table.nameIdentifier(), Entity.EntityType.TABLE, List.of("a", 
"b")));
+    Assertions.assertTrue(statisticsByName(table).isEmpty());
+  }
+
+  /** Verifies concurrency failures of batch statements become conflicts on 
writes and drops. */
+  @TestTemplate
+  public void testBatchStatementConcurrencyFailuresAreConflicts() throws 
Exception {
+    TableEntity table = createBatchConflictTable("deadlock");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createNamedStatistic("a", 1L)), table.nameIdentifier(), 
Entity.EntityType.TABLE);
+
+    for (SQLException failure :
+        List.of(
+            new SQLException("MySQL deadlock", "40001", 1213),
+            new SQLException("PostgreSQL deadlock", "40P01"),
+            new SQLException("serialization failure", "40001"),
+            new SQLException("MySQL lock wait timeout", "40001", 1205),
+            new SQLException("MySQL lock wait timeout", "HY000", 1205),
+            new SQLException("H2 lock wait timeout", "HYT00", 50200),
+            new SQLException("PostgreSQL lock timeout", "55P03"))) {
+      StatisticMetaService failing = failingStatements(new 
PersistenceException(failure));
+      OptimisticLockException writeConflict =
+          Assertions.assertThrows(
+              OptimisticLockException.class,
+              () ->
+                  failing.writeStatisticsWithVersion(
+                      List.of(createNamedStatistic("a", 2L), 
createNamedStatistic("b", 2L)),
+                      table.nameIdentifier(),
+                      Entity.EntityType.TABLE));
+      Assertions.assertSame(failure, writeConflict.getCause().getCause());
+      Assertions.assertTrue(writeConflict.getMessage().contains("[a, b]"));
+      
Assertions.assertTrue(writeConflict.getMessage().contains(table.nameIdentifier().toString()));
+
+      OptimisticLockException dropConflict =
+          Assertions.assertThrows(
+              OptimisticLockException.class,
+              () ->
+                  failing.batchDeleteStatisticPOs(
+                      table.nameIdentifier(), Entity.EntityType.TABLE, 
List.of("a")));
+      Assertions.assertSame(failure, dropConflict.getCause().getCause());
+    }
+
+    // A duplicate key is a conflict on a write and on a drop. Names that 
collide only in the
+    // backend
+    // collation are rejected before the write, so a duplicate key can only 
come from a concurrent
+    // creator, even if that creator's row is gone again by the time the 
caller looks.
+    SQLException duplicate = new SQLException("duplicate key", "23505");
+    StatisticMetaService duplicating = failingStatements(new 
PersistenceException(duplicate));
+    OptimisticLockException writeDuplicate =
+        Assertions.assertThrows(
+            OptimisticLockException.class,
+            () ->
+                duplicating.writeStatisticsWithVersion(
+                    List.of(createNamedStatistic("b", 2L)),
+                    table.nameIdentifier(),
+                    Entity.EntityType.TABLE));
+    Assertions.assertSame(duplicate, writeDuplicate.getCause().getCause());
+    OptimisticLockException dropDuplicate =
+        Assertions.assertThrows(
+            OptimisticLockException.class,
+            () ->
+                duplicating.batchDeleteStatisticPOs(
+                    table.nameIdentifier(), Entity.EntityType.TABLE, 
List.of("a")));
+    Assertions.assertSame(duplicate, dropDuplicate.getCause().getCause());
+
+    PersistenceException connectionFailure =
+        new PersistenceException(new SQLException("connection lost", "08006"));
+    StatisticMetaService failing = failingStatements(connectionFailure);
+    Assertions.assertSame(
+        connectionFailure,
+        Assertions.assertThrows(
+            PersistenceException.class,
+            () ->
+                failing.writeStatisticsWithVersion(
+                    List.of(createNamedStatistic("a", 3L)),
+                    table.nameIdentifier(),
+                    Entity.EntityType.TABLE)));
+    Assertions.assertSame(
+        connectionFailure,
+        Assertions.assertThrows(
+            PersistenceException.class,
+            () ->
+                failing.batchDeleteStatisticPOs(
+                    table.nameIdentifier(), Entity.EntityType.TABLE, 
List.of("a"))));
+
+    Map<String, StatisticEntity> remaining = statisticsByName(table);
+    Assertions.assertEquals(1, remaining.size());
+    Assertions.assertEquals(1L, remaining.get("a").value().value());
+  }
+
+  /** Verifies a real H2 lock wait timeout on the fenced write is reported as 
a conflict. */
+  @TestTemplate
+  public void testH2LockWaitTimeoutIsConflict() throws Exception {
+    Assumptions.assumeTrue("h2".equalsIgnoreCase(backendType), "H2 lock wait 
timeout only");
+    TableEntity table = createBatchConflictTable("h2_lock_timeout");
+
+    // Hold the table row that the write fences on, so the write waits until 
H2 gives up.
+    try (SqlSession holder =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(false);
+        Statement statement = holder.getConnection().createStatement()) {
+      try (ResultSet ignored =
+          statement.executeQuery(
+              "SELECT table_id FROM table_meta WHERE table_id = " + table.id() 
+ " FOR UPDATE")) {
+        OptimisticLockException conflict =
+            Assertions.assertThrows(
+                OptimisticLockException.class,
+                () ->
+                    statisticMetaService.writeStatisticsWithVersion(
+                        List.of(createNamedStatistic("a", 1L)),
+                        table.nameIdentifier(),
+                        Entity.EntityType.TABLE));
+        Throwable cause = conflict;
+        while (cause != null && !(cause instanceof SQLException)) {
+          cause = cause.getCause();
+        }
+        Assertions.assertNotNull(cause, "the lock wait timeout stays attached 
for diagnosis");
+        Assertions.assertEquals(50200, ((SQLException) cause).getErrorCode());
+      } finally {
+        holder.rollback();
+      }
+    }
+    Assertions.assertTrue(statisticsByName(table).isEmpty());
+  }
+
+  /** Verifies a write of a name equal to an existing one under a padding 
collation is illegal. */
+  @TestTemplate
+  public void testWriteOfNameDifferingOnlyInTrailingSpaces() throws Exception {
+    TableEntity table = createBatchConflictTable("trailing_space_write");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createNamedStatistic("name", 1L)), table.nameIdentifier(), 
Entity.EntityType.TABLE);
+
+    List<StatisticEntity> padded = List.of(createNamedStatistic("name ", 2L));
+    if ("mysql".equalsIgnoreCase(backendType)) {
+      // MySQL's collation treats "name " as "name", so the write can never 
succeed.
+      IllegalStatisticNameException e =
+          Assertions.assertThrows(
+              IllegalStatisticNameException.class,
+              () ->
+                  statisticMetaService.writeStatisticsWithVersion(
+                      padded, table.nameIdentifier(), 
Entity.EntityType.TABLE));
+      // The message names both the requested name and the stored statistic it 
collides with.
+      Assertions.assertTrue(e.getMessage().contains("'name '"), 
e.getMessage());
+      Assertions.assertTrue(e.getMessage().contains("'name'"), e.getMessage());
+      Assertions.assertEquals(1, statisticsByName(table).size());
+    } else {
+      statisticMetaService.writeStatisticsWithVersion(
+          padded, table.nameIdentifier(), Entity.EntityType.TABLE);
+      Assertions.assertEquals(2L, statisticsByName(table).get("name 
").value().value());
+    }
+    Assertions.assertEquals(1L, 
statisticsByName(table).get("name").value().value());
+  }
+
+  /** Verifies names that collide only under a padding collation in one 
request are illegal. */
+  @TestTemplate
+  public void testWriteOfTrailingSpaceAliasesInOneRequest() throws Exception {
+    TableEntity table = createBatchConflictTable("trailing_space_batch");
+    List<StatisticEntity> bothNew =
+        List.of(createNamedStatistic("x", 1L), createNamedStatistic("x ", 2L));
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createNamedStatistic("y", 1L)), table.nameIdentifier(), 
Entity.EntityType.TABLE);
+    List<StatisticEntity> updateAndAlias =
+        List.of(createNamedStatistic("y", 10L), createNamedStatistic("y ", 
20L));
+
+    if ("mysql".equalsIgnoreCase(backendType)) {
+      // MySQL's unique key treats "x " as "x", so these requests can never 
succeed. They are
+      // rejected before any statement runs, so they cannot be mistaken for a 
concurrent creator.
+      for (List<StatisticEntity> request : List.of(bothNew, updateAndAlias)) {
+        IllegalStatisticNameException e =
+            Assertions.assertThrows(
+                IllegalStatisticNameException.class,
+                () ->
+                    statisticMetaService.writeStatisticsWithVersion(
+                        request, table.nameIdentifier(), 
Entity.EntityType.TABLE));
+        String alias = request.get(0).name();
+        Assertions.assertTrue(e.getMessage().contains("'" + alias + "'"), 
e.getMessage());
+        Assertions.assertTrue(e.getMessage().contains("'" + alias + " '"), 
e.getMessage());
+      }
+      Map<String, StatisticEntity> current = statisticsByName(table);
+      Assertions.assertEquals(1, current.size());
+      Assertions.assertEquals(1L, current.get("y").value().value());
+    } else {
+      statisticMetaService.writeStatisticsWithVersion(
+          bothNew, table.nameIdentifier(), Entity.EntityType.TABLE);
+      statisticMetaService.writeStatisticsWithVersion(
+          updateAndAlias, table.nameIdentifier(), Entity.EntityType.TABLE);
+      Map<String, StatisticEntity> current = statisticsByName(table);
+      Assertions.assertEquals(4, current.size());
+      Assertions.assertEquals(10L, current.get("y").value().value());
+      Assertions.assertEquals(20L, current.get("y ").value().value());
+    }
+  }
+
+  /**
+   * Verifies a drop matches only the exact requested name, even under a 
padding collation. Only the
+   * MySQL backend, whose collation ignores trailing spaces, can return "name" 
for "name ".
+   */
+  @TestTemplate
+  public void testDropDoesNotDropNameWithoutTrailingSpace() throws Exception {
+    TableEntity table = createBatchConflictTable("trailing_space");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createNamedStatistic("name", 1L)), table.nameIdentifier(), 
Entity.EntityType.TABLE);
+
+    Assertions.assertEquals(
+        0,
+        statisticMetaService.batchDeleteStatisticPOs(
+            table.nameIdentifier(), Entity.EntityType.TABLE, List.of("name 
")));
+    Assertions.assertEquals(1L, 
statisticsByName(table).get("name").value().value());
+  }
+
+  /** Verifies a write or delete based on a stale version fails without 
touching the newer value. */
+  @TestTemplate
+  public void testStaleStatisticWriteAndDeleteReportConflict() throws 
Exception {
+    String metalake = "statistic_stale_service_metalake";
+    String catalog = "statistic_stale_service_catalog";
+    String schema = "statistic_stale_service_schema";
+    AuditInfo auditInfo =
+        
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.now()).build();
+    createParentEntities(metalake, catalog, schema, auditInfo);
+    TableEntity table =
+        createAndInsertTableEntity(Namespace.of(metalake, catalog, schema), 
"stale_service");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createStatisticEntity(auditInfo, 1L)),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+
+    OptimisticLockException staleWrite =
+        Assertions.assertThrows(
+            OptimisticLockException.class,
+            () ->
+                updateAfterSnapshot(table, auditInfo, 2L)
+                    .writeStatisticsWithVersion(
+                        List.of(createStatisticEntity(auditInfo, 3L)),
+                        table.nameIdentifier(),
+                        Entity.EntityType.TABLE));
+    Assertions.assertTrue(staleWrite.getMessage().contains("retry the 
operation"));
+    Assertions.assertEquals(2L, singleStatisticValue(table));
+
+    Assertions.assertThrows(
+        OptimisticLockException.class,
+        () ->
+            updateAfterSnapshot(table, auditInfo, 4L)
+                .batchDeleteStatisticPOs(
+                    table.nameIdentifier(), Entity.EntityType.TABLE, 
List.of("test")));
+    Assertions.assertEquals(4L, singleStatisticValue(table));
+  }
+
+  /** Verifies a late write conflict rolls back earlier updates and inserts in 
the batch. */
+  @TestTemplate
+  public void testStatisticWriteConflictRollsBackWholeBatch() throws Exception 
{
+    TableEntity table = createBatchConflictTable("write");
+    StatisticEntity unchanged = createNamedStatistic("a_existing", 10L);
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(unchanged, createStatisticEntity(AUDIT_INFO, 1L)),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+
+    OptimisticLockException conflict =
+        Assertions.assertThrows(
+            OptimisticLockException.class,
+            () ->
+                updateAfterSnapshot(table, AUDIT_INFO, 2L)
+                    .writeStatisticsWithVersion(
+                        // A stale update in the batch must also roll back the 
insert of b_new.
+                        List.of(
+                            createStatisticEntity(AUDIT_INFO, 3L),
+                            createNamedStatistic("b_new", 30L),
+                            createNamedStatistic("a_existing", 20L)),
+                        table.nameIdentifier(),
+                        Entity.EntityType.TABLE));
+    assertStatisticConflict(conflict, table);
+    Map<String, StatisticEntity> remaining = statisticsByName(table);
+    Assertions.assertEquals(2, remaining.size());
+    Assertions.assertEquals(unchanged.fields(), 
remaining.get("a_existing").fields());
+    Assertions.assertEquals(2L, remaining.get("test").value().value());
+  }
+
+  /** Verifies a late delete conflict restores statistics deleted earlier in 
the batch. */
+  @TestTemplate
+  public void testStatisticDeleteConflictRollsBackWholeBatch() throws 
Exception {
+    TableEntity table = createBatchConflictTable("delete");
+    StatisticEntity unchanged = createNamedStatistic("a_existing", 10L);
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(unchanged, createStatisticEntity(AUDIT_INFO, 1L)),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+
+    OptimisticLockException conflict =
+        Assertions.assertThrows(
+            OptimisticLockException.class,
+            () ->
+                updateAfterSnapshot(table, AUDIT_INFO, 2L)
+                    .batchDeleteStatisticPOs(
+                        table.nameIdentifier(),
+                        Entity.EntityType.TABLE,
+                        List.of("test", "a_existing")));
+    assertStatisticConflict(conflict, table);
+    Map<String, StatisticEntity> remaining = statisticsByName(table);
+    Assertions.assertEquals(2, remaining.size());
+    Assertions.assertEquals(unchanged.fields(), 
remaining.get("a_existing").fields());
+    Assertions.assertEquals(2L, remaining.get("test").value().value());
+  }
+
+  /** Verifies a stale write or delete cannot affect a same-name replacement 
at version one. */
+  @TestTemplate
+  public void testRecreatedStatisticIsNotChangedByStaleWriteOrDelete() throws 
Exception {
+    TableEntity table = createBatchConflictTable("recreated");
+    statisticMetaService.writeStatisticsWithVersion(
+        List.of(createStatisticEntity(AUDIT_INFO, 1L)),
+        table.nameIdentifier(),
+        Entity.EntityType.TABLE);
+    for (boolean delete : List.of(false, true)) {
+      StatisticEntity replacement = createStatisticEntity(AUDIT_INFO, 2L);
+      StatisticMetaService staleService =
+          new StatisticMetaService() {
+            @Override
+            List<StatisticPO> listStatisticPOs(NamespacedEntityId endpoint, 
List<String> names) {
+              List<StatisticPO> rows = super.listStatisticPOs(endpoint, names);
+              Assertions.assertEquals(1L, rows.get(0).getCurrentVersion());
+              Assertions.assertEquals(
+                  1,
+                  statisticMetaService.batchDeleteStatisticPOs(
+                      table.nameIdentifier(), Entity.EntityType.TABLE, names));
+              statisticMetaService.writeStatisticsWithVersion(
+                  List.of(replacement), table.nameIdentifier(), 
Entity.EntityType.TABLE);
+              return rows;
+            }
+          };
+      OptimisticLockException conflict =
+          Assertions.assertThrows(
+              OptimisticLockException.class,
+              () -> {
+                if (delete) {
+                  staleService.batchDeleteStatisticPOs(
+                      table.nameIdentifier(), Entity.EntityType.TABLE, 
List.of("test"));
+                } else {
+                  staleService.writeStatisticsWithVersion(
+                      List.of(createStatisticEntity(AUDIT_INFO, 3L)),
+                      table.nameIdentifier(),
+                      Entity.EntityType.TABLE);
+                }
+              });
+      assertStatisticConflict(conflict, table);
+      Assertions.assertEquals(replacement.fields(), 
statisticsByName(table).get("test").fields());
+    }
+  }
+
+  private TableEntity createBatchConflictTable(String suffix) throws Exception 
{
+    String metalake = "batch_conflict_metalake_" + suffix;
+    String catalog = "batch_conflict_catalog";
+    String schema = "batch_conflict_schema";
+    createParentEntities(metalake, catalog, schema, AUDIT_INFO);
+    return createAndInsertTableEntity(Namespace.of(metalake, catalog, schema), 
"batch_conflict");
+  }
+
+  private StatisticEntity createNamedStatistic(String name, long value) {
+    return TableStatisticEntity.builder()
+        .withId(RandomIdGenerator.INSTANCE.nextId())
+        .withName(name)
+        .withValue(StatisticValues.longValue(value))
+        .withAuditInfo(AUDIT_INFO)
+        .build();
+  }
+
+  private Map<String, StatisticEntity> statisticsByName(TableEntity table) {
+    return statisticMetaService
+        .listStatisticsByEntity(table.nameIdentifier(), 
Entity.EntityType.TABLE)
+        .stream()
+        .collect(Collectors.toMap(StatisticEntity::name, statistic -> 
statistic));
+  }
+
+  private void assertStatisticConflict(OptimisticLockException conflict, 
TableEntity table) {
+    Assertions.assertTrue(conflict.getMessage().contains("test"));
+    
Assertions.assertTrue(conflict.getMessage().contains(table.nameIdentifier().toString()));
+    Assertions.assertTrue(conflict.getMessage().contains("retry the 
operation"));
+  }
+
+  private static StatisticMetaService failingStatements(RuntimeException 
failure) {
+    return new StatisticMetaService() {
+      @Override
+      int executeStatement(Function<StatisticMetaMapper, Integer> statement) {
+        throw failure;
+      }
+    };
+  }
+
+  private StatisticMetaService updateAfterSnapshot(
+      TableEntity table, AuditInfo auditInfo, long value) {
+    return new StatisticMetaService() {
+      @Override
+      List<StatisticPO> listStatisticPOs(NamespacedEntityId endpoint, 
List<String> names) {
+        List<StatisticPO> rows = super.listStatisticPOs(endpoint, names);
+        statisticMetaService.writeStatisticsWithVersion(
+            List.of(createStatisticEntity(auditInfo, value)),
+            table.nameIdentifier(),
+            Entity.EntityType.TABLE);
+        return rows;
+      }
+    };
+  }
+
+  private Object singleStatisticValue(TableEntity table) {
+    List<StatisticEntity> statistics =
+        statisticMetaService.listStatisticsByEntity(
+            table.nameIdentifier(), Entity.EntityType.TABLE);
+    Assertions.assertEquals(1, statistics.size());
+    return statistics.get(0).value().value();
+  }
+
+  private StatisticMetaService dropTableAfterSnapshot(TableEntity table) {
+    return new StatisticMetaService() {
+      @Override
+      List<StatisticPO> listStatisticPOs(NamespacedEntityId endpoint, 
List<String> names) {
+        List<StatisticPO> rows = super.listStatisticPOs(endpoint, names);
+        
Assertions.assertTrue(TableMetaService.getInstance().deleteTable(table.nameIdentifier()));
+        return rows;
+      }
+    };
+  }
+
   @TestTemplate
   public void testTableStatisticWriteWaitsForConcurrentSchemaDelete() throws 
Exception {
     String metalakeName = "metalake_for_statistic_schema_fence";
@@ -257,7 +1007,7 @@ public class TestStatisticMetaService extends 
TestJDBCBackend {
     StatisticEntity statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
 
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, table.nameIdentifier(), Entity.EntityType.TABLE);
 
     List<StatisticEntity> listEntities =
@@ -271,7 +1021,7 @@ public class TestStatisticMetaService extends 
TestJDBCBackend {
     statisticEntity = createStatisticEntity(auditInfo, 200L);
     statisticEntities.clear();
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, table.nameIdentifier(), Entity.EntityType.TABLE);
 
     listEntities =
@@ -346,25 +1096,25 @@ public class TestStatisticMetaService extends 
TestJDBCBackend {
     List<StatisticEntity> statisticEntities = Lists.newArrayList();
     StatisticEntity statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, table.nameIdentifier(), Entity.EntityType.TABLE);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, topic.nameIdentifier(), Entity.EntityType.TOPIC);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, fileset.nameIdentifier(), 
Entity.EntityType.FILESET);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, model.nameIdentifier(), Entity.EntityType.MODEL);
 
     // assert stats
@@ -459,25 +1209,25 @@ public class TestStatisticMetaService extends 
TestJDBCBackend {
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, table.nameIdentifier(), Entity.EntityType.TABLE);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, topic.nameIdentifier(), Entity.EntityType.TOPIC);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, fileset.nameIdentifier(), 
Entity.EntityType.FILESET);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, model.nameIdentifier(), Entity.EntityType.MODEL);
 
     // assert stats
@@ -540,25 +1290,25 @@ public class TestStatisticMetaService extends 
TestJDBCBackend {
     statisticEntities = Lists.newArrayList();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, table.nameIdentifier(), Entity.EntityType.TABLE);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, topic.nameIdentifier(), Entity.EntityType.TOPIC);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, fileset.nameIdentifier(), 
Entity.EntityType.FILESET);
 
     statisticEntities.clear();
     statisticEntity = createStatisticEntity(auditInfo, 100L);
     statisticEntities.add(statisticEntity);
-    statisticMetaService.batchInsertStatisticPOsOnDuplicateKeyUpdate(
+    statisticMetaService.writeStatisticsWithVersion(
         statisticEntities, model.nameIdentifier(), Entity.EntityType.MODEL);
 
     // assert stats count
diff --git a/docs/manage-statistics-in-gravitino.md 
b/docs/manage-statistics-in-gravitino.md
index 05676e8fba..754915ae77 100644
--- a/docs/manage-statistics-in-gravitino.md
+++ b/docs/manage-statistics-in-gravitino.md
@@ -24,6 +24,19 @@ may reserve later.
 Updating creates a statistic that does not exist and overwrites one that does. 
Reserved statistics
 maintained by the system are not modifiable and the request is rejected.
 
+Concurrent creation, modification or deletion of the same statistic can return 
HTTP 409, as can a
+write that times out waiting for a concurrent writer. The entire update batch 
is rolled back on a
+conflict. If the client chooses to retry, it should retry against the current 
state. If the target
+table was deleted or replaced while the operation was in progress, the request 
fails with HTTP 404.
+On a MySQL backend, whose collation ignores trailing spaces, a name that 
differs only in trailing
+spaces from an existing statistic, or from another name in the same request, 
is rejected with
+HTTP 400.
+
+OCC conflicts are an expected result of concurrent writes. Clients decide 
whether to abort or
+retry according to their application semantics. Gravitino does not 
automatically retry a
+conflicting write. The built-in statistics updater propagates the exception to 
its caller; a
+maintenance job can therefore fail on a conflict under its existing failure 
handling.
+
 <Tabs groupId='language' queryString>
 <TabItem value="shell" label="REST">
 
@@ -74,6 +87,11 @@ List<Statistic> statistics = 
orders.supportsStatistics().listStatistics();
 
 ### Drop Statistics
 
+Dropping a statistic that another request has already removed is not a 
conflict. If a statistic
+was updated or deleted and recreated after the drop read it, the request can 
return HTTP 409 and
+the entire drop batch is rolled back. Retry against the current state. If the 
target table was
+deleted or replaced while dropping existing statistics, the request fails with 
HTTP 404.
+
 <Tabs groupId='language' queryString>
 <TabItem value="shell" label="REST">
 
diff --git a/docs/open-api/statistics.yaml b/docs/open-api/statistics.yaml
index 826a4b7ec1..e373d8c36c 100644
--- a/docs/open-api/statistics.yaml
+++ b/docs/open-api/statistics.yaml
@@ -97,7 +97,7 @@ paths:
                 NoSuchMetadataObjectException:
                   $ref: "#/components/examples/NoSuchMetadataObjectException"
         "409":
-          description: Conflict - The specified statistic is unmodifiable
+          description: Conflict - The specified statistic is unmodifiable or 
was modified concurrently; the caller decides whether to abort or retry against 
the current state
           content:
             application/vnd.gravitino.v1+json:
               schema:
@@ -150,7 +150,7 @@ paths:
                 NoSuchMetadataObjectException:
                   $ref: "#/components/examples/NoSuchMetadataObjectException"
         "409":
-          description: Conflict - The specified statistic is unmodifiable
+          description: Conflict - The specified statistic is unmodifiable or 
was modified concurrently; the caller decides whether to abort or retry against 
the current state
           content:
             application/vnd.gravitino.v1+json:
               schema:
diff --git 
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/updater/statistics/TestGravitinoStatisticsUpdater.java
 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/updater/statistics/TestGravitinoStatisticsUpdater.java
index f856cc7c03..7780d021f9 100644
--- 
a/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/updater/statistics/TestGravitinoStatisticsUpdater.java
+++ 
b/maintenance/optimizer/src/test/java/org/apache/gravitino/maintenance/optimizer/updater/statistics/TestGravitinoStatisticsUpdater.java
@@ -24,6 +24,7 @@ import java.util.List;
 import java.util.Map;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.client.GravitinoClient;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.maintenance.optimizer.api.common.PartitionPath;
 import org.apache.gravitino.maintenance.optimizer.api.common.StatisticEntry;
 import org.apache.gravitino.maintenance.optimizer.common.PartitionEntryImpl;
@@ -32,6 +33,7 @@ import 
org.apache.gravitino.maintenance.optimizer.recommender.util.PartitionUtil
 import org.apache.gravitino.stats.PartitionStatisticsUpdate;
 import org.apache.gravitino.stats.StatisticValue;
 import org.apache.gravitino.stats.StatisticValues;
+import org.apache.gravitino.stats.SupportsStatistics;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
@@ -124,6 +126,30 @@ class TestGravitinoStatisticsUpdater {
     Assertions.assertEquals(3L, updates.get(0).statistics().get("s2").value());
   }
 
+  @Test
+  void testTableStatisticConflictPropagatesWithoutRetry() {
+    GravitinoStatisticsUpdater updater = new GravitinoStatisticsUpdater();
+    GravitinoClient client = Mockito.mock(GravitinoClient.class, 
Mockito.RETURNS_DEEP_STUBS);
+    updater.setGravitinoClientForTest(client);
+    SupportsStatistics statistics =
+        client
+            .loadCatalog("catalog")
+            .asTableCatalog()
+            .loadTable(NameIdentifier.of("db", "table"))
+            .supportsStatistics();
+    OptimisticLockException conflict = new OptimisticLockException("concurrent 
statistic update");
+    
Mockito.doThrow(conflict).when(statistics).updateStatistics(Mockito.anyMap());
+
+    Assertions.assertSame(
+        conflict,
+        Assertions.assertThrows(
+            OptimisticLockException.class,
+            () ->
+                updater.updateTableStatistics(
+                    NameIdentifier.of("catalog", "db", "table"), 
List.of(stat("row_count", 10L)))));
+    Mockito.verify(statistics).updateStatistics(Mockito.anyMap());
+  }
+
   private StatisticEntry<?> stat(String name, long value) {
     return new StatisticEntryImpl<>(name, StatisticValues.longValue(value));
   }
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
index 07d7c7ca50..c5d7f6b098 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestSemanticModelOperations.java
@@ -1056,7 +1056,6 @@ public class TestSemanticModelOperations extends 
BaseOperationsTest {
         JsonUtils.objectMapper().readValue(response.body(), 
SemanticModelResponse.class);
     body.validate();
     Assertions.assertEquals("marketing", body.getSemanticModel().name());
-    verify(dispatcher).importOssieDocument(namespace, 
OssieDocument.yaml(yaml));
     verify(dispatcher)
         .createSemanticModel(
             eq(ident),
diff --git 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestStatisticOperations.java
 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestStatisticOperations.java
index 751ee690c2..f8654506c5 100644
--- 
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestStatisticOperations.java
+++ 
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestStatisticOperations.java
@@ -61,6 +61,7 @@ import org.apache.gravitino.dto.stats.StatisticDTO;
 import org.apache.gravitino.dto.util.DTOConverters;
 import org.apache.gravitino.exceptions.IllegalStatisticNameException;
 import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.exceptions.UnmodifiableStatisticException;
 import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.meta.AuditInfo;
@@ -516,6 +517,52 @@ public class TestStatisticOperations extends 
BaseOperationsTest {
     Assertions.assertFalse(errorResponse.getMessage().contains(longName));
   }
 
+  @Test
+  public void testUpdateAndDropTableStatisticsReportOptimisticLockConflict() {
+    MetadataObject tableObject =
+        MetadataObjects.parse(
+            String.format("%s.%s.%s", catalog, schema, table), 
MetadataObject.Type.TABLE);
+    String path =
+        "/metalakes/"
+            + metalake
+            + "/objects/"
+            + tableObject.type()
+            + "/"
+            + tableObject.fullName()
+            + "/statistics";
+    when(tableDispatcher.tableExists(any())).thenReturn(true);
+    OptimisticLockException conflict =
+        new OptimisticLockException("statistic was modified concurrently");
+
+    Map<String, StatisticValue<?>> statsMap = Maps.newHashMap();
+    statsMap.put(Statistic.CUSTOM_PREFIX + "test1", 
StatisticValues.longValue(1L));
+    doThrow(conflict).when(manager).updateStatistics(any(), any(), any());
+    Response updateResp =
+        target(path)
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .put(entity(new StatisticsUpdateRequest(statsMap), 
MediaType.APPLICATION_JSON_TYPE));
+    assertOptimisticLockConflict(updateResp);
+
+    doThrow(conflict).when(manager).dropStatistics(any(), any(), any());
+    Response dropResp =
+        target(path)
+            .request(MediaType.APPLICATION_JSON_TYPE)
+            .accept("application/vnd.gravitino.v1+json")
+            .post(
+                entity(
+                    new StatisticsDropRequest(new String[] 
{Statistic.CUSTOM_PREFIX + "test1"}),
+                    MediaType.APPLICATION_JSON_TYPE));
+    assertOptimisticLockConflict(dropResp);
+  }
+
+  private static void assertOptimisticLockConflict(Response resp) {
+    Assertions.assertEquals(Response.Status.CONFLICT.getStatusCode(), 
resp.getStatus());
+    ErrorResponse errorResp = resp.readEntity(ErrorResponse.class);
+    Assertions.assertEquals(ErrorConstants.OPTIMISTIC_LOCK_CONFLICT_CODE, 
errorResp.getCode());
+    Assertions.assertEquals(OptimisticLockException.class.getSimpleName(), 
errorResp.getType());
+  }
+
   @Test
   public void testUpdateTableStatisticsWithNullRequestBody() {
     when(tableDispatcher.tableExists(any())).thenReturn(true);

Reply via email to