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

jerryshao 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 8f41d92f87 [#12651] improvement(core): add OCC for topic writes 
(#12680)
8f41d92f87 is described below

commit 8f41d92f87485730fbe4d75971bf5088551d37e9
Author: Qi Yu <[email protected]>
AuthorDate: Mon Aug 31 20:31:51 2026 +0800

    [#12651] improvement(core): add OCC for topic writes (#12680)
    
    ### What changes were proposed in this pull request?
    
    - Use `topic_meta.current_version` as the OCC token for topic alters and
    direct deletes.
    - Run the root topic CAS and dependent metadata cleanup in one
    transaction.
    - Classify stale writes as either `OptimisticLockException` or
    `NoSuchEntityException`.
    - Lock the parent schema during topic creation.
    - Preserve monotonic versions and persisted topic IDs during overwrite
    across H2, MySQL, and PostgreSQL.
    - Invalidate the entity cache after overwrite because the database may
    preserve existing identity.
    - Add SQL-provider, service, converter, and cache tests for the OCC
    behavior.
    
    ### Why are the changes needed?
    
    Concurrent topic writes could overwrite newer metadata, and a stale
    delete could remove relationships belonging to a newer topic. Overwrite
    could also reset the OCC version or cache an entity whose ID differed
    from the row retained by the database.
    
    Fix: #12651
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Concurrent stale topic writes now fail with an optimistic-lock
    conflict, while writes against a topic that was deleted or renamed
    report that the topic no longer exists.
    
    No public API or property key is changed.
    
    ### How was this patch tested?
    
    - `./gradlew --no-daemon :core:spotlessApply`
    - `SKIP_DOCKER_TESTS=true ./gradlew --no-daemon :core:check -PskipITs
    -PskipDockerTests=true`
    - Targeted Topic service, SQL-provider, PO converter, and relational
    entity-store tests
---
 .../storage/relational/mapper/TopicMetaMapper.java |  19 +-
 .../mapper/TopicMetaSQLProviderFactory.java        |  34 +-
 .../provider/base/TopicMetaBaseSQLProvider.java    |  62 +++-
 .../postgresql/TopicMetaPostgreSQLProvider.java    |  58 ++-
 .../relational/service/TopicMetaService.java       | 143 +++++---
 .../storage/relational/utils/POConverters.java     |   7 +-
 .../relational/TestRelationalEntityStore.java      |  26 ++
 .../mapper/TestTopicMetaSQLProviderFactory.java    |  39 +++
 .../base/TestTopicMetaBaseSQLProvider.java         |  69 ++++
 .../TestTopicMetaPostgreSQLProvider.java           |  67 ++++
 .../relational/service/TestTopicMetaService.java   | 388 ++++++++++++++++++++-
 .../storage/relational/utils/TestPOConverters.java |  56 ++-
 12 files changed, 856 insertions(+), 112 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TopicMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TopicMetaMapper.java
index fd447015d8..23b781129d 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TopicMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TopicMetaMapper.java
@@ -69,6 +69,15 @@ public interface TopicMetaMapper {
   @SelectProvider(type = TopicMetaSQLProviderFactory.class, method = 
"selectTopicMetaById")
   TopicPO selectTopicMetaById(@Param("topicId") Long topicId);
 
+  /**
+   * Selects and exclusively locks an active topic metadata row.
+   *
+   * @param topicId the topic ID
+   * @return the active topic metadata, or {@code null} when it no longer 
exists
+   */
+  @SelectProvider(type = TopicMetaSQLProviderFactory.class, method = 
"selectTopicMetaByIdForUpdate")
+  TopicPO selectTopicMetaByIdForUpdate(@Param("topicId") Long topicId);
+
   @UpdateProvider(type = TopicMetaSQLProviderFactory.class, method = 
"updateTopicMeta")
   Integer updateTopicMeta(
       @Param("newTopicMeta") TopicPO newTopicPO, @Param("oldTopicMeta") 
TopicPO oldTopicPO);
@@ -79,10 +88,18 @@ public interface TopicMetaMapper {
   Long selectTopicIdBySchemaIdAndName(
       @Param("schemaId") Long schemaId, @Param("topicName") String name);
 
+  /**
+   * Soft-deletes a topic only if its version has not changed since the caller 
read it.
+   *
+   * @param topicId the topic ID
+   * @param currentVersion the version observed by the caller
+   * @return the number of deleted rows; zero means the topic changed or 
disappeared
+   */
   @UpdateProvider(
       type = TopicMetaSQLProviderFactory.class,
       method = "softDeleteTopicMetasByTopicId")
-  Integer softDeleteTopicMetasByTopicId(@Param("topicId") Long topicId);
+  Integer softDeleteTopicMetasByTopicId(
+      @Param("topicId") Long topicId, @Param("currentVersion") Long 
currentVersion);
 
   @UpdateProvider(
       type = TopicMetaSQLProviderFactory.class,
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TopicMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TopicMetaSQLProviderFactory.java
index 32d6c1b52e..3e808b53f1 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TopicMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TopicMetaSQLProviderFactory.java
@@ -50,7 +50,17 @@ public class TopicMetaSQLProviderFactory {
 
   static class TopicMetaMySQLProvider extends TopicMetaBaseSQLProvider {}
 
-  static class TopicMetaH2Provider extends TopicMetaBaseSQLProvider {}
+  static class TopicMetaH2Provider extends TopicMetaBaseSQLProvider {
+
+    /** {@inheritDoc} */
+    @Override
+    protected String overwriteVersionAssignments() {
+      // H2 evaluates both right-hand sides against the row before the update, 
so each assignment
+      // must calculate the next version independently.
+      return " current_version = GREATEST(current_version, last_version) + 1,"
+          + " last_version = GREATEST(current_version, last_version) + 1,";
+    }
+  }
 
   public static String insertTopicMeta(@Param("topicMeta") TopicPO topicPO) {
     return getProvider().insertTopicMeta(topicPO);
@@ -93,6 +103,16 @@ public class TopicMetaSQLProviderFactory {
     return getProvider().selectTopicMetaById(topicId);
   }
 
+  /**
+   * Returns SQL that locks an active topic metadata row by ID.
+   *
+   * @param topicId the topic ID
+   * @return the locking select SQL
+   */
+  public static String selectTopicMetaByIdForUpdate(@Param("topicId") Long 
topicId) {
+    return getProvider().selectTopicMetaByIdForUpdate(topicId);
+  }
+
   public static String updateTopicMeta(
       @Param("newTopicMeta") TopicPO newTopicPO, @Param("oldTopicMeta") 
TopicPO oldTopicPO) {
     return getProvider().updateTopicMeta(newTopicPO, oldTopicPO);
@@ -103,8 +123,16 @@ public class TopicMetaSQLProviderFactory {
     return getProvider().selectTopicIdBySchemaIdAndName(schemaId, name);
   }
 
-  public static String softDeleteTopicMetasByTopicId(@Param("topicId") Long 
topicId) {
-    return getProvider().softDeleteTopicMetasByTopicId(topicId);
+  /**
+   * Returns SQL that soft-deletes a topic by ID and expected version.
+   *
+   * @param topicId the topic ID
+   * @param currentVersion the version observed by the caller
+   * @return the version-checked delete SQL
+   */
+  public static String softDeleteTopicMetasByTopicId(
+      @Param("topicId") Long topicId, @Param("currentVersion") Long 
currentVersion) {
+    return getProvider().softDeleteTopicMetasByTopicId(topicId, 
currentVersion);
   }
 
   public static String softDeleteTopicMetasByCatalogId(@Param("catalogId") 
Long catalogId) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TopicMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TopicMetaBaseSQLProvider.java
index 8d508b351b..271695b87f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TopicMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TopicMetaBaseSQLProvider.java
@@ -78,8 +78,7 @@ public class TopicMetaBaseSQLProvider {
         + " comment = #{topicMeta.comment},"
         + " properties = #{topicMeta.properties},"
         + " audit_info = #{topicMeta.auditInfo},"
-        + " current_version = #{topicMeta.currentVersion},"
-        + " last_version = #{topicMeta.lastVersion},"
+        + overwriteVersionAssignments()
         + " deleted_at = #{topicMeta.deletedAt}";
   }
 
@@ -219,6 +218,29 @@ public class TopicMetaBaseSQLProvider {
         + " WHERE topic_id = #{topicId} AND deleted_at = 0";
   }
 
+  /**
+   * Returns an active topic metadata row and locks it for the current 
transaction.
+   *
+   * <p>The stable ID lets a failed CAS distinguish a newer topic from one 
that was deleted,
+   * renamed, or moved while the caller was writing.
+   *
+   * @param topicId the topic ID
+   * @return the locking select SQL
+   */
+  public String selectTopicMetaByIdForUpdate(@Param("topicId") Long topicId) {
+    return selectTopicMetaById(topicId) + " FOR UPDATE";
+  }
+
+  /**
+   * Returns SQL that updates a topic only while its OCC version is unchanged.
+   *
+   * <p>The version is the concurrency token. Comparing payload columns would 
miss a writer that
+   * changes a value and then changes it back before this update runs.
+   *
+   * @param newTopicPO the new topic values
+   * @param oldTopicPO the topic values and version observed by the caller
+   * @return the version-checked update SQL
+   */
   public String updateTopicMeta(
       @Param("newTopicMeta") TopicPO newTopicPO, @Param("oldTopicMeta") 
TopicPO oldTopicPO) {
     return "UPDATE "
@@ -234,16 +256,7 @@ public class TopicMetaBaseSQLProvider {
         + " last_version = #{newTopicMeta.lastVersion},"
         + " deleted_at = #{newTopicMeta.deletedAt}"
         + " WHERE topic_id = #{oldTopicMeta.topicId}"
-        + " AND topic_name = #{oldTopicMeta.topicName}"
-        + " AND metalake_id = #{oldTopicMeta.metalakeId}"
-        + " AND catalog_id = #{oldTopicMeta.catalogId}"
-        + " AND schema_id = #{oldTopicMeta.schemaId}"
-        + " AND (comment = #{oldTopicMeta.comment}"
-        + "   OR (comment IS NULL and #{oldTopicMeta.comment} IS NULL))"
-        + " AND properties = #{oldTopicMeta.properties}"
-        + " AND audit_info = #{oldTopicMeta.auditInfo}"
         + " AND current_version = #{oldTopicMeta.currentVersion}"
-        + " AND last_version = #{oldTopicMeta.lastVersion}"
         + " AND deleted_at = 0";
   }
 
@@ -255,12 +268,21 @@ public class TopicMetaBaseSQLProvider {
         + " AND deleted_at = 0";
   }
 
-  public String softDeleteTopicMetasByTopicId(@Param("topicId") Long topicId) {
+  /**
+   * Returns SQL that deletes only the topic version observed by the caller.
+   *
+   * @param topicId the topic ID
+   * @param currentVersion the version observed by the caller
+   * @return the version-checked delete SQL
+   */
+  public String softDeleteTopicMetasByTopicId(
+      @Param("topicId") Long topicId, @Param("currentVersion") Long 
currentVersion) {
     return "UPDATE "
         + TABLE_NAME
         + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
         + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
-        + " WHERE topic_id = #{topicId} AND deleted_at = 0";
+        + " WHERE topic_id = #{topicId}"
+        + " AND current_version = #{currentVersion} AND deleted_at = 0";
   }
 
   public String softDeleteTopicMetasByCatalogId(@Param("catalogId") Long 
catalogId) {
@@ -334,4 +356,18 @@ public class TopicMetaBaseSQLProvider {
         + " AND tm.deleted_at = 0 AND sm.deleted_at = 0 AND cm.deleted_at = 0 
AND mm.deleted_at = 0"
         + "</script>";
   }
+
+  /**
+   * Returns MySQL assignments that advance an overwritten topic beyond both 
stored version markers.
+   *
+   * <p>MySQL evaluates assignments from left to right. Updating {@code 
current_version} first lets
+   * {@code last_version} copy the same newly computed value without 
evaluating the maximum again
+   * against a partially updated row.
+   *
+   * @return the overwrite version assignments
+   */
+  protected String overwriteVersionAssignments() {
+    return " current_version = GREATEST(current_version, last_version) + 1,"
+        + " last_version = current_version,";
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TopicMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TopicMetaPostgreSQLProvider.java
index 711c951934..3014403c14 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TopicMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TopicMetaPostgreSQLProvider.java
@@ -26,43 +26,13 @@ import org.apache.gravitino.storage.relational.po.TopicPO;
 import org.apache.ibatis.annotations.Param;
 
 public class TopicMetaPostgreSQLProvider extends TopicMetaBaseSQLProvider {
-
-  @Override
-  public String updateTopicMeta(
-      @Param("newTopicMeta") TopicPO newTopicPO, @Param("oldTopicMeta") 
TopicPO oldTopicPO) {
-    return "UPDATE "
-        + TABLE_NAME
-        + " SET topic_name = #{newTopicMeta.topicName},"
-        + " metalake_id = #{newTopicMeta.metalakeId},"
-        + " catalog_id = #{newTopicMeta.catalogId},"
-        + " schema_id = #{newTopicMeta.schemaId},"
-        + " comment = #{newTopicMeta.comment},"
-        + " properties = #{newTopicMeta.properties},"
-        + " audit_info = #{newTopicMeta.auditInfo},"
-        + " current_version = #{newTopicMeta.currentVersion},"
-        + " last_version = #{newTopicMeta.lastVersion},"
-        + " deleted_at = #{newTopicMeta.deletedAt}"
-        + " WHERE topic_id = #{oldTopicMeta.topicId}"
-        + " AND topic_name = #{oldTopicMeta.topicName}"
-        + " AND metalake_id = #{oldTopicMeta.metalakeId}"
-        + " AND catalog_id = #{oldTopicMeta.catalogId}"
-        + " AND schema_id = #{oldTopicMeta.schemaId}"
-        + " AND (comment = #{oldTopicMeta.comment}"
-        + "   OR (CAST(comment AS VARCHAR) IS NULL"
-        + "   AND CAST(#{oldTopicMeta.comment} AS VARCHAR) IS NULL))"
-        + " AND properties = #{oldTopicMeta.properties}"
-        + " AND audit_info = #{oldTopicMeta.auditInfo}"
-        + " AND current_version = #{oldTopicMeta.currentVersion}"
-        + " AND last_version = #{oldTopicMeta.lastVersion}"
-        + " AND deleted_at = 0";
-  }
-
   @Override
-  public String softDeleteTopicMetasByTopicId(Long topicId) {
+  public String softDeleteTopicMetasByTopicId(Long topicId, Long 
currentVersion) {
     return "UPDATE "
         + TABLE_NAME
         + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
-        + " WHERE topic_id = #{topicId} AND deleted_at = 0";
+        + " WHERE topic_id = #{topicId}"
+        + " AND current_version = #{currentVersion} AND deleted_at = 0";
   }
 
   @Override
@@ -115,7 +85,10 @@ public class TopicMetaPostgreSQLProvider extends 
TopicMetaBaseSQLProvider {
         + " #{topicMeta.lastVersion},"
         + " #{topicMeta.deletedAt}"
         + " )"
-        + " ON CONFLICT (topic_id) DO UPDATE SET"
+        // Overwrite is selected by name, and an import can carry a different 
ID for a topic that
+        // already has an active registration. Target the natural key so 
PostgreSQL preserves the
+        // stored topic ID, matching MySQL and H2.
+        + " ON CONFLICT (schema_id, topic_name, deleted_at) DO UPDATE SET"
         + " topic_name = #{topicMeta.topicName},"
         + " metalake_id = #{topicMeta.metalakeId},"
         + " catalog_id = #{topicMeta.catalogId},"
@@ -123,8 +96,21 @@ public class TopicMetaPostgreSQLProvider extends 
TopicMetaBaseSQLProvider {
         + " comment = #{topicMeta.comment},"
         + " properties = #{topicMeta.properties},"
         + " audit_info = #{topicMeta.auditInfo},"
-        + " current_version = #{topicMeta.currentVersion},"
-        + " last_version = #{topicMeta.lastVersion},"
+        // PostgreSQL evaluates both assignments against the stored row. 
Qualifying the columns
+        // distinguishes them from the row that caused the conflict, and 
taking the larger marker
+        // prevents an inconsistent legacy row from moving either version 
backwards.
+        + " current_version = "
+        + "GREATEST("
+        + TABLE_NAME
+        + ".current_version, "
+        + TABLE_NAME
+        + ".last_version) + 1,"
+        + " last_version = "
+        + "GREATEST("
+        + TABLE_NAME
+        + ".current_version, "
+        + TABLE_NAME
+        + ".last_version) + 1,"
         + " deleted_at = #{topicMeta.deletedAt}";
   }
 
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java
index ca33b4fe2e..367c33de63 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java
@@ -24,7 +24,6 @@ import com.google.common.base.Preconditions;
 import java.io.IOException;
 import java.util.List;
 import java.util.Objects;
-import java.util.concurrent.atomic.AtomicInteger;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
@@ -122,28 +121,28 @@ public class TopicMetaService {
         newEntity.id(),
         oldTopicEntity.id());
 
-    AtomicInteger updateResult = new AtomicInteger(0);
     try {
+      TopicPO newTopicPO = POConverters.updateTopicPOWithVersion(oldTopicPO, 
newEntity);
       SessionUtils.doMultipleWithCommit(
-          () ->
-              updateResult.set(
-                  SessionUtils.getWithoutCommit(
-                      TopicMetaMapper.class,
-                      mapper ->
-                          mapper.updateTopicMeta(
-                              
POConverters.updateTopicPOWithVersion(oldTopicPO, newEntity),
-                              oldTopicPO))));
+          () -> {
+            // current_version is the decision point for the whole write. Even 
if another writer
+            // changes the payload and later restores it, that writer still 
advances the version,
+            // so this stale update changes zero rows.
+            int updated =
+                SessionUtils.getWithoutCommit(
+                    TopicMetaMapper.class,
+                    mapper -> mapper.updateTopicMeta(newTopicPO, oldTopicPO));
+            if (updated == 0) {
+              throw topicWriteFailure(ident, oldTopicPO);
+            }
+          });
     } catch (RuntimeException re) {
       ExceptionUtils.checkSQLException(
           re, Entity.EntityType.TOPIC, newEntity.nameIdentifier().toString());
       throw re;
     }
 
-    if (updateResult.get() > 0) {
-      return newEntity;
-    } else {
-      throw new IOException("Failed to update the entity: " + ident);
-    }
+    return newEntity;
   }
 
   private TopicPO getTopicPOBySchemaIdAndName(Long schemaId, String topicName) 
{
@@ -280,44 +279,8 @@ public class TopicMetaService {
   @Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME, 
baseMetricName = "deleteTopic")
   public boolean deleteTopic(NameIdentifier identifier) {
     TopicPO topicPO = getTopicPOByIdentifier(identifier);
-    Long topicId = topicPO.getTopicId();
-
-    AtomicInteger deleteResult = new AtomicInteger(0);
-    SessionUtils.doMultipleWithCommit(
-        () ->
-            deleteResult.set(
-                SessionUtils.getWithoutCommit(
-                    TopicMetaMapper.class,
-                    mapper -> mapper.softDeleteTopicMetasByTopicId(topicId))),
-        () -> {
-          if (deleteResult.get() > 0) {
-            SessionUtils.doWithoutCommit(
-                OwnerMetaMapper.class,
-                mapper ->
-                    mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
-                        topicId, MetadataObject.Type.TOPIC.name()));
-            SessionUtils.doWithoutCommit(
-                SecurableObjectMapper.class,
-                mapper ->
-                    mapper.softDeleteObjectRelsByMetadataObject(
-                        topicId, MetadataObject.Type.TOPIC.name()));
-            SessionUtils.doWithoutCommit(
-                TagMetadataObjectRelMapper.class,
-                mapper ->
-                    mapper.softDeleteTagMetadataObjectRelsByMetadataObject(
-                        topicId, MetadataObject.Type.TOPIC.name()));
-            SessionUtils.doWithoutCommit(
-                StatisticMetaMapper.class,
-                mapper -> mapper.softDeleteStatisticsByEntityId(topicId));
-            SessionUtils.doWithoutCommit(
-                PolicyMetadataObjectRelMapper.class,
-                mapper ->
-                    mapper.softDeletePolicyMetadataObjectRelsByMetadataObject(
-                        topicId, MetadataObject.Type.TOPIC.name()));
-          }
-        });
-
-    return deleteResult.get() > 0;
+    deleteTopicWithVersion(identifier, topicPO);
+    return true;
   }
 
   @Monitored(
@@ -370,4 +333,78 @@ public class TopicMetaService {
           return POConverters.fromTopicPOs(topicPOs, firstIdent.namespace());
         });
   }
+
+  /**
+   * Deletes the observed topic and its dependent rows in one transaction.
+   *
+   * <p>Package access lets concurrency tests submit a stale snapshot while 
exercising the same
+   * root-first ordering as the public delete path.
+   *
+   * @param identifier the topic identity observed by the caller
+   * @param observedTopicPO the topic row and OCC version observed by the 
caller
+   */
+  void deleteTopicWithVersion(NameIdentifier identifier, TopicPO 
observedTopicPO) {
+    SessionUtils.doMultipleWithCommit(
+        () -> {
+          // Check the root version before touching relationships. If this 
snapshot is stale,
+          // throwing here stops the transaction before any dependent row can 
be deleted.
+          int deleted =
+              SessionUtils.getWithoutCommit(
+                  TopicMetaMapper.class,
+                  mapper ->
+                      mapper.softDeleteTopicMetasByTopicId(
+                          observedTopicPO.getTopicId(), 
observedTopicPO.getCurrentVersion()));
+          if (deleted == 0) {
+            throw topicWriteFailure(identifier, observedTopicPO);
+          }
+        },
+        () -> deleteTopicDependents(observedTopicPO.getTopicId()));
+  }
+
+  private void deleteTopicDependents(Long topicId) {
+    // The topic row has passed its version check. Every cleanup below uses 
the same transaction,
+    // so a later failure also restores the root row and all earlier 
relationship changes.
+    SessionUtils.doWithoutCommit(
+        OwnerMetaMapper.class,
+        mapper ->
+            mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
+                topicId, MetadataObject.Type.TOPIC.name()));
+    SessionUtils.doWithoutCommit(
+        SecurableObjectMapper.class,
+        mapper ->
+            mapper.softDeleteObjectRelsByMetadataObject(topicId, 
MetadataObject.Type.TOPIC.name()));
+    SessionUtils.doWithoutCommit(
+        TagMetadataObjectRelMapper.class,
+        mapper ->
+            mapper.softDeleteTagMetadataObjectRelsByMetadataObject(
+                topicId, MetadataObject.Type.TOPIC.name()));
+    SessionUtils.doWithoutCommit(
+        StatisticMetaMapper.class, mapper -> 
mapper.softDeleteStatisticsByEntityId(topicId));
+    SessionUtils.doWithoutCommit(
+        PolicyMetadataObjectRelMapper.class,
+        mapper ->
+            mapper.softDeletePolicyMetadataObjectRelsByMetadataObject(
+                topicId, MetadataObject.Type.TOPIC.name()));
+  }
+
+  private RuntimeException topicWriteFailure(NameIdentifier identifier, 
TopicPO observedTopicPO) {
+    // A zero-row CAS means either the same topic has a newer version, or the 
topic disappeared
+    // from the name the caller used. The stable-ID lock waits for an 
in-flight writer to finish so
+    // the result is classified from committed identity data.
+    TopicPO currentTopicPO =
+        SessionUtils.getWithoutCommit(
+            TopicMetaMapper.class,
+            mapper -> 
mapper.selectTopicMetaByIdForUpdate(observedTopicPO.getTopicId()));
+    if (currentTopicPO == null
+        || !Objects.equals(currentTopicPO.getTopicName(), 
observedTopicPO.getTopicName())
+        || !Objects.equals(currentTopicPO.getSchemaId(), 
observedTopicPO.getSchemaId())
+        || !Objects.equals(currentTopicPO.getCatalogId(), 
observedTopicPO.getCatalogId())
+        || !Objects.equals(currentTopicPO.getMetalakeId(), 
observedTopicPO.getMetalakeId())) {
+      return new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          Entity.EntityType.TOPIC.name().toLowerCase(),
+          identifier.name());
+    }
+    return ExceptionUtils.concurrentModification(Entity.EntityType.TOPIC, 
identifier);
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
index 49a59d32e6..889aa0d6c4 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
@@ -908,9 +908,10 @@ public class POConverters {
   }
 
   public static TopicPO updateTopicPOWithVersion(TopicPO oldTopicPO, 
TopicEntity newEntity) {
-    Long lastVersion = oldTopicPO.getLastVersion();
-    // Will set the version to the last version + 1 when having some fields 
need be multiple version
-    Long nextVersion = lastVersion;
+    // Every successful alter advances beyond both stored version markers. 
They normally match, but
+    // taking the larger value also prevents an inconsistent legacy row from 
moving either marker
+    // backwards and making an old request current again.
+    Long nextVersion = Math.max(oldTopicPO.getCurrentVersion(), 
oldTopicPO.getLastVersion()) + 1;
     try {
       return TopicPO.builder()
           .withTopicId(oldTopicPO.getTopicId())
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
index 3d7e38e9ac..09a003cebc 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/TestRelationalEntityStore.java
@@ -38,6 +38,7 @@ import org.apache.gravitino.cache.Coherence;
 import org.apache.gravitino.cache.EntityCache;
 import org.apache.gravitino.cache.NoOpsCache;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.meta.TopicEntity;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -85,6 +86,31 @@ public class TestRelationalEntityStore {
     inOrder.verify(cache).invalidate(ident, Entity.EntityType.CATALOG);
   }
 
+  @Test
+  void testOverwriteInvalidatesCacheAfterBackendInsert()
+      throws IOException, EntityAlreadyExistsException, IllegalAccessException 
{
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", 
"topic");
+    TopicEntity topic = Mockito.mock(TopicEntity.class);
+    Mockito.when(topic.nameIdentifier()).thenReturn(ident);
+    Mockito.when(topic.type()).thenReturn(Entity.EntityType.TOPIC);
+    NoOpsCache cache = (NoOpsCache) FieldUtils.readField(store, "cache", true);
+
+    Mockito.doAnswer(
+            invocation -> {
+              Mockito.verify(cache, Mockito.never()).invalidate(ident, 
Entity.EntityType.TOPIC);
+              return null;
+            })
+        .when(backend)
+        .insert(topic, true);
+
+    store.put(topic, true);
+
+    InOrder inOrder = Mockito.inOrder(backend, cache);
+    inOrder.verify(backend).insert(topic, true);
+    inOrder.verify(cache).invalidate(ident, Entity.EntityType.TOPIC);
+    Mockito.verify(cache, Mockito.never()).put(topic);
+  }
+
   @Test
   void testDeleteInvalidatesCacheAfterBackendDelete()
       throws IOException, NoSuchEntityException, IllegalAccessException {
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/TestTopicMetaSQLProviderFactory.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/TestTopicMetaSQLProviderFactory.java
new file mode 100644
index 0000000000..65c5e8cacf
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/TestTopicMetaSQLProviderFactory.java
@@ -0,0 +1,39 @@
+/*
+ * 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;
+
+import 
org.apache.gravitino.storage.relational.mapper.provider.base.TopicMetaBaseSQLProvider;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestTopicMetaSQLProviderFactory {
+
+  private static final TopicMetaBaseSQLProvider H2_PROVIDER =
+      new TopicMetaSQLProviderFactory.TopicMetaH2Provider();
+
+  @Test
+  void testH2OverwriteCalculatesBothVersionsFromStoredRow() {
+    String sql = H2_PROVIDER.insertTopicMetaOnDuplicateKeyUpdate(null);
+    String updateClause = sql.substring(sql.indexOf(" ON DUPLICATE KEY 
UPDATE"));
+    String nextVersionExpression = "GREATEST(current_version, last_version) + 
1";
+
+    Assertions.assertTrue(updateClause.contains("current_version = " + 
nextVersionExpression));
+    Assertions.assertTrue(updateClause.contains("last_version = " + 
nextVersionExpression));
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestTopicMetaBaseSQLProvider.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestTopicMetaBaseSQLProvider.java
new file mode 100644
index 0000000000..7a4e98ea2e
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestTopicMetaBaseSQLProvider.java
@@ -0,0 +1,69 @@
+/*
+ * 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 TestTopicMetaBaseSQLProvider {
+
+  private static final TopicMetaBaseSQLProvider PROVIDER = new 
TopicMetaBaseSQLProvider();
+
+  @Test
+  void testOverwriteAdvancesBeyondBothStoredVersions() {
+    String sql = PROVIDER.insertTopicMetaOnDuplicateKeyUpdate(null);
+    String updateClause = sql.substring(sql.indexOf(" ON DUPLICATE KEY 
UPDATE"));
+
+    Assertions.assertTrue(
+        updateClause.contains("current_version = GREATEST(current_version, 
last_version) + 1"));
+    Assertions.assertTrue(updateClause.contains("last_version = 
current_version"));
+    Assertions.assertTrue(
+        updateClause.indexOf("current_version =") < 
updateClause.indexOf("last_version ="));
+    
Assertions.assertFalse(updateClause.contains("#{topicMeta.currentVersion}"));
+    Assertions.assertFalse(updateClause.contains("#{topicMeta.lastVersion}"));
+  }
+
+  @Test
+  void testUpdateUsesOnlyIdVersionAndActiveStateForCas() {
+    String sql = PROVIDER.updateTopicMeta(null, null);
+    String whereClause = sql.substring(sql.indexOf(" WHERE"));
+
+    Assertions.assertEquals(
+        " WHERE topic_id = #{oldTopicMeta.topicId}"
+            + " AND current_version = #{oldTopicMeta.currentVersion}"
+            + " AND deleted_at = 0",
+        whereClause);
+  }
+
+  @Test
+  void testDirectDeleteUsesVersionCas() {
+    String sql = PROVIDER.softDeleteTopicMetasByTopicId(null, null);
+
+    Assertions.assertTrue(sql.contains("AND current_version = 
#{currentVersion}"));
+    Assertions.assertTrue(sql.endsWith("AND deleted_at = 0"));
+  }
+
+  @Test
+  void testConflictReadLocksActiveTopicByStableId() {
+    String sql = PROVIDER.selectTopicMetaByIdForUpdate(null);
+
+    Assertions.assertTrue(sql.contains("WHERE topic_id = #{topicId} 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/TestTopicMetaPostgreSQLProvider.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestTopicMetaPostgreSQLProvider.java
new file mode 100644
index 0000000000..10c5b44c31
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestTopicMetaPostgreSQLProvider.java
@@ -0,0 +1,67 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.storage.relational.mapper.provider.postgresql;
+
+import org.apache.gravitino.storage.relational.mapper.TopicMetaMapper;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestTopicMetaPostgreSQLProvider {
+
+  private static final TopicMetaPostgreSQLProvider PROVIDER = new 
TopicMetaPostgreSQLProvider();
+
+  @Test
+  void testOverwriteAdvancesBeyondBothStoredVersions() {
+    String sql = PROVIDER.insertTopicMetaOnDuplicateKeyUpdate(null);
+    String conflictClause = sql.substring(sql.indexOf(" ON CONFLICT"));
+    String nextVersionExpression =
+        "GREATEST("
+            + TopicMetaMapper.TABLE_NAME
+            + ".current_version, "
+            + TopicMetaMapper.TABLE_NAME
+            + ".last_version) + 1";
+
+    Assertions.assertTrue(
+        conflictClause.startsWith(" ON CONFLICT (schema_id, topic_name, 
deleted_at)"));
+    Assertions.assertTrue(conflictClause.contains("current_version = " + 
nextVersionExpression));
+    Assertions.assertTrue(conflictClause.contains("last_version = " + 
nextVersionExpression));
+    
Assertions.assertFalse(conflictClause.contains("#{topicMeta.currentVersion}"));
+    
Assertions.assertFalse(conflictClause.contains("#{topicMeta.lastVersion}"));
+  }
+
+  @Test
+  void testUpdateUsesVersionCas() {
+    String sql = PROVIDER.updateTopicMeta(null, null);
+    String whereClause = sql.substring(sql.indexOf(" WHERE"));
+
+    Assertions.assertEquals(
+        " WHERE topic_id = #{oldTopicMeta.topicId}"
+            + " AND current_version = #{oldTopicMeta.currentVersion}"
+            + " AND deleted_at = 0",
+        whereClause);
+  }
+
+  @Test
+  void testDirectDeleteUsesVersionCas() {
+    String sql = PROVIDER.softDeleteTopicMetasByTopicId(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/TestTopicMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTopicMetaService.java
index a8a3c6fc1f..45c6c97313 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTopicMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTopicMetaService.java
@@ -28,30 +28,48 @@ import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 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 org.apache.gravitino.Entity;
 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.SchemaEntity;
+import org.apache.gravitino.meta.TagEntity;
 import org.apache.gravitino.meta.TopicEntity;
 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.TopicMetaMapper;
+import org.apache.gravitino.storage.relational.po.SchemaPO;
+import org.apache.gravitino.storage.relational.po.TopicPO;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.TestTemplate;
+import org.junit.jupiter.api.function.Executable;
 
 public class TestTopicMetaService extends TestJDBCBackend {
   private final String metalakeName = "metalake_for_topic_test";
   private final String catalogName = "catalog_for_topic_test";
   private final String schemaName = "schema_for_topic_test";
+  private SchemaEntity schema;
 
   @BeforeEach
   public void prepare() throws IOException {
     createAndInsertMakeLake(metalakeName);
     createAndInsertCatalog(metalakeName, catalogName);
-    createAndInsertSchema(metalakeName, catalogName, schemaName);
+    schema = createAndInsertSchema(metalakeName, catalogName, schemaName);
   }
 
   @TestTemplate
@@ -72,6 +90,24 @@ public class TestTopicMetaService extends TestJDBCBackend {
     assertThrows(EntityAlreadyExistsException.class, () -> 
backend.insert(topicCopy, false));
   }
 
+  @TestTemplate
+  public void testInsertWaitsForConcurrentSchemaDelete() throws Exception {
+    SchemaPO observedSchemaPO =
+        SessionUtils.getWithoutCommit(
+            SchemaMetaMapper.class, mapper -> 
mapper.selectSchemaMetaById(schema.id()));
+    TopicEntity topic = createTopic("topic_racing_schema_delete", "comment");
+
+    Throwable insertFailure =
+        runWhileSchemaDeleteUncommitted(
+            observedSchemaPO, () -> 
TopicMetaService.getInstance().insertTopic(topic, false));
+
+    Assertions.assertInstanceOf(NoSuchEntityException.class, insertFailure);
+    Assertions.assertTrue(
+        SessionUtils.getWithoutCommit(
+                TopicMetaMapper.class, mapper -> 
mapper.listTopicPOsByTopicIds(List.of(topic.id())))
+            .isEmpty());
+  }
+
   @TestTemplate
   public void testMetaLifeCycleFromCreationToDeletion() throws IOException {
     TopicEntity topic =
@@ -152,6 +188,232 @@ public class TestTopicMetaService extends TestJDBCBackend 
{
                     createTopicEntity(topicCopy.id(), topicCopy.namespace(), 
"topic", AUDIT_INFO)));
   }
 
+  @TestTemplate
+  public void testAlterDetectsChangeThenChangeBack() throws IOException {
+    TopicEntity topic = createTopic("topic_change_back", "original");
+    TopicMetaService.getInstance().insertTopic(topic, false);
+    TopicPO initialPO = getTopicPO(topic.id());
+
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            TopicMetaService.getInstance()
+                .updateTopic(
+                    topic.nameIdentifier(),
+                    entity -> {
+                      // Restore the original payload through a second 
committed alter. A full-row
+                      // comparison would miss both writes, but the OCC 
version must still expose
+                      // the stale outer update.
+                      updateTopicUnchecked(
+                          topic.nameIdentifier(),
+                          current -> copyTopic(current, current.name(), 
"temporary"));
+                      updateTopicUnchecked(
+                          topic.nameIdentifier(),
+                          current -> copyTopic(current, current.name(), 
"original"));
+                      TopicEntity stale = (TopicEntity) entity;
+                      return copyTopic(stale, stale.name(), "stale update");
+                    }));
+
+    TopicEntity stored =
+        
TopicMetaService.getInstance().getTopicByIdentifier(topic.nameIdentifier());
+    TopicPO currentPO = getTopicPO(topic.id());
+    Assertions.assertEquals("original", stored.comment());
+    Assertions.assertEquals(
+        initialPO.getCurrentVersion() + 2, 
currentPO.getCurrentVersion().longValue());
+    Assertions.assertEquals(currentPO.getCurrentVersion(), 
currentPO.getLastVersion());
+  }
+
+  @TestTemplate
+  public void testOverwriteAdvancesVersionAndRejectsStaleAlter() throws 
IOException {
+    TopicEntity topic = createTopic("topic_overwrite_occ", "original");
+    TopicMetaService.getInstance().insertTopic(topic, false);
+    TopicPO initialPO = getTopicPO(topic.id());
+    TopicEntity replacement = copyTopic(topic, topic.name(), "overwrite 
winner");
+
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            TopicMetaService.getInstance()
+                .updateTopic(
+                    topic.nameIdentifier(),
+                    entity -> {
+                      insertTopicUnchecked(replacement, true);
+                      TopicEntity stale = (TopicEntity) entity;
+                      return copyTopic(stale, stale.name(), "stale alter");
+                    }));
+
+    TopicEntity stored =
+        
TopicMetaService.getInstance().getTopicByIdentifier(topic.nameIdentifier());
+    TopicPO currentPO = getTopicPO(topic.id());
+    Assertions.assertEquals("overwrite winner", stored.comment());
+    Assertions.assertEquals(
+        initialPO.getCurrentVersion() + 1, 
currentPO.getCurrentVersion().longValue());
+    Assertions.assertEquals(currentPO.getCurrentVersion(), 
currentPO.getLastVersion());
+  }
+
+  @TestTemplate
+  public void testNaturalKeyOverwritePreservesStoredTopicId() throws 
IOException {
+    // Every backend resolves overwrite by the identifier and keeps the ID 
already stored for it.
+    // Relationships keyed by that ID must remain attached to the same topic.
+    TopicEntity topic = createTopic("topic_natural_key_overwrite", "original");
+    TopicMetaService.getInstance().insertTopic(topic, false);
+    TopicPO initialPO = getTopicPO(topic.id());
+    TopicEntity replacement =
+        TopicEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName(topic.name())
+            .withNamespace(topic.namespace())
+            .withComment("replacement")
+            .withProperties(topic.properties())
+            .withAuditInfo(topic.auditInfo())
+            .build();
+
+    TopicMetaService.getInstance().insertTopic(replacement, true);
+
+    TopicEntity stored =
+        
TopicMetaService.getInstance().getTopicByIdentifier(topic.nameIdentifier());
+    TopicPO currentPO = getTopicPO(topic.id());
+    Assertions.assertEquals(topic.id(), stored.id());
+    Assertions.assertEquals("replacement", stored.comment());
+    Assertions.assertEquals(
+        initialPO.getCurrentVersion() + 1, 
currentPO.getCurrentVersion().longValue());
+  }
+
+  @TestTemplate
+  public void testOverwriteAdvancesBeyondBothVersionMarkers() throws 
IOException {
+    TopicEntity topic = createTopic("topic_overwrite_mismatched_versions", 
"original");
+    TopicMetaService.getInstance().insertTopic(topic, false);
+    TopicPO initialPO = getTopicPO(topic.id());
+    TopicPO inconsistentLegacyPO = copyTopicPOWithVersions(initialPO, 3L, 5L);
+    SessionUtils.doWithCommit(
+        TopicMetaMapper.class,
+        mapper ->
+            Assertions.assertEquals(1, 
mapper.updateTopicMeta(inconsistentLegacyPO, initialPO)));
+
+    TopicEntity replacement = copyTopic(topic, topic.name(), "replacement");
+    TopicMetaService.getInstance().insertTopic(replacement, true);
+
+    TopicPO currentPO = getTopicPO(topic.id());
+    Assertions.assertEquals(6L, currentPO.getCurrentVersion());
+    Assertions.assertEquals(6L, currentPO.getLastVersion());
+    Assertions.assertEquals(
+        "replacement",
+        
TopicMetaService.getInstance().getTopicByIdentifier(topic.nameIdentifier()).comment());
+  }
+
+  @TestTemplate
+  public void testAlterReportsNoSuchWhenDeletedConcurrently() throws 
IOException {
+    TopicEntity topic = createTopic("topic_alter_deleted", "original");
+    TopicMetaService.getInstance().insertTopic(topic, false);
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            TopicMetaService.getInstance()
+                .updateTopic(
+                    topic.nameIdentifier(),
+                    entity -> {
+                      
TopicMetaService.getInstance().deleteTopic(topic.nameIdentifier());
+                      TopicEntity stale = (TopicEntity) entity;
+                      return copyTopic(stale, stale.name(), "stale alter");
+                    }));
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
TopicMetaService.getInstance().getTopicByIdentifier(topic.nameIdentifier()));
+  }
+
+  @TestTemplate
+  public void testAlterReportsNoSuchWhenRenamedConcurrently() throws 
IOException {
+    TopicEntity topic = createTopic("topic_rename_conflict", "original");
+    TopicMetaService.getInstance().insertTopic(topic, false);
+    String renamedName = topic.name() + "_winner";
+    NameIdentifier renamedIdentifier = NameIdentifier.of(topic.namespace(), 
renamedName);
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            TopicMetaService.getInstance()
+                .updateTopic(
+                    topic.nameIdentifier(),
+                    entity -> {
+                      updateTopicUnchecked(
+                          topic.nameIdentifier(),
+                          current -> copyTopic(current, renamedName, "rename 
winner"));
+                      TopicEntity stale = (TopicEntity) entity;
+                      return copyTopic(stale, stale.name(), "stale alter");
+                    }));
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
TopicMetaService.getInstance().getTopicByIdentifier(topic.nameIdentifier()));
+    Assertions.assertEquals(
+        "rename winner",
+        
TopicMetaService.getInstance().getTopicByIdentifier(renamedIdentifier).comment());
+  }
+
+  @TestTemplate
+  public void testStaleDeleteKeepsNewerTopicAndTagRelation() throws 
IOException {
+    TopicEntity topic = createTopic("topic_stale_delete", "original");
+    TopicMetaService.getInstance().insertTopic(topic, false);
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("topic_occ_tag")
+            .withNamespace(NamespaceUtil.ofTag(metalakeName))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TagMetaService.getInstance().insertTag(tag, false);
+    TagMetaService.getInstance()
+        .associateTagsWithMetadataObject(
+            topic.nameIdentifier(),
+            topic.type(),
+            new NameIdentifier[] {tag.nameIdentifier()},
+            new NameIdentifier[0]);
+    TopicPO stalePO = getTopicPO(topic.id());
+
+    TopicMetaService.getInstance()
+        .updateTopic(
+            topic.nameIdentifier(),
+            entity -> {
+              TopicEntity current = (TopicEntity) entity;
+              return copyTopic(current, current.name(), "winning alter");
+            });
+
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            
TopicMetaService.getInstance().deleteTopicWithVersion(topic.nameIdentifier(), 
stalePO));
+
+    TopicEntity current =
+        
TopicMetaService.getInstance().getTopicByIdentifier(topic.nameIdentifier());
+    Assertions.assertEquals("winning alter", current.comment());
+    Assertions.assertEquals(
+        List.of(tag),
+        TagMetaService.getInstance()
+            .listTagsForMetadataObject(topic.nameIdentifier(), topic.type()));
+
+    
assertTrue(TopicMetaService.getInstance().deleteTopic(topic.nameIdentifier()));
+    assertTrue(
+        TagMetaService.getInstance()
+            .listAssociatedMetadataObjectsForTag(tag.nameIdentifier())
+            .isEmpty());
+  }
+
+  @TestTemplate
+  public void testDeleteReportsNoSuchWhenDeletedConcurrently() throws 
IOException {
+    TopicEntity topic = createTopic("topic_double_delete", "original");
+    TopicMetaService.getInstance().insertTopic(topic, false);
+    TopicPO stalePO = getTopicPO(topic.id());
+
+    TopicMetaService.getInstance().deleteTopic(topic.nameIdentifier());
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            
TopicMetaService.getInstance().deleteTopicWithVersion(topic.nameIdentifier(), 
stalePO));
+  }
+
   @TestTemplate
   public void 
testGetTopicByFullQualifiedNameMalformedNamespaceThrowsNoSuchEntityException()
       throws Exception {
@@ -170,4 +432,128 @@ public class TestTopicMetaService extends TestJDBCBackend 
{
 
     assertInstanceOf(NoSuchEntityException.class, 
invocationTargetException.getCause());
   }
+
+  private TopicEntity createTopic(String name, String comment) {
+    return TopicEntity.builder()
+        .withId(RandomIdGenerator.INSTANCE.nextId())
+        .withName(name)
+        .withNamespace(NamespaceUtil.ofTopic(metalakeName, catalogName, 
schemaName))
+        .withComment(comment)
+        .withProperties(Map.of("key", "value"))
+        .withAuditInfo(AUDIT_INFO)
+        .build();
+  }
+
+  /**
+   * Holds an uncommitted schema delete open and runs {@code victim} while the 
schema row is locked.
+   *
+   * <p>The victim must wait for the delete to commit. It can then report the 
missing parent without
+   * leaving an active topic below that deleted schema.
+   */
+  private Throwable runWhileSchemaDeleteUncommitted(SchemaPO observedSchemaPO, 
Executable victim)
+      throws Exception {
+    CountDownLatch schemaDeleteLocked = new CountDownLatch(1);
+    CountDownLatch allowDeleteCommit = new CountDownLatch(1);
+    CountDownLatch victimStarted = 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> victimResult =
+          executor.submit(
+              () -> {
+                victimStarted.countDown();
+                try {
+                  victim.execute();
+                  return null;
+                } catch (Throwable throwable) {
+                  return throwable;
+                }
+              });
+      assertTrue(victimStarted.await(30, TimeUnit.SECONDS));
+      assertThrows(TimeoutException.class, () -> victimResult.get(500, 
TimeUnit.MILLISECONDS));
+
+      allowDeleteCommit.countDown();
+      Assertions.assertNull(deleteResult.get(30, TimeUnit.SECONDS));
+      return victimResult.get(30, TimeUnit.SECONDS);
+    } finally {
+      allowDeleteCommit.countDown();
+      executor.shutdownNow();
+    }
+  }
+
+  private TopicEntity copyTopic(TopicEntity source, String name, String 
comment) {
+    return TopicEntity.builder()
+        .withId(source.id())
+        .withName(name)
+        .withNamespace(source.namespace())
+        .withComment(comment)
+        .withProperties(source.properties())
+        .withAuditInfo(source.auditInfo())
+        .build();
+  }
+
+  private TopicPO getTopicPO(Long topicId) {
+    return SessionUtils.getWithoutCommit(
+        TopicMetaMapper.class, mapper -> mapper.selectTopicMetaById(topicId));
+  }
+
+  private TopicPO copyTopicPOWithVersions(TopicPO source, Long currentVersion, 
Long lastVersion) {
+    return TopicPO.builder()
+        .withTopicId(source.getTopicId())
+        .withTopicName(source.getTopicName())
+        .withMetalakeId(source.getMetalakeId())
+        .withCatalogId(source.getCatalogId())
+        .withSchemaId(source.getSchemaId())
+        .withComment(source.getComment())
+        .withProperties(source.getProperties())
+        .withAuditInfo(source.getAuditInfo())
+        .withCurrentVersion(currentVersion)
+        .withLastVersion(lastVersion)
+        .withDeletedAt(source.getDeletedAt())
+        .build();
+  }
+
+  private void updateTopicUnchecked(
+      NameIdentifier identifier, Function<TopicEntity, TopicEntity> updater) {
+    try {
+      TopicMetaService.getInstance().updateTopic(identifier, updater);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private void insertTopicUnchecked(TopicEntity topic, boolean overwrite) {
+    try {
+      TopicMetaService.getInstance().insertTopic(topic, overwrite);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
index e7b0beae5f..e3121b74c5 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
@@ -370,6 +370,44 @@ public class TestPOConverters {
     assertEquals(expectedTopic.properties(), convertedTopic.properties());
   }
 
+  @Test
+  public void testUpdateTopicPOAdvancesOccVersion() throws 
JsonProcessingException {
+    TopicPO oldTopicPO =
+        createTopicPO(1L, "test", 1L, 1L, 1L, "old comment", 
ImmutableMap.of("key", "value"));
+    TopicEntity updatedTopic =
+        createTopic(
+            1L,
+            "test",
+            NamespaceUtil.ofTopic("test_metalake", "test_catalog", 
"test_schema"),
+            "new comment",
+            ImmutableMap.of("key", "new value"));
+
+    TopicPO updatedTopicPO = POConverters.updateTopicPOWithVersion(oldTopicPO, 
updatedTopic);
+
+    assertEquals(2L, updatedTopicPO.getCurrentVersion());
+    assertEquals(2L, updatedTopicPO.getLastVersion());
+    assertEquals("new comment", updatedTopicPO.getComment());
+    assertEquals(
+        updatedTopic.properties(),
+        JsonUtils.anyFieldMapper().readValue(updatedTopicPO.getProperties(), 
Map.class));
+
+    TopicPO currentVersionAhead =
+        createTopicPO(
+            1L, "test", 1L, 1L, 1L, "old comment", ImmutableMap.of("key", 
"value"), 5L, 3L);
+    TopicPO lastVersionAhead =
+        createTopicPO(
+            1L, "test", 1L, 1L, 1L, "old comment", ImmutableMap.of("key", 
"value"), 3L, 5L);
+
+    // A legacy row with mismatched markers must advance beyond both values, 
whichever is larger.
+    assertEquals(
+        6L,
+        POConverters.updateTopicPOWithVersion(currentVersionAhead, 
updatedTopic)
+            .getCurrentVersion());
+    assertEquals(
+        6L,
+        POConverters.updateTopicPOWithVersion(lastVersionAhead, 
updatedTopic).getCurrentVersion());
+  }
+
   @Test
   public void testFromMetalakePOs() throws JsonProcessingException {
     MetalakePO metalakePO1 = createMetalakePO(1L, "test", "this is test");
@@ -1577,6 +1615,20 @@ public class TestPOConverters {
       String comment,
       Map<String, String> properties)
       throws JsonProcessingException {
+    return createTopicPO(id, name, metalakeId, catalogId, schemaId, comment, 
properties, 1L, 1L);
+  }
+
+  private static TopicPO createTopicPO(
+      Long id,
+      String name,
+      Long metalakeId,
+      Long catalogId,
+      Long schemaId,
+      String comment,
+      Map<String, String> properties,
+      Long currentVersion,
+      Long lastVersion)
+      throws JsonProcessingException {
     AuditInfo auditInfo =
         
AuditInfo.builder().withCreator("creator").withCreateTime(FIX_INSTANT).build();
     return TopicPO.builder()
@@ -1588,8 +1640,8 @@ public class TestPOConverters {
         .withComment(comment)
         
.withProperties(JsonUtils.anyFieldMapper().writeValueAsString(properties))
         
.withAuditInfo(JsonUtils.anyFieldMapper().writeValueAsString(auditInfo))
-        .withCurrentVersion(1L)
-        .withLastVersion(1L)
+        .withCurrentVersion(currentVersion)
+        .withLastVersion(lastVersion)
         .withDeletedAt(0L)
         .build();
   }

Reply via email to