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

yuqi1129 pushed a commit to branch feat/12345-occ-table
in repository https://gitbox.apache.org/repos/asf/gravitino.git

commit 23d0f3c15f07b52c25817f51cf46807db1e8a1c8
Author: yuqi <[email protected]>
AuthorDate: Fri Aug 21 17:28:31 2026 +0800

    [#12345] improvement(core): add OCC for table writes
---
 .../lakehouse/lance/LanceTableOperations.java      |  40 +-
 .../lance/TestLanceConcurrentRepairStress.java     |  19 +-
 .../lakehouse/lance/TestLanceTableOperations.java  |  94 +++-
 .../gravitino/catalog/OperationDispatcher.java     |   4 +
 .../storage/relational/mapper/TableMetaMapper.java |  19 +-
 .../mapper/TableMetaSQLProviderFactory.java        |  22 +-
 .../provider/base/TableMetaBaseSQLProvider.java    |  50 +-
 .../postgresql/TableMetaPostgreSQLProvider.java    |   5 +-
 .../relational/service/TableMetaService.java       | 208 +++++----
 .../catalog/TestTableOperationDispatcher.java      |  12 +-
 .../base/TestTableMetaBaseSQLProvider.java         |  55 +++
 .../TestTableMetaPostgreSQLProvider.java           |  33 ++
 .../relational/service/TestTableMetaService.java   | 511 +++++++++++++++++++++
 13 files changed, 933 insertions(+), 139 deletions(-)

diff --git 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
index 7edfc468d5..08b36cae5e 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
@@ -50,6 +50,7 @@ import org.apache.gravitino.connector.SupportsSchemas;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NoSuchTableException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.exceptions.TableAlreadyExistsException;
 import org.apache.gravitino.lance.common.ops.gravitino.LanceDataTypeConverter;
 import org.apache.gravitino.lance.common.utils.LanceConstants;
@@ -65,7 +66,6 @@ import org.apache.gravitino.rel.expressions.sorts.SortOrder;
 import org.apache.gravitino.rel.expressions.transforms.Transform;
 import org.apache.gravitino.rel.indexes.Index;
 import org.apache.gravitino.storage.IdGenerator;
-import org.apache.gravitino.storage.relational.service.TableMetaService;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.lance.Dataset;
 import org.lance.ReadOptions;
@@ -561,30 +561,20 @@ public class LanceTableOperations extends 
ManagedTableOperations {
   }
 
   /**
-   * Applies an idempotent update to the stored table, retrying when the 
optimistic-lock CAS is lost
-   * to a concurrent update. The repair-on-load path runs on every {@code 
loadTable}, so concurrent
-   * loads of the same table race on the version CAS; {@code store.update} 
surfaces the lost race as
-   * an {@link IOException} whose message starts with {@link
-   * TableMetaService#UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX}. Because the 
updater is idempotent, the
-   * loser sleeps a short randomized backoff (to avoid re-colliding), re-reads 
the latest (already
-   * repaired) entity, and retries instead of failing the whole load with a 
fatal error. Other IO
-   * failures (DB outage, serialization errors, etc.) are not conflicts and 
fail fast.
+   * Repairs stored table metadata and retries only when another repair wins 
the same race.
+   *
+   * <p>Two concurrent loads can both read the old metadata. The first update 
wins; the second gets
+   * {@link OptimisticLockException}. Repair is safe to run more than once, so 
the loser waits
+   * briefly, reads the winner's latest row, and tries again. Ordinary IO 
failures are not safe to
+   * retry here and are returned immediately.
    */
   private TableEntity updateTableWithCasRetry(
       NameIdentifier ident, Function<TableEntity, TableEntity> updater) throws 
IOException {
-    IOException lastConflict = null;
+    OptimisticLockException lastConflict = null;
     for (int attempt = 1; attempt <= REPAIR_UPDATE_MAX_ATTEMPTS; attempt++) {
       try {
         return store.update(ident, TableEntity.class, Entity.EntityType.TABLE, 
updater);
-      } catch (IOException e) {
-        // Only retry when the update matched 0 rows (lost optimistic-lock 
CAS). Other IO failures
-        // (DB outage, serialization errors, etc.) should fail fast.
-        String message = e.getMessage();
-        if (message == null
-            || 
!message.startsWith(TableMetaService.UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX)) {
-          throw e;
-        }
-
+      } catch (OptimisticLockException e) {
         lastConflict = e;
         LOG.debug(
             "Optimistic-lock conflict updating table {} metadata (attempt 
{}/{}), {}",
@@ -599,11 +589,13 @@ public class LanceTableOperations extends 
ManagedTableOperations {
         }
       }
     }
-    throw new IOException(
-        String.format(
-            "Failed to update table %s after %d optimistic-lock retries",
-            ident, REPAIR_UPDATE_MAX_ATTEMPTS),
-        lastConflict);
+    // Reaching here means every attempt lost to another writer. Keep the 
exception type so the
+    // caller still knows this is an OCC conflict, and add the attempt count 
for diagnosis.
+    throw new OptimisticLockException(
+        lastConflict,
+        "Failed to repair table %s after %d optimistic-lock attempts",
+        ident,
+        REPAIR_UPDATE_MAX_ATTEMPTS);
   }
 
   /**
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceConcurrentRepairStress.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceConcurrentRepairStress.java
index 4a67be530b..1921d31d3f 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceConcurrentRepairStress.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceConcurrentRepairStress.java
@@ -50,6 +50,7 @@ import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.catalog.ManagedSchemaOperations;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.rel.Table;
@@ -66,16 +67,16 @@ import org.mockito.Mockito;
 /**
  * Real multi-threaded reproduction of the repair-on-load optimistic-lock race 
behind #11891. Unlike
  * {@code 
TestLanceTableOperations#testLoadTableSurvivesConcurrentRepairVersionRace} (a
- * deterministic mock that throws one scripted {@code IOException}), this test 
drives {@link
+ * deterministic mock that throws one scripted conflict), this test drives 
{@link
  * LanceTableOperations#loadTable} from several threads at once against a 
{@link CasEntityStore}
  * that models the production relational store's compare-and-set semantics 
faithfully: every {@code
  * update} bumps a version guarded by the base version, so concurrent updates 
from the same base
  * conflict and exactly one wins per generation — just like {@code 
TableMetaService.updateTable}
  * ({@code UPDATE ... WHERE current_version = old}).
  *
- * <p>Before the CAS retry, the loser of the race got {@code 
IOException("Failed to update the
- * entity")}, rethrown as a fatal {@code RuntimeException} (HTTP 500). This 
test asserts every
- * concurrent load returns the repaired table instead.
+ * <p>Before the CAS retry, the loser of the race got an {@link 
OptimisticLockException}, rethrown
+ * as a fatal error (HTTP 500). This test asserts every concurrent load 
returns the repaired table
+ * instead.
  */
 public class TestLanceConcurrentRepairStress {
 
@@ -189,10 +190,10 @@ public class TestLanceConcurrentRepairStress {
   /**
    * In-memory {@link EntityStore} that reproduces the relational store's 
optimistic-lock CAS:
    * {@code update} reads a versioned snapshot, applies the (idempotent) 
updater, and commits only
-   * if the version has not advanced since the read — otherwise it throws 
{@code IOException("Failed
-   * to update the entity")}, exactly as {@code TableMetaService.updateTable} 
does when {@code
-   * UPDATE ... WHERE current_version = old} matches zero rows. Every commit 
bumps the version, so
-   * even a no-op update invalidates a concurrent update from the same base, 
matching production.
+   * if the version has not advanced since the read — otherwise it throws an 
{@link
+   * OptimisticLockException}, exactly as the relational table service does 
when {@code UPDATE ...
+   * WHERE current_version = old} matches zero rows. Every commit bumps the 
version, so even a no-op
+   * update invalidates a concurrent update from the same base, matching 
production.
    */
   private static final class CasEntityStore implements EntityStore {
 
@@ -230,7 +231,7 @@ public class TestLanceConcurrentRepairStress {
       if (ref.compareAndSet(base, next)) {
         return updated;
       }
-      throw new IOException("Failed to update the entity: " + ident);
+      throw new OptimisticLockException("mock conflict for %s", ident);
     }
 
     // --- unused surface ---------------------------------------------------
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
index bf99a64814..e0226c1b4d 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
@@ -48,6 +48,7 @@ import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.catalog.ManagedSchemaOperations;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.ColumnEntity;
 import org.apache.gravitino.meta.TableEntity;
@@ -174,10 +175,10 @@ public class TestLanceTableOperations {
   /**
    * Reproduces the concurrent repair-on-load race seen in {@code 
LanceSparkRESTServiceIT}. When two
    * loads repair the same table at once, the optimistic-locked {@code 
store.update} of the slower
-   * one matches zero rows and {@code TableMetaService} surfaces it as {@code 
IOException("Failed to
-   * update the entity")}. Before the CAS retry, {@code repairTableMetadata} 
rethrew it as a fatal
-   * {@code RuntimeException} (HTTP 500) instead of tolerating the concurrent 
update. This test
-   * asserts that the lost race is benign and load returns a usable table.
+   * one matches zero rows and the store surfaces an {@link 
OptimisticLockException}. Before the CAS
+   * retry, {@code repairTableMetadata} rethrew it as a fatal error (HTTP 500) 
instead of tolerating
+   * the concurrent update. This test asserts that the lost race is benign and 
load returns a usable
+   * table.
    */
   @Test
   public void testLoadTableSurvivesConcurrentRepairVersionRace() throws 
Exception {
@@ -219,10 +220,10 @@ public class TestLanceTableOperations {
     when(idGenerator.nextId()).thenReturn(10L, 11L);
 
     // First repair attempt loses the optimistic-lock CAS (a concurrent load 
already bumped the
-    // version): TableMetaService surfaces exactly this IOException. The retry 
re-reads the winner's
-    // already-repaired entity, against which the idempotent updater succeeds.
+    // version). The retry re-reads the winner's already-repaired entity, 
against which the
+    // idempotent updater succeeds.
     when(store.update(eq(ident), eq(TableEntity.class), 
eq(Entity.EntityType.TABLE), any()))
-        .thenThrow(new IOException("Failed to update the entity: " + ident))
+        .thenThrow(new OptimisticLockException("mock conflict"))
         .thenAnswer(
             invocation -> {
               @SuppressWarnings("unchecked")
@@ -255,6 +256,58 @@ public class TestLanceTableOperations {
     Assertions.assertEquals("name", loadedTable.columns()[1].name());
   }
 
+  @Test
+  public void testRepairStopsAfterBoundedOptimisticLockRetries() throws 
Exception {
+    NameIdentifier ident = 
prepareDeclaredTableForRepair("repair-conflict-exhausted");
+    when(store.update(eq(ident), eq(TableEntity.class), 
eq(Entity.EntityType.TABLE), any()))
+        .thenThrow(new OptimisticLockException("mock conflict"));
+    // Remove the real sleep so this test checks the retry bound without 
becoming timing-sensitive.
+    Mockito.doNothing().when(lanceTableOps).backoffBeforeRetry(any());
+
+    OptimisticLockException failure =
+        Assertions.assertThrows(
+            OptimisticLockException.class,
+            () ->
+                PrincipalUtils.doAs(
+                    new UserPrincipal("tester"), () -> 
lanceTableOps.loadTable(ident)));
+    Assertions.assertInstanceOf(OptimisticLockException.class, 
failure.getCause());
+    verify(store, Mockito.times(5))
+        .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE), 
any());
+  }
+
+  @Test
+  public void testRepairDoesNotRetryOrdinaryIoFailure() throws Exception {
+    NameIdentifier ident = prepareDeclaredTableForRepair("repair-io-failure");
+    when(store.update(eq(ident), eq(TableEntity.class), 
eq(Entity.EntityType.TABLE), any()))
+        .thenThrow(new IOException("database unavailable"));
+
+    RuntimeException failure =
+        Assertions.assertThrows(
+            RuntimeException.class,
+            () ->
+                PrincipalUtils.doAs(
+                    new UserPrincipal("tester"), () -> 
lanceTableOps.loadTable(ident)));
+    Assertions.assertInstanceOf(IOException.class, failure.getCause());
+    verify(store, Mockito.times(1))
+        .update(eq(ident), eq(TableEntity.class), eq(Entity.EntityType.TABLE), 
any());
+  }
+
+  @Test
+  public void testRepairBackoffPreservesThreadInterrupt() {
+    NameIdentifier ident = NameIdentifier.of("schema", "table");
+    Thread.currentThread().interrupt();
+    try {
+      IOException failure =
+          Assertions.assertThrows(IOException.class, () -> 
lanceTableOps.backoffBeforeRetry(ident));
+
+      Assertions.assertInstanceOf(InterruptedException.class, 
failure.getCause());
+      Assertions.assertTrue(Thread.currentThread().isInterrupted());
+    } finally {
+      // JUnit reuses worker threads, so do not leak this test's interrupt 
flag into another test.
+      Thread.interrupted();
+    }
+  }
+
   @Test
   public void testLoadTableWithStoredColumnsDoesNotReadLocation() throws 
Exception {
     NameIdentifier ident = NameIdentifier.of("schema", "table");
@@ -954,4 +1007,31 @@ public class TestLanceTableOperations {
             
AuditInfo.builder().withCreator("creator").withCreateTime(Instant.EPOCH).build())
         .build();
   }
+
+  private NameIdentifier prepareDeclaredTableForRepair(String directoryName) 
throws Exception {
+    NameIdentifier ident = NameIdentifier.of("schema", "table");
+    String location = tempDir.resolve(directoryName).toString();
+    TableEntity tableEntity =
+        tableEntity(
+            ident,
+            List.of(),
+            Map.of(
+                Table.PROPERTY_LOCATION,
+                location,
+                LANCE_TABLE_DECLARED,
+                "true",
+                LANCE_STORAGE_OPTIONS_PREFIX + "endpoint",
+                "http://endpoint";));
+    when(store.get(eq(ident), eq(Entity.EntityType.TABLE), 
eq(TableEntity.class)))
+        .thenReturn(tableEntity);
+
+    Dataset dataset = mock(Dataset.class);
+    when(dataset.getSchema())
+        .thenReturn(new Schema(List.of(Field.nullable("id", new 
ArrowType.Int(32, true)))));
+    when(dataset.version()).thenReturn(8L);
+    Mockito.doReturn(dataset)
+        .when(lanceTableOps)
+        .openDataset(location, Map.of("endpoint", "http://endpoint";));
+    return ident;
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java 
b/core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java
index 4fa1808fc6..adf6d64482 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java
@@ -35,6 +35,7 @@ import org.apache.gravitino.connector.HasPropertyMetadata;
 import org.apache.gravitino.connector.PropertiesMetadata;
 import org.apache.gravitino.connector.capability.Capability;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.file.FilesetChange;
 import org.apache.gravitino.messaging.TopicChange;
 import org.apache.gravitino.rel.SupportsPartitions;
@@ -219,6 +220,9 @@ public abstract class OperationDispatcher {
     R ret = null;
     try {
       ret = fn.apply(ident);
+    } catch (OptimisticLockException e) {
+      // A version conflict is actionable: the caller must retry against the 
latest entity.
+      throw e;
     } catch (NoSuchEntityException e) {
       // Case 2: The table is created by Gravitino, but has no corresponding 
entity in Gravitino.
       LOG.error(FormattedErrorMessages.ENTITY_NOT_FOUND, ident);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TableMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TableMetaMapper.java
index acb682916d..4fdc5103ae 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TableMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TableMetaMapper.java
@@ -73,6 +73,15 @@ public interface TableMetaMapper {
       @Param("schemaName") String schemaName,
       @Param("tableName") String tableName);
 
+  /**
+   * Selects and exclusively locks an active table metadata row.
+   *
+   * @param tableId the table ID
+   * @return the active table metadata, or {@code null} when it no longer 
exists
+   */
+  @SelectProvider(type = TableMetaSQLProviderFactory.class, method = 
"selectTableMetaByIdForUpdate")
+  TablePO selectTableMetaByIdForUpdate(@Param("tableId") Long tableId);
+
   @InsertProvider(type = TableMetaSQLProviderFactory.class, method = 
"insertTableMeta")
   void insertTableMeta(@Param("tableMeta") TablePO tablePO);
 
@@ -87,10 +96,18 @@ public interface TableMetaMapper {
       @Param("oldTableMeta") TablePO oldTablePO,
       @Param("newSchemaId") Long newSchemaId);
 
+  /**
+   * Soft-deletes a table only if its version has not changed since the caller 
read it.
+   *
+   * @param tableId the table ID
+   * @param currentVersion the version observed by the caller
+   * @return the number of deleted rows; zero means the table changed or 
disappeared
+   */
   @UpdateProvider(
       type = TableMetaSQLProviderFactory.class,
       method = "softDeleteTableMetasByTableId")
-  Integer softDeleteTableMetasByTableId(@Param("tableId") Long tableId);
+  Integer softDeleteTableMetasByTableId(
+      @Param("tableId") Long tableId, @Param("currentVersion") Long 
currentVersion);
 
   @UpdateProvider(
       type = TableMetaSQLProviderFactory.class,
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TableMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TableMetaSQLProviderFactory.java
index c69fd9a191..2876bbc1d1 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TableMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TableMetaSQLProviderFactory.java
@@ -89,6 +89,16 @@ public class TableMetaSQLProviderFactory {
     return getProvider().selectTableMetaById(tableId);
   }
 
+  /**
+   * Returns SQL that selects and exclusively locks an active table metadata 
row.
+   *
+   * @param tableId the table ID
+   * @return the locking select SQL
+   */
+  public static String selectTableMetaByIdForUpdate(@Param("tableId") Long 
tableId) {
+    return getProvider().selectTableMetaByIdForUpdate(tableId);
+  }
+
   public static String insertTableMeta(@Param("tableMeta") TablePO tablePO) {
     return getProvider().insertTableMeta(tablePO);
   }
@@ -104,8 +114,16 @@ public class TableMetaSQLProviderFactory {
     return getProvider().updateTableMeta(newTablePO, oldTablePO, newSchemaId);
   }
 
-  public static String softDeleteTableMetasByTableId(@Param("tableId") Long 
tableId) {
-    return getProvider().softDeleteTableMetasByTableId(tableId);
+  /**
+   * Returns SQL that soft-deletes a table with a version check.
+   *
+   * @param tableId the table ID
+   * @param currentVersion the version observed by the caller
+   * @return the version-checked delete SQL
+   */
+  public static String softDeleteTableMetasByTableId(
+      @Param("tableId") Long tableId, @Param("currentVersion") Long 
currentVersion) {
+    return getProvider().softDeleteTableMetasByTableId(tableId, 
currentVersion);
   }
 
   public static String softDeleteTableMetasByMetalakeId(@Param("metalakeId") 
Long metalakeId) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TableMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TableMetaBaseSQLProvider.java
index 06684724fa..71f63cb2b6 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TableMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TableMetaBaseSQLProvider.java
@@ -178,6 +178,23 @@ public class TableMetaBaseSQLProvider {
         + " WHERE tm.table_id = #{tableId} AND tm.deleted_at = 0";
   }
 
+  /**
+   * Returns the active table metadata row and holds it exclusively for the 
current transaction.
+   *
+   * @param tableId the table ID
+   * @return the locking select SQL
+   */
+  public String selectTableMetaByIdForUpdate(@Param("tableId") Long tableId) {
+    return "SELECT table_id as tableId, table_name as tableName,"
+        + " metalake_id as metalakeId, catalog_id as catalogId,"
+        + " schema_id as schemaId, audit_info as auditInfo,"
+        + " current_version as currentVersion, last_version as lastVersion,"
+        + " deleted_at as deletedAt"
+        + " FROM "
+        + TABLE_NAME
+        + " WHERE table_id = #{tableId} AND deleted_at = 0 FOR UPDATE";
+  }
+
   public String insertTableMeta(@Param("tableMeta") TablePO tablePO) {
     return "INSERT INTO "
         + TABLE_NAME
@@ -225,6 +242,18 @@ public class TableMetaBaseSQLProvider {
         + " deleted_at = #{tableMeta.deletedAt}";
   }
 
+  /**
+   * Returns SQL that updates a table only while its OCC version is unchanged.
+   *
+   * <p>The WHERE clause intentionally compares only the stable ID, the 
version seen by the caller,
+   * and the active flag. Comparing serialized payload or audit fields would 
make harmless encoding
+   * differences look like conflicts; the version is the single source of 
truth for concurrency.
+   *
+   * @param newTablePO the table values to write
+   * @param oldTablePO the table values and OCC version read by the caller
+   * @param newSchemaId the target schema ID
+   * @return the version-checked update SQL
+   */
   public String updateTableMeta(
       @Param("newTableMeta") TablePO newTablePO,
       @Param("oldTableMeta") TablePO oldTablePO,
@@ -240,22 +269,27 @@ public class TableMetaBaseSQLProvider {
         + " last_version = #{newTableMeta.lastVersion},"
         + " deleted_at = #{newTableMeta.deletedAt}"
         + " WHERE table_id = #{oldTableMeta.tableId}"
-        + " AND table_name = #{oldTableMeta.tableName}"
-        + " AND metalake_id = #{oldTableMeta.metalakeId}"
-        + " AND catalog_id = #{oldTableMeta.catalogId}"
-        + " AND schema_id = #{oldTableMeta.schemaId}"
-        + " AND audit_info = #{oldTableMeta.auditInfo}"
         + " AND current_version = #{oldTableMeta.currentVersion}"
-        + " AND last_version = #{oldTableMeta.lastVersion}"
         + " AND deleted_at = 0";
   }
 
-  public String softDeleteTableMetasByTableId(@Param("tableId") Long tableId) {
+  /**
+   * Returns SQL that deletes only the table version observed by the caller.
+   *
+   * <p>For example, a drop that read version 3 must not delete version 4 
after a concurrent alter.
+   *
+   * @param tableId the table ID
+   * @param currentVersion the version observed by the caller
+   * @return the version-checked delete SQL
+   */
+  public String softDeleteTableMetasByTableId(
+      @Param("tableId") Long tableId, @Param("currentVersion") Long 
currentVersion) {
     return "UPDATE "
         + TABLE_NAME
         + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
         + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
-        + " WHERE table_id = #{tableId} AND deleted_at = 0";
+        + " WHERE table_id = #{tableId}"
+        + " AND current_version = #{currentVersion} AND deleted_at = 0";
   }
 
   public String softDeleteTableMetasByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TableMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TableMetaPostgreSQLProvider.java
index 7add18edac..13b16579f9 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TableMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TableMetaPostgreSQLProvider.java
@@ -56,11 +56,12 @@ public class TableMetaPostgreSQLProvider extends 
TableMetaBaseSQLProvider {
   }
 
   @Override
-  public String softDeleteTableMetasByTableId(Long tableId) {
+  public String softDeleteTableMetasByTableId(Long tableId, Long 
currentVersion) {
     return "UPDATE "
         + TABLE_NAME
         + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
-        + " WHERE table_id = #{tableId} AND deleted_at = 0";
+        + " WHERE table_id = #{tableId}"
+        + " AND current_version = #{currentVersion} AND deleted_at = 0";
   }
 
   @Override
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
index 741a210e10..a2e0fdf25e 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
@@ -25,8 +25,6 @@ import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
-import java.util.concurrent.atomic.AtomicInteger;
-import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.HasIdentifier;
@@ -55,15 +53,6 @@ import org.apache.gravitino.utils.NamespaceUtil;
 /** The service class for table metadata. It provides the basic database 
operations for table. */
 public class TableMetaService {
 
-  /**
-   * Message prefix of the {@link java.io.IOException} thrown by {@link 
#updateTable} when the
-   * optimistic-lock CAS matches zero rows (the stored version advanced under 
a concurrent update).
-   * Exposed so callers that retry the lost race (e.g. the Lance 
repair-on-load path) can recognize
-   * the conflict without re-declaring the literal.
-   */
-  public static final String UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX =
-      "Failed to update the entity: ";
-
   private static final TableMetaService INSTANCE = new TableMetaService();
   private BasePOStorageOps<TablePO, TableMetaMapper> ops;
 
@@ -123,8 +112,9 @@ public class TableMetaService {
       TablePO.Builder builder = TablePO.builder();
       fillTablePOBuilderParentEntityId(builder, tableEntity.namespace());
 
-      AtomicReference<TablePO> tablePORef = new AtomicReference<>();
       TablePO po = POConverters.initializeTablePOWithVersion(tableEntity, 
builder);
+      // The schema lock, table row, version row, and columns share one 
transaction. If any later
+      // step fails, the earlier inserts are rolled back as well.
       SessionUtils.doMultipleWithCommit(
           // Hold the parent schema row until this transaction ends, so the 
table cannot be
           // written below a schema that is being dropped.
@@ -137,11 +127,7 @@ public class TableMetaService {
                       po.getMetalakeId()),
           () ->
               SessionUtils.doWithoutCommit(
-                  TableMetaMapper.class,
-                  mapper -> {
-                    tablePORef.set(po);
-                    ops.insertPO(mapper, po, overwrite);
-                  }),
+                  TableMetaMapper.class, mapper -> ops.insertPO(mapper, po, 
overwrite)),
           () ->
               SessionUtils.doWithoutCommit(
                   TableVersionMapper.class,
@@ -155,14 +141,12 @@ public class TableMetaService {
           () -> {
             // We need to delete the columns first if we want to overwrite the 
table.
             if (overwrite) {
-              TableColumnMetaService.getInstance()
-                  .deleteColumnsByTableId(tablePORef.get().getTableId());
+              
TableColumnMetaService.getInstance().deleteColumnsByTableId(po.getTableId());
             }
           },
           () -> {
             if (tableEntity.columns() != null && 
!tableEntity.columns().isEmpty()) {
-              TableColumnMetaService.getInstance()
-                  .insertColumnPOs(tablePORef.get(), tableEntity.columns());
+              TableColumnMetaService.getInstance().insertColumnPOs(po, 
tableEntity.columns());
             }
           });
 
@@ -200,12 +184,12 @@ public class TableMetaService {
     TablePO newTablePO =
         POConverters.updateTablePOWithVersionAndSchemaId(oldTablePO, 
newTableEntity, newSchemaId);
 
-    final AtomicInteger updateResult = new AtomicInteger(0);
     try {
       SessionUtils.doMultipleWithCommit(
           () -> {
-            // Only a rename that moves the table to another schema needs a 
lock here, and it is the
-            // new parent that has to stay alive, not the old one.
+            // Only an update that moves the table to another schema needs a 
lock here. The new
+            // parent must stay alive until the move commits; locking the old 
parent would not
+            // protect the table's new location.
             if (isSchemaChanged) {
               SchemaMetaService.getInstance()
                   .lockSchemaForEntityWrite(
@@ -215,24 +199,35 @@ public class TableMetaService {
                       oldTablePO.getMetalakeId());
             }
           },
-          () ->
-              updateResult.set(
-                  SessionUtils.getWithoutCommit(
-                      TableMetaMapper.class,
-                      mapper -> ops.updatePO(mapper, newTablePO, oldTablePO))),
-          () ->
-              SessionUtils.doWithoutCommit(
-                  TableVersionMapper.class,
-                  mapper -> {
-                    mapper.softDeleteTableVersionByTableIdAndVersion(
-                        oldTablePO.getTableId(), 
oldTablePO.getCurrentVersion());
-                    mapper.insertTableVersionOnDuplicateKeyUpdate(newTablePO);
-                  }),
           () -> {
-            if (updateResult.get() > 0) {
-              TableColumnMetaService.getInstance()
-                  .updateColumnPOsFromTableDiff(oldTableEntity, 
newTableEntity, newTablePO);
+            // This update is the decision point for the whole transaction. 
current_version is the
+            // table's OCC token: if another writer changed the table after we 
read it, that writer
+            // has already increased the token and this UPDATE changes zero 
rows. Throwing here
+            // rolls back the transaction before it can touch the version 
history or columns.
+            int updated =
+                SessionUtils.getWithoutCommit(
+                    TableMetaMapper.class, mapper -> ops.updatePO(mapper, 
newTablePO, oldTablePO));
+            if (updated == 0) {
+              throw tableWriteFailure(identifier, oldTablePO);
             }
+          },
+          () -> {
+            // The table details live in table_version_info, while table_meta 
points to the current
+            // version. These two rows must move together. This step runs only 
after the table_meta
+            // CAS above succeeds, so a losing writer cannot overwrite the 
winner's version row.
+            SessionUtils.doWithoutCommit(
+                TableVersionMapper.class,
+                mapper -> {
+                  mapper.softDeleteTableVersionByTableIdAndVersion(
+                      oldTablePO.getTableId(), oldTablePO.getCurrentVersion());
+                  mapper.insertTableVersionOnDuplicateKeyUpdate(newTablePO);
+                });
+          },
+          () -> {
+            // Column changes use the same new table version. Keeping this in 
the same transaction
+            // means a column failure also rolls back table_meta and 
table_version_info.
+            TableColumnMetaService.getInstance()
+                .updateColumnPOsFromTableDiff(oldTableEntity, newTableEntity, 
newTablePO);
           });
 
     } catch (RuntimeException re) {
@@ -241,61 +236,42 @@ public class TableMetaService {
       throw re;
     }
 
-    if (updateResult.get() > 0) {
-      return newTableEntity;
-    } else {
-      throw new IOException(UPDATE_ENTITY_CONFLICT_MESSAGE_PREFIX + 
identifier);
-    }
+    return newTableEntity;
   }
 
   @Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME, 
baseMetricName = "deleteTable")
   public boolean deleteTable(NameIdentifier identifier) {
     TablePO tablePO = getTablePOByIdentifier(identifier);
 
-    AtomicInteger deleteResult = new AtomicInteger(0);
     SessionUtils.doMultipleWithCommit(
-        () ->
-            deleteResult.set(
-                SessionUtils.getWithoutCommit(
-                    TableMetaMapper.class,
-                    mapper -> 
mapper.softDeleteTableMetasByTableId(tablePO.getTableId()))),
         () -> {
-          if (deleteResult.get() > 0) {
-            SessionUtils.doWithoutCommit(
-                OwnerMetaMapper.class,
-                mapper ->
-                    mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
-                        tablePO.getTableId(), 
MetadataObject.Type.TABLE.name()));
-            
TableColumnMetaService.getInstance().deleteColumnsByTableId(tablePO.getTableId());
-            SessionUtils.doWithoutCommit(
-                SecurableObjectMapper.class,
-                mapper ->
-                    mapper.softDeleteObjectRelsByMetadataObject(
-                        tablePO.getTableId(), 
MetadataObject.Type.TABLE.name()));
-            SessionUtils.doWithoutCommit(
-                TagMetadataObjectRelMapper.class,
-                mapper ->
-                    mapper.softDeleteTagMetadataObjectRelsByMetadataObject(
-                        tablePO.getTableId(), 
MetadataObject.Type.TABLE.name()));
-            SessionUtils.doWithoutCommit(
-                TagMetadataObjectRelMapper.class,
-                mapper -> 
mapper.softDeleteTagMetadataObjectRelsByTableId(tablePO.getTableId()));
+          // Delete the table row first and only if it still has the version 
we read. A stale drop
+          // stops here, before it can remove columns, tags, policies, or any 
other related data.
+          deleteTableWithVersion(identifier, tablePO);
+        },
+        () -> deleteTableDependents(tablePO));
 
-            SessionUtils.doWithoutCommit(
-                StatisticMetaMapper.class,
-                mapper -> 
mapper.softDeleteStatisticsByEntityId(tablePO.getTableId()));
-            SessionUtils.doWithoutCommit(
-                PolicyMetadataObjectRelMapper.class,
-                mapper -> 
mapper.softDeletePolicyMetadataObjectRelsByTableId(tablePO.getTableId()));
-            SessionUtils.doWithoutCommit(
-                TableVersionMapper.class,
-                mapper ->
-                    mapper.softDeleteTableVersionByTableIdAndVersion(
-                        tablePO.getTableId(), tablePO.getCurrentVersion()));
-          }
-        });
+    return true;
+  }
 
-    return deleteResult.get() > 0;
+  /**
+   * Deletes the table root row only when it still has the version observed by 
the caller.
+   *
+   * <p>This method deliberately does not start or commit a transaction. Its 
caller must include it
+   * in the same transaction as dependent-row cleanup, so a later cleanup 
failure restores the root
+   * row too. Package-private access also lets concurrency tests submit a 
deliberately stale
+   * snapshot without copying the production CAS logic.
+   */
+  void deleteTableWithVersion(NameIdentifier identifier, TablePO 
observedTablePO) {
+    int deleted =
+        SessionUtils.getWithoutCommit(
+            TableMetaMapper.class,
+            mapper ->
+                mapper.softDeleteTableMetasByTableId(
+                    observedTablePO.getTableId(), 
observedTablePO.getCurrentVersion()));
+    if (deleted == 0) {
+      throw tableWriteFailure(identifier, observedTablePO);
+    }
   }
 
   @Monitored(
@@ -374,4 +350,66 @@ public class TableMetaService {
     builder.withCatalogId(namespacedEntityId.namespaceIds()[1]);
     builder.withSchemaId(namespacedEntityId.entityId());
   }
+
+  private void deleteTableDependents(TablePO tablePO) {
+    // The table row has already passed its version check. All cleanup below 
uses the same database
+    // transaction, so either the table and every related row are deleted 
together, or none are.
+    SessionUtils.doWithoutCommit(
+        OwnerMetaMapper.class,
+        mapper ->
+            mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
+                tablePO.getTableId(), MetadataObject.Type.TABLE.name()));
+    
TableColumnMetaService.getInstance().deleteColumnsByTableId(tablePO.getTableId());
+    SessionUtils.doWithoutCommit(
+        SecurableObjectMapper.class,
+        mapper ->
+            mapper.softDeleteObjectRelsByMetadataObject(
+                tablePO.getTableId(), MetadataObject.Type.TABLE.name()));
+    SessionUtils.doWithoutCommit(
+        TagMetadataObjectRelMapper.class,
+        mapper ->
+            mapper.softDeleteTagMetadataObjectRelsByMetadataObject(
+                tablePO.getTableId(), MetadataObject.Type.TABLE.name()));
+    SessionUtils.doWithoutCommit(
+        TagMetadataObjectRelMapper.class,
+        mapper -> 
mapper.softDeleteTagMetadataObjectRelsByTableId(tablePO.getTableId()));
+    SessionUtils.doWithoutCommit(
+        StatisticMetaMapper.class,
+        mapper -> mapper.softDeleteStatisticsByEntityId(tablePO.getTableId()));
+    SessionUtils.doWithoutCommit(
+        PolicyMetadataObjectRelMapper.class,
+        mapper -> 
mapper.softDeletePolicyMetadataObjectRelsByTableId(tablePO.getTableId()));
+    SessionUtils.doWithoutCommit(
+        TableVersionMapper.class,
+        mapper ->
+            mapper.softDeleteTableVersionByTableIdAndVersion(
+                tablePO.getTableId(), tablePO.getCurrentVersion()));
+  }
+
+  private RuntimeException tableWriteFailure(NameIdentifier identifier, 
TablePO observedTablePO) {
+    // A zero-row CAS has two different meanings:
+    // 1. The same table is still here, but another writer changed its 
version. The caller should
+    //    retry, so return OptimisticLockException.
+    // 2. The table ID was deleted, renamed, or moved away from the requested 
name. From the
+    //    caller's point of view the requested table no longer exists, so 
return NoSuchEntity.
+    //
+    // Read by the stable table ID and lock the row. The lock waits for an 
in-flight writer to
+    // finish, which lets us classify the failure using committed data instead 
of guessing while
+    // the other transaction is still running.
+    TablePO currentTablePO =
+        SessionUtils.getWithoutCommit(
+            TableMetaMapper.class,
+            mapper -> 
mapper.selectTableMetaByIdForUpdate(observedTablePO.getTableId()));
+    if (currentTablePO == null
+        || !Objects.equals(currentTablePO.getTableName(), 
observedTablePO.getTableName())
+        || !Objects.equals(currentTablePO.getSchemaId(), 
observedTablePO.getSchemaId())
+        || !Objects.equals(currentTablePO.getCatalogId(), 
observedTablePO.getCatalogId())
+        || !Objects.equals(currentTablePO.getMetalakeId(), 
observedTablePO.getMetalakeId())) {
+      return new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          Entity.EntityType.TABLE.name().toLowerCase(),
+          identifier.name());
+    }
+    return ExceptionUtils.concurrentModification(Entity.EntityType.TABLE, 
identifier);
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
index a834398c71..0c2eb8c091 100644
--- 
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
@@ -58,6 +58,7 @@ import org.apache.gravitino.TestColumn;
 import org.apache.gravitino.auth.AuthConstants;
 import org.apache.gravitino.connector.TestCatalogOperations;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.ColumnEntity;
@@ -488,7 +489,16 @@ public class TestTableOperationDispatcher extends 
TestOperationDispatcher {
     Assertions.assertEquals("test", alteredTable3.auditInfo().creator());
     Assertions.assertEquals("test", alteredTable3.auditInfo().lastModifier());
 
-    // Case 4: Test if the table entity is not matched
+    // Case 4: Optimistic-lock conflicts must reach the caller so it can retry.
+    reset(entityStore);
+    doThrow(new OptimisticLockException("mock conflict"))
+        .when(entityStore)
+        .update(any(), any(), any(), any());
+    Assertions.assertThrows(
+        OptimisticLockException.class,
+        () -> tableOperationDispatcher.alterTable(tableIdent, changes));
+
+    // Case 5: Test if the table entity is not matched
     reset(entityStore);
     TableEntity unmatchedEntity =
         TableEntity.builder()
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestTableMetaBaseSQLProvider.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestTableMetaBaseSQLProvider.java
new file mode 100644
index 0000000000..e3789d6be4
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestTableMetaBaseSQLProvider.java
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper.provider.base;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestTableMetaBaseSQLProvider {
+
+  private static final TableMetaBaseSQLProvider PROVIDER = new 
TableMetaBaseSQLProvider();
+
+  @Test
+  void testUpdateUsesOnlyIdVersionAndActiveStateForCas() {
+    String sql = PROVIDER.updateTableMeta(null, null, null);
+    String whereClause = sql.substring(sql.indexOf(" WHERE"));
+
+    Assertions.assertEquals(
+        " WHERE table_id = #{oldTableMeta.tableId}"
+            + " AND current_version = #{oldTableMeta.currentVersion}"
+            + " AND deleted_at = 0",
+        whereClause);
+  }
+
+  @Test
+  void testDirectDeleteUsesVersionCas() {
+    String sql = PROVIDER.softDeleteTableMetasByTableId(null, null);
+
+    Assertions.assertTrue(sql.contains("AND current_version = 
#{currentVersion}"));
+    Assertions.assertTrue(sql.endsWith("AND deleted_at = 0"));
+  }
+
+  @Test
+  void testConflictReadLocksActiveRow() {
+    String sql = PROVIDER.selectTableMetaByIdForUpdate(null);
+
+    Assertions.assertTrue(sql.contains("WHERE table_id = #{tableId} AND 
deleted_at = 0"));
+    Assertions.assertTrue(sql.endsWith("FOR UPDATE"));
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestTableMetaPostgreSQLProvider.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestTableMetaPostgreSQLProvider.java
new file mode 100644
index 0000000000..cad40453aa
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestTableMetaPostgreSQLProvider.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper.provider.postgresql;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestTableMetaPostgreSQLProvider {
+
+  @Test
+  void testDirectDeleteUsesVersionCas() {
+    String sql = new 
TableMetaPostgreSQLProvider().softDeleteTableMetasByTableId(null, null);
+
+    Assertions.assertTrue(sql.contains("AND current_version = 
#{currentVersion}"));
+    Assertions.assertTrue(sql.endsWith("AND deleted_at = 0"));
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableMetaService.java
index a795168a5d..623a45029b 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableMetaService.java
@@ -27,6 +27,12 @@ import java.io.IOException;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+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;
@@ -34,6 +40,7 @@ import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.meta.BaseMetalake;
 import org.apache.gravitino.meta.CatalogEntity;
 import org.apache.gravitino.meta.ColumnEntity;
@@ -56,6 +63,10 @@ import org.apache.gravitino.rel.types.Types;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
 import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
+import org.apache.gravitino.storage.relational.mapper.SchemaMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.TableMetaMapper;
+import org.apache.gravitino.storage.relational.po.SchemaPO;
+import org.apache.gravitino.storage.relational.po.TablePO;
 import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
 import org.apache.gravitino.storage.relational.po.cache.OperateType;
 import org.apache.gravitino.storage.relational.utils.SessionUtils;
@@ -101,6 +112,204 @@ public class TestTableMetaService extends TestJDBCBackend 
{
     assertThrows(EntityAlreadyExistsException.class, () -> 
backend.insert(tableCopy, false));
   }
 
+  @TestTemplate
+  public void testInsertWaitsForConcurrentSchemaDelete() throws Exception {
+    createAndInsertMakeLake(metalakeName);
+    createAndInsertCatalog(metalakeName, catalogName);
+    SchemaEntity schema = createAndInsertSchema(metalakeName, catalogName, 
schemaName);
+    SchemaPO observedSchemaPO =
+        SessionUtils.getWithoutCommit(
+            SchemaMetaMapper.class, mapper -> 
mapper.selectSchemaMetaById(schema.id()));
+    TableEntity table =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofTable(metalakeName, catalogName, schemaName),
+            "table_racing_schema_delete",
+            AUDIT_INFO);
+
+    CountDownLatch schemaDeleteLocked = new CountDownLatch(1);
+    CountDownLatch allowDeleteCommit = new CountDownLatch(1);
+    CountDownLatch tableInsertStarted = new CountDownLatch(1);
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    Future<Throwable> deleteResult =
+        executor.submit(
+            () -> {
+              try {
+                SessionUtils.doMultipleWithCommit(
+                    () -> {
+                      int deleted =
+                          SessionUtils.getWithoutCommit(
+                              SchemaMetaMapper.class,
+                              mapper ->
+                                  
mapper.softDeleteSchemaMetaBySchemaIdAndVersion(
+                                      observedSchemaPO.getSchemaId(),
+                                      observedSchemaPO.getCurrentVersion()));
+                      Assertions.assertEquals(1, deleted);
+                      schemaDeleteLocked.countDown();
+                      try {
+                        assertTrue(allowDeleteCommit.await(30, 
TimeUnit.SECONDS));
+                      } catch (InterruptedException e) {
+                        Thread.currentThread().interrupt();
+                        throw new RuntimeException(e);
+                      }
+                    });
+                return null;
+              } catch (Throwable throwable) {
+                return throwable;
+              }
+            });
+    try {
+      assertTrue(schemaDeleteLocked.await(30, TimeUnit.SECONDS));
+      Future<Throwable> insertResult =
+          executor.submit(
+              () -> {
+                tableInsertStarted.countDown();
+                try {
+                  TableMetaService.getInstance().insertTable(table, false);
+                  return null;
+                } catch (Throwable throwable) {
+                  return throwable;
+                }
+              });
+      assertTrue(tableInsertStarted.await(30, TimeUnit.SECONDS));
+      assertThrows(TimeoutException.class, () -> insertResult.get(500, 
TimeUnit.MILLISECONDS));
+
+      allowDeleteCommit.countDown();
+      Assertions.assertNull(deleteResult.get(30, TimeUnit.SECONDS));
+      Assertions.assertInstanceOf(
+          NoSuchEntityException.class, insertResult.get(30, TimeUnit.SECONDS));
+      Assertions.assertTrue(
+          SessionUtils.getWithoutCommit(
+                  TableMetaMapper.class,
+                  mapper -> mapper.listTablePOsByTableIds(List.of(table.id())))
+              .isEmpty());
+    } finally {
+      allowDeleteCommit.countDown();
+      executor.shutdownNow();
+    }
+  }
+
+  @TestTemplate
+  public void testInsertRollsBackAllRowsWhenColumnWriteFails() throws 
IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    Namespace tableNamespace = NamespaceUtil.ofTable(metalakeName, 
catalogName, schemaName);
+    ColumnEntity column =
+        ColumnEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("column")
+            .withPosition(0)
+            .withDataType(Types.IntegerType.get())
+            .withNullable(true)
+            .withAutoIncrement(false)
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableEntity invalidTable =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_insert_rollback")
+            .withNamespace(tableNamespace)
+            // The duplicate ID violates the column-version unique key. The 
failure happens after
+            // table_meta and table_version_info have already been written in 
this transaction.
+            .withColumns(List.of(column, column))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+
+    assertThrows(
+        RuntimeException.class,
+        () -> TableMetaService.getInstance().insertTable(invalidTable, false));
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
TableMetaService.getInstance().getTableByIdentifier(invalidTable.nameIdentifier()));
+
+    // Reusing the same table ID, name, and column proves that the failed 
attempt left no metadata,
+    // version, or column row behind.
+    TableEntity validTable =
+        TableEntity.builder()
+            .withId(invalidTable.id())
+            .withName(invalidTable.name())
+            .withNamespace(tableNamespace)
+            .withColumns(List.of(column))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(validTable, false);
+    TableEntity inserted =
+        
TableMetaService.getInstance().getTableByIdentifier(validTable.nameIdentifier());
+    Assertions.assertEquals(validTable.id(), inserted.id());
+    Assertions.assertEquals(1, inserted.columns().size());
+  }
+
+  @TestTemplate
+  public void testOverwriteRollsBackExistingTableAndThenSucceeds() throws 
IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    Namespace tableNamespace = NamespaceUtil.ofTable(metalakeName, 
catalogName, schemaName);
+    ColumnEntity originalColumn =
+        ColumnEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("original_column")
+            .withPosition(0)
+            .withDataType(Types.IntegerType.get())
+            .withNullable(true)
+            .withAutoIncrement(false)
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableEntity original =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_overwrite_rollback")
+            .withNamespace(tableNamespace)
+            .withColumns(List.of(originalColumn))
+            .withComment("original")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(original, false);
+
+    ColumnEntity replacementColumn =
+        ColumnEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("replacement_column")
+            .withPosition(0)
+            .withDataType(Types.StringType.get())
+            .withNullable(true)
+            .withAutoIncrement(false)
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableEntity invalidReplacement =
+        TableEntity.builder()
+            .withId(original.id())
+            .withName(original.name())
+            .withNamespace(tableNamespace)
+            // Overwrite deletes the old columns before inserting these. 
Repeating the same ID
+            // makes the final step fail, which must restore both the table 
and its old column.
+            .withColumns(List.of(replacementColumn, replacementColumn))
+            .withComment("must roll back")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    assertThrows(
+        RuntimeException.class,
+        () -> TableMetaService.getInstance().insertTable(invalidReplacement, 
true));
+
+    TableEntity afterFailure =
+        
TableMetaService.getInstance().getTableByIdentifier(original.nameIdentifier());
+    Assertions.assertEquals("original", afterFailure.comment());
+    Assertions.assertEquals(List.of(originalColumn), afterFailure.columns());
+
+    TableEntity validReplacement =
+        TableEntity.builder()
+            .withId(original.id())
+            .withName(original.name())
+            .withNamespace(tableNamespace)
+            .withColumns(List.of(replacementColumn))
+            .withComment("replaced")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(validReplacement, true);
+
+    TableEntity afterSuccess =
+        
TableMetaService.getInstance().getTableByIdentifier(original.nameIdentifier());
+    Assertions.assertEquals("replaced", afterSuccess.comment());
+    Assertions.assertEquals(List.of(replacementColumn), 
afterSuccess.columns());
+  }
+
   @TestTemplate
   public void testUpdateAlreadyExistsException() throws IOException {
     createAndInsertMakeLake(metalakeName);
@@ -318,6 +527,273 @@ public class TestTableMetaService extends TestJDBCBackend 
{
                         && record.getOperateType() == OperateType.DROP));
   }
 
+  @TestTemplate
+  public void testAlterReportsOptimisticLockConflictAndKeepsWinnerVersion() 
throws IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    TableEntity table =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofTable(metalakeName, catalogName, schemaName),
+            "table_alter_conflict",
+            AUDIT_INFO);
+    backend.insert(table, false);
+    TablePO initialPO = getTablePO(table.id());
+
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            TableMetaService.getInstance()
+                .updateTable(
+                    table.nameIdentifier(),
+                    entity -> {
+                      TableEntity current = (TableEntity) entity;
+                      // The updater runs before the outer CAS. Commit another 
update here so the
+                      // outer write continues with a stale current_version 
and must lose the CAS.
+                      updateTableUnchecked(
+                          table.nameIdentifier(),
+                          competing -> copyTableWithComment(competing, 
"competing update"));
+                      return copyTableWithComment(current, "requested update");
+                    }));
+
+    TableEntity current =
+        
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier());
+    Assertions.assertEquals("competing update", current.comment());
+    TablePO currentPO = getTablePO(table.id());
+    Assertions.assertEquals(
+        initialPO.getCurrentVersion() + 1, 
currentPO.getCurrentVersion().longValue());
+    Assertions.assertEquals(currentPO.getCurrentVersion(), 
currentPO.getLastVersion());
+  }
+
+  @TestTemplate
+  public void testAlterReportsNoSuchWhenDeletedConcurrently() throws 
IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    TableEntity table =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofTable(metalakeName, catalogName, schemaName),
+            "table_alter_deleted",
+            AUDIT_INFO);
+    backend.insert(table, false);
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            TableMetaService.getInstance()
+                .updateTable(
+                    table.nameIdentifier(),
+                    entity -> {
+                      
TableMetaService.getInstance().deleteTable(table.nameIdentifier());
+                      return copyTableWithComment((TableEntity) entity, 
"requested update");
+                    }));
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier()));
+  }
+
+  @TestTemplate
+  public void testAlterReportsNoSuchWhenRenamedConcurrently() throws 
IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    TableEntity table =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofTable(metalakeName, catalogName, schemaName),
+            "table_alter_renamed",
+            AUDIT_INFO);
+    backend.insert(table, false);
+    NameIdentifier renamedIdentifier =
+        NameIdentifier.of(table.namespace(), "table_alter_renamed_winner");
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            TableMetaService.getInstance()
+                .updateTable(
+                    table.nameIdentifier(),
+                    entity -> {
+                      TableEntity current = (TableEntity) entity;
+                      updateTableUnchecked(
+                          table.nameIdentifier(),
+                          competing ->
+                              copyTable(
+                                  competing,
+                                  renamedIdentifier.name(),
+                                  competing.namespace(),
+                                  "renamed winner"));
+                      return copyTableWithComment(current, "stale update");
+                    }));
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier()));
+    Assertions.assertEquals(
+        "renamed winner",
+        
TableMetaService.getInstance().getTableByIdentifier(renamedIdentifier).comment());
+  }
+
+  @TestTemplate
+  public void testAlterReportsNoSuchWhenMovedConcurrently() throws IOException 
{
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    String newSchemaName = "schema_for_concurrent_move";
+    createAndInsertSchema(metalakeName, catalogName, newSchemaName);
+    TableEntity table =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofTable(metalakeName, catalogName, schemaName),
+            "table_alter_moved",
+            AUDIT_INFO);
+    backend.insert(table, false);
+    Namespace movedNamespace = NamespaceUtil.ofTable(metalakeName, 
catalogName, newSchemaName);
+    NameIdentifier movedIdentifier = NameIdentifier.of(movedNamespace, 
table.name());
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            TableMetaService.getInstance()
+                .updateTable(
+                    table.nameIdentifier(),
+                    entity -> {
+                      TableEntity current = (TableEntity) entity;
+                      updateTableUnchecked(
+                          table.nameIdentifier(),
+                          competing ->
+                              copyTable(
+                                  competing, competing.name(), movedNamespace, 
"moved winner"));
+                      return copyTableWithComment(current, "stale update");
+                    }));
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier()));
+    Assertions.assertEquals(
+        "moved winner",
+        
TableMetaService.getInstance().getTableByIdentifier(movedIdentifier).comment());
+  }
+
+  @TestTemplate
+  public void testDeleteRejectsStaleVersion() throws IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    ColumnEntity column =
+        ColumnEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("column_that_must_survive")
+            .withPosition(0)
+            .withDataType(Types.IntegerType.get())
+            .withNullable(true)
+            .withAutoIncrement(false)
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableEntity table =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_stale_delete")
+            .withNamespace(NamespaceUtil.ofTable(metalakeName, catalogName, 
schemaName))
+            .withColumns(List.of(column))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    backend.insert(table, false);
+    TablePO stalePO = getTablePO(table.id());
+
+    TableMetaService.getInstance()
+        .updateTable(
+            table.nameIdentifier(),
+            entity -> copyTableWithComment((TableEntity) entity, "winning 
update"));
+
+    // The stale drop still carries the original version. It must not delete 
the newer table.
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            SessionUtils.doMultipleWithCommit(
+                () ->
+                    TableMetaService.getInstance()
+                        .deleteTableWithVersion(table.nameIdentifier(), 
stalePO)));
+    TableEntity current =
+        
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier());
+    Assertions.assertEquals("winning update", current.comment());
+    Assertions.assertEquals(1, current.columns().size());
+    Assertions.assertEquals(column.id(), current.columns().get(0).id());
+  }
+
+  @TestTemplate
+  public void testDeleteReportsNoSuchWhenDeletedConcurrently() throws 
IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    TableEntity table =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofTable(metalakeName, catalogName, schemaName),
+            "table_delete_deleted",
+            AUDIT_INFO);
+    backend.insert(table, false);
+    TablePO stalePO = getTablePO(table.id());
+
+    TableMetaService.getInstance().deleteTable(table.nameIdentifier());
+
+    // The second delete still has the first delete's snapshot. Since the 
table is now gone rather
+    // than merely newer, the result must be "not found", not an OCC conflict.
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            SessionUtils.doMultipleWithCommit(
+                () ->
+                    TableMetaService.getInstance()
+                        .deleteTableWithVersion(table.nameIdentifier(), 
stalePO)));
+  }
+
+  @TestTemplate
+  public void testUpdateRollsBackMetadataAndVersionWhenColumnWriteFails() 
throws IOException {
+    createParentEntities(metalakeName, catalogName, schemaName, AUDIT_INFO);
+    TableEntity table =
+        createTableEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofTable(metalakeName, catalogName, schemaName),
+            "table_update_rollback",
+            AUDIT_INFO);
+    backend.insert(table, false);
+    TablePO initialPO = getTablePO(table.id());
+
+    ColumnEntity duplicateColumn =
+        ColumnEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("duplicate_id")
+            .withPosition(0)
+            .withDataType(Types.IntegerType.get())
+            .withNullable(true)
+            .withAutoIncrement(false)
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    // The duplicate column ID fails after the table and version rows are 
updated. The assertions
+    // below verify that the outer transaction rolls both earlier writes back.
+    assertThrows(
+        IllegalStateException.class,
+        () ->
+            TableMetaService.getInstance()
+                .updateTable(
+                    table.nameIdentifier(),
+                    entity -> {
+                      TableEntity current = (TableEntity) entity;
+                      return TableEntity.builder()
+                          .withId(current.id())
+                          .withName(current.name())
+                          .withNamespace(current.namespace())
+                          .withColumns(List.of(duplicateColumn, 
duplicateColumn))
+                          .withProperties(current.properties())
+                          .withPartitioning(current.partitioning())
+                          .withSortOrders(current.sortOrders())
+                          .withDistribution(current.distribution())
+                          .withIndexes(current.indexes())
+                          .withComment("must roll back")
+                          .withAuditInfo(current.auditInfo())
+                          .build();
+                    }));
+
+    TableEntity current =
+        
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier());
+    Assertions.assertNull(current.comment());
+    Assertions.assertTrue(current.columns().isEmpty());
+    TablePO currentPO = getTablePO(table.id());
+    Assertions.assertEquals(initialPO.getCurrentVersion(), 
currentPO.getCurrentVersion());
+    Assertions.assertEquals(initialPO.getLastVersion(), 
currentPO.getLastVersion());
+  }
+
   @TestTemplate
   public void testBatchGetTableByIdentifierIncludesVersionInfoFields() throws 
IOException {
     createAndInsertMakeLake(metalakeName);
@@ -449,4 +925,39 @@ public class TestTableMetaService extends TestJDBCBackend {
           Assertions.assertEquals(expectedColumn.auditInfo(), 
column.auditInfo());
         });
   }
+
+  private TablePO getTablePO(long tableId) {
+    return SessionUtils.getWithoutCommit(
+        TableMetaMapper.class, mapper -> 
mapper.listTablePOsByTableIds(List.of(tableId)).get(0));
+  }
+
+  private TableEntity copyTableWithComment(TableEntity current, String 
comment) {
+    return copyTable(current, current.name(), current.namespace(), comment);
+  }
+
+  private TableEntity copyTable(
+      TableEntity current, String name, Namespace namespace, String comment) {
+    return TableEntity.builder()
+        .withId(current.id())
+        .withName(name)
+        .withNamespace(namespace)
+        .withColumns(current.columns())
+        .withProperties(current.properties())
+        .withPartitioning(current.partitioning())
+        .withSortOrders(current.sortOrders())
+        .withDistribution(current.distribution())
+        .withIndexes(current.indexes())
+        .withComment(comment)
+        .withAuditInfo(current.auditInfo())
+        .build();
+  }
+
+  private void updateTableUnchecked(
+      NameIdentifier identifier, Function<TableEntity, TableEntity> updater) {
+    try {
+      TableMetaService.getInstance().updateTable(identifier, updater);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
 }

Reply via email to