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 d326c6ac2a [#12775] feat(core): Add OCC for tag metadata (#12781)
d326c6ac2a is described below

commit d326c6ac2ae3de7107018c17f0eca04e10dabd9c
Author: Qi Yu <[email protected]>
AuthorDate: Mon Sep 7 14:02:01 2026 +0800

    [#12775] feat(core): Add OCC for tag metadata (#12781)
    
    ### What changes were proposed in this pull request?
    
    - Apply stable-ID and expected-version CAS to tag alter and delete
    operations.
    - Replace database-specific tag overwrite upserts with locked, monotonic
    root updates that preserve the stored tag ID.
    - Lock parent metalakes during creation and tag roots during assignment
    changes.
    - Keep tag deletion and all dependent relationship cleanup, including
    policy-on-tag rows, atomic.
    - Add real two-transaction interleaving coverage for create/delete,
    overwrite/rename, and assignment/rename races.
    
    ### Why are the changes needed?
    
    Concurrent tag writes could otherwise cause lost updates, stale
    deletions, ID/version resets, or partial relationship cleanup.
    
    Fix: #12775
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API or configuration change. Concurrent stale writes now fail
    deterministically; a tag deleted or renamed by a competing transaction
    preserves the existing idempotent delete result.
    
    ### How was this patch tested?
    
    - `./gradlew :core:spotlessApply :core:compileTestJava :core:javadoc`
    - `./gradlew :core:test --tests
    org.apache.gravitino.storage.relational.service.TestTagMetaService
    --tests
    org.apache.gravitino.storage.relational.service.TestPolicyTagRelService
    --tests org.apache.gravitino.tag.TestTagManager -PskipDockerTests=false`
    - `TestTagMetaService` ran against H2, MySQL, and PostgreSQL.
---
 .../relational/mapper/PolicyTagRelMapper.java      |   9 +
 .../mapper/PolicyTagRelSQLProviderFactory.java     |   5 +
 .../storage/relational/mapper/TagMetaMapper.java   |  40 +-
 .../mapper/TagMetaSQLProviderFactory.java          |  22 +-
 .../mapper/TagMetadataObjectRelMapper.java         |  11 +
 .../TagMetadataObjectRelSQLProviderFactory.java    |   5 +
 .../provider/base/PolicyTagRelBaseSQLProvider.java |   9 +
 .../provider/base/TagMetaBaseSQLProvider.java      |  89 ++-
 .../base/TagMetadataObjectRelBaseSQLProvider.java  |   9 +
 .../postgresql/TagMetaPostgreSQLProvider.java      |  50 +-
 .../TagMetadataObjectRelPostgreSQLProvider.java    |   8 +
 .../relational/service/PolicyTagRelService.java    |  26 +-
 .../storage/relational/service/TagMetaService.java | 370 ++++++++++---
 .../storage/relational/utils/POConverters.java     |   5 +-
 .../java/org/apache/gravitino/tag/TagManager.java  |  18 +
 .../relational/service/TestTagMetaService.java     | 597 +++++++++++++++++++++
 16 files changed, 1082 insertions(+), 191 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/PolicyTagRelMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/PolicyTagRelMapper.java
index 370c2b2f5e..35a97b04a8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/PolicyTagRelMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/PolicyTagRelMapper.java
@@ -91,6 +91,15 @@ public interface PolicyTagRelMapper {
   @UpdateProvider(type = PolicyTagRelSQLProviderFactory.class, method = 
"softDeleteByMetalakeId")
   int softDeleteByMetalakeId(@Param("metalakeId") Long metalakeId);
 
+  /**
+   * Soft-deletes every active policy relation for a tag.
+   *
+   * @param tagId The tag ID.
+   * @return The number of affected rows.
+   */
+  @UpdateProvider(type = PolicyTagRelSQLProviderFactory.class, method = 
"softDeleteByTagId")
+  int softDeleteByTagId(@Param("tagId") Long tagId);
+
   /**
    * Physically deletes expired relation rows.
    *
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/PolicyTagRelSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/PolicyTagRelSQLProviderFactory.java
index d9cdb827ce..5a82d68d77 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/PolicyTagRelSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/PolicyTagRelSQLProviderFactory.java
@@ -82,6 +82,11 @@ public class PolicyTagRelSQLProviderFactory {
     return getProvider().softDeleteByMetalakeId(metalakeId);
   }
 
+  /** Delegates tag deletion cleanup. */
+  public static String softDeleteByTagId(@Param("tagId") Long tagId) {
+    return getProvider().softDeleteByTagId(tagId);
+  }
+
   /** Delegates expired relation cleanup. */
   public static String deleteByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetaMapper.java
index 2d6095bf03..721499826f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetaMapper.java
@@ -53,22 +53,37 @@ public interface TagMetaMapper {
   TagPO selectTagMetaByMetalakeIdAndName(
       @Param("metalakeId") Long metalakeId, @Param("name") String tagName);
 
+  /**
+   * Selects and exclusively locks an active tag by its natural key.
+   *
+   * @param metalakeId The metalake ID.
+   * @param tagName The tag name.
+   * @return The locked tag, or null if the natural key is not active.
+   */
+  @SelectProvider(
+      type = TagMetaSQLProviderFactory.class,
+      method = "selectTagMetaByMetalakeIdAndNameForUpdate")
+  TagPO selectTagMetaByMetalakeIdAndNameForUpdate(
+      @Param("metalakeId") Long metalakeId, @Param("name") String tagName);
+
   @InsertProvider(type = TagMetaSQLProviderFactory.class, method = 
"insertTagMeta")
   void insertTagMeta(@Param("tagMeta") TagPO tagPO);
 
-  @InsertProvider(
-      type = TagMetaSQLProviderFactory.class,
-      method = "insertTagMetaOnDuplicateKeyUpdate")
-  void insertTagMetaOnDuplicateKeyUpdate(@Param("tagMeta") TagPO tagPO);
-
   @UpdateProvider(type = TagMetaSQLProviderFactory.class, method = 
"updateTagMeta")
   Integer updateTagMeta(@Param("newTagMeta") TagPO newTagPO, 
@Param("oldTagMeta") TagPO oldTagPO);
 
+  /**
+   * Soft-deletes an active tag when its OCC version still matches.
+   *
+   * @param tagId The tag ID.
+   * @param currentVersion The version observed by the caller.
+   * @return The number of affected rows.
+   */
   @UpdateProvider(
       type = TagMetaSQLProviderFactory.class,
-      method = "softDeleteTagMetaByMetalakeAndTagName")
-  Integer softDeleteTagMetaByMetalakeAndTagName(
-      @Param("metalakeName") String metalakeName, @Param("tagName") String 
tagName);
+      method = "softDeleteTagMetaByIdAndVersion")
+  Integer softDeleteTagMetaByIdAndVersion(
+      @Param("tagId") Long tagId, @Param("currentVersion") Long 
currentVersion);
 
   @UpdateProvider(type = TagMetaSQLProviderFactory.class, method = 
"softDeleteTagMetasByMetalakeId")
   void softDeleteTagMetasByMetalakeId(@Param("metalakeId") Long metalakeId);
@@ -92,6 +107,15 @@ public interface TagMetaMapper {
   @SelectProvider(type = TagMetaSQLProviderFactory.class, method = 
"listTagPOsByTagIds")
   List<TagPO> listTagPOsByTagIds(@Param("tagIds") List<Long> tagIds);
 
+  /**
+   * Selects and exclusively locks the active tags with the given IDs, in 
ascending ID order.
+   *
+   * @param tagIds The tag IDs to lock.
+   * @return The locked tags. Tags that are not active are absent from the 
result.
+   */
+  @SelectProvider(type = TagMetaSQLProviderFactory.class, method = 
"listTagPOsByTagIdsForUpdate")
+  List<TagPO> listTagPOsByTagIdsForUpdate(@Param("tagIds") List<Long> tagIds);
+
   @SelectProvider(type = TagMetaSQLProviderFactory.class, method = 
"batchSelectTagByIdentifier")
   List<TagPO> batchSelectTagByIdentifier(
       @Param("metalakeName") String metalakeName, @Param("tagNames") 
List<String> tagNames);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetaSQLProviderFactory.java
index 400bcc0e08..b7feb9b4b7 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetaSQLProviderFactory.java
@@ -74,18 +74,15 @@ public class TagMetaSQLProviderFactory {
     return getProvider().insertTagMeta(tagPO);
   }
 
-  public static String insertTagMetaOnDuplicateKeyUpdate(@Param("tagMeta") 
TagPO tagPO) {
-    return getProvider().insertTagMetaOnDuplicateKeyUpdate(tagPO);
-  }
-
   public static String updateTagMeta(
       @Param("newTagMeta") TagPO newTagPO, @Param("oldTagMeta") TagPO 
oldTagPO) {
     return getProvider().updateTagMeta(newTagPO, oldTagPO);
   }
 
-  public static String softDeleteTagMetaByMetalakeAndTagName(
-      @Param("metalakeName") String metalakeName, @Param("tagName") String 
tagName) {
-    return getProvider().softDeleteTagMetaByMetalakeAndTagName(metalakeName, 
tagName);
+  /** Delegates a version-checked tag soft delete. */
+  public static String softDeleteTagMetaByIdAndVersion(
+      @Param("tagId") Long tagId, @Param("currentVersion") Long 
currentVersion) {
+    return getProvider().softDeleteTagMetaByIdAndVersion(tagId, 
currentVersion);
   }
 
   public static String softDeleteTagMetasByMetalakeId(@Param("metalakeId") 
Long metalakeId) {
@@ -102,6 +99,12 @@ public class TagMetaSQLProviderFactory {
     return getProvider().selectTagMetaByMetalakeIdAndName(metalakeId, name);
   }
 
+  /** Delegates an exclusive-lock tag query by natural key. */
+  public static String selectTagMetaByMetalakeIdAndNameForUpdate(
+      @Param("metalakeId") Long metalakeId, @Param("name") String name) {
+    return getProvider().selectTagMetaByMetalakeIdAndNameForUpdate(metalakeId, 
name);
+  }
+
   public static String selectTagByTagId(@Param("tagId") Long tagId) {
     return getProvider().selectTagByTagId(tagId);
   }
@@ -111,6 +114,11 @@ public class TagMetaSQLProviderFactory {
     return getProvider().selectTagByTagIdForUpdate(tagId);
   }
 
+  /** Delegates a locking read of several tags. */
+  public static String listTagPOsByTagIdsForUpdate(@Param("tagIds") List<Long> 
tagIds) {
+    return getProvider().listTagPOsByTagIdsForUpdate(tagIds);
+  }
+
   public static String listTagPOsByTagIds(@Param("tagIds") List<Long> tagIds) {
     return getProvider().listTagPOsByTagIds(tagIds);
   }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetadataObjectRelMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetadataObjectRelMapper.java
index bdb461a3f6..48ecf2636f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetadataObjectRelMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetadataObjectRelMapper.java
@@ -86,6 +86,17 @@ public interface TagMetadataObjectRelMapper {
   Integer softDeleteTagMetadataObjectRelsByMetalakeAndTagName(
       @Param("metalakeName") String metalakeName, @Param("tagName") String 
tagName);
 
+  /**
+   * Soft-deletes all active metadata-object assignments for a tag.
+   *
+   * @param tagId The tag ID.
+   * @return The number of affected rows.
+   */
+  @UpdateProvider(
+      type = TagMetadataObjectRelSQLProviderFactory.class,
+      method = "softDeleteTagMetadataObjectRelsByTagId")
+  Integer softDeleteTagMetadataObjectRelsByTagId(@Param("tagId") Long tagId);
+
   @UpdateProvider(
       type = TagMetadataObjectRelSQLProviderFactory.class,
       method = "softDeleteTagMetadataObjectRelsByMetalakeId")
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetadataObjectRelSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetadataObjectRelSQLProviderFactory.java
index 43ec136a93..f604d2aac6 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetadataObjectRelSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/TagMetadataObjectRelSQLProviderFactory.java
@@ -107,6 +107,11 @@ public class TagMetadataObjectRelSQLProviderFactory {
     return 
getProvider().softDeleteTagMetadataObjectRelsByMetalakeAndTagName(metalakeName, 
tagName);
   }
 
+  /** Delegates cleanup of metadata-object assignments by tag ID. */
+  public static String softDeleteTagMetadataObjectRelsByTagId(@Param("tagId") 
Long tagId) {
+    return getProvider().softDeleteTagMetadataObjectRelsByTagId(tagId);
+  }
+
   public static String softDeleteTagMetadataObjectRelsByMetalakeId(
       @Param("metalakeId") Long metalakeId) {
     return 
getProvider().softDeleteTagMetadataObjectRelsByMetalakeId(metalakeId);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/PolicyTagRelBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/PolicyTagRelBaseSQLProvider.java
index b26a842da4..826e37ba88 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/PolicyTagRelBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/PolicyTagRelBaseSQLProvider.java
@@ -90,6 +90,15 @@ public class PolicyTagRelBaseSQLProvider {
         + " AND deleted_at = 0";
   }
 
+  /** Returns SQL for soft-deleting policy relations when a tag is deleted. */
+  public String softDeleteByTagId(@Param("tagId") Long tagId) {
+    return "UPDATE "
+        + POLICY_TAG_RELATION_TABLE_NAME
+        + " SET deleted_at = "
+        + deletedAtNowExpression()
+        + " WHERE tag_id = #{tagId} AND deleted_at = 0";
+  }
+
   /** Returns SQL for physically deleting expired relation rows. */
   public String deleteByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit) 
{
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetaBaseSQLProvider.java
index 65a2b4bcda..1b13ad6e1f 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetaBaseSQLProvider.java
@@ -123,36 +123,6 @@ public class TagMetaBaseSQLProvider {
         + " )";
   }
 
-  public String insertTagMetaOnDuplicateKeyUpdate(@Param("tagMeta") TagPO 
tagPO) {
-    return "INSERT INTO "
-        + TAG_TABLE_NAME
-        + " (tag_id, tag_name,"
-        + " metalake_id, tag_comment, properties, allowed_values, audit_info,"
-        + " current_version, last_version, deleted_at)"
-        + " VALUES ("
-        + " #{tagMeta.tagId},"
-        + " #{tagMeta.tagName},"
-        + " #{tagMeta.metalakeId},"
-        + " #{tagMeta.comment},"
-        + " #{tagMeta.properties},"
-        + " #{tagMeta.allowedValues},"
-        + " #{tagMeta.auditInfo},"
-        + " #{tagMeta.currentVersion},"
-        + " #{tagMeta.lastVersion},"
-        + " #{tagMeta.deletedAt}"
-        + " )"
-        + " ON DUPLICATE KEY UPDATE"
-        + " tag_name = #{tagMeta.tagName},"
-        + " metalake_id = #{tagMeta.metalakeId},"
-        + " tag_comment = #{tagMeta.comment},"
-        + " properties = #{tagMeta.properties},"
-        + " allowed_values = #{tagMeta.allowedValues},"
-        + " audit_info = #{tagMeta.auditInfo},"
-        + " current_version = #{tagMeta.currentVersion},"
-        + " last_version = #{tagMeta.lastVersion},"
-        + " deleted_at = #{tagMeta.deletedAt}";
-  }
-
   public String updateTagMeta(
       @Param("newTagMeta") TagPO newTagPO, @Param("oldTagMeta") TagPO 
oldTagPO) {
     return "UPDATE "
@@ -166,28 +136,19 @@ public class TagMetaBaseSQLProvider {
         + " last_version = #{newTagMeta.lastVersion},"
         + " deleted_at = #{newTagMeta.deletedAt}"
         + " WHERE tag_id = #{oldTagMeta.tagId}"
-        + " AND metalake_id = #{oldTagMeta.metalakeId}"
-        + " AND tag_name = #{oldTagMeta.tagName}"
-        + " AND (tag_comment = #{oldTagMeta.comment}"
-        + "   OR (tag_comment IS NULL and #{oldTagMeta.comment} IS NULL))"
-        + " AND properties = #{oldTagMeta.properties}"
-        + " AND audit_info = #{oldTagMeta.auditInfo}"
         + " AND current_version = #{oldTagMeta.currentVersion}"
-        + " AND last_version = #{oldTagMeta.lastVersion}"
         + " AND deleted_at = 0";
   }
 
-  public String softDeleteTagMetaByMetalakeAndTagName(
-      @Param("metalakeName") String metalakeName, @Param("tagName") String 
tagName) {
+  /** Returns SQL that soft-deletes a tag using its stable ID and observed OCC 
version. */
+  public String softDeleteTagMetaByIdAndVersion(
+      @Param("tagId") Long tagId, @Param("currentVersion") Long 
currentVersion) {
     return "UPDATE "
         + TAG_TABLE_NAME
-        + " tm SET tm.deleted_at = "
+        + " SET deleted_at = "
         + DatabaseTimeSQL.MYSQL
-        + " WHERE tm.metalake_id IN ("
-        + " SELECT mm.metalake_id FROM "
-        + MetalakeMetaMapper.TABLE_NAME
-        + " mm WHERE mm.metalake_name = #{metalakeName} AND mm.deleted_at = 0)"
-        + " AND tm.tag_name = #{tagName} AND tm.deleted_at = 0";
+        + " WHERE tag_id = #{tagId} AND current_version = #{currentVersion}"
+        + " AND deleted_at = 0";
   }
 
   public String softDeleteTagMetasByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
@@ -211,6 +172,7 @@ public class TagMetaBaseSQLProvider {
         + " metalake_id as metalakeId,"
         + " tag_comment as comment,"
         + " properties as properties,"
+        + " allowed_values as allowedValues,"
         + " audit_info as auditInfo,"
         + " current_version as currentVersion,"
         + " last_version as lastVersion,"
@@ -220,11 +182,18 @@ public class TagMetaBaseSQLProvider {
         + " WHERE metalake_id = #{metalakeId} AND tag_name = #{name} and 
deleted_at = 0";
   }
 
+  /** Returns SQL that selects and exclusively locks an active tag by its 
natural key. */
+  public String selectTagMetaByMetalakeIdAndNameForUpdate(
+      @Param("metalakeId") Long metalakeId, @Param("name") String name) {
+    return selectTagMetaByMetalakeIdAndName(metalakeId, name) + " FOR UPDATE";
+  }
+
   public String selectTagByTagId(@Param("tagId") Long tagId) {
     return "SELECT tag_id as tagId, tag_name as tagName,"
         + " metalake_id as metalakeId,"
         + " tag_comment as comment,"
         + " properties as properties,"
+        + " allowed_values as allowedValues,"
         + " audit_info as auditInfo,"
         + " current_version as currentVersion,"
         + " last_version as lastVersion,"
@@ -245,6 +214,7 @@ public class TagMetaBaseSQLProvider {
         + " metalake_id as metalakeId,"
         + " tag_comment as comment,"
         + " properties as properties,"
+        + " allowed_values as allowedValues,"
         + " audit_info as auditInfo,"
         + " current_version as currentVersion,"
         + " last_version as lastVersion,"
@@ -260,6 +230,34 @@ public class TagMetaBaseSQLProvider {
         + "</script>";
   }
 
+  /**
+   * Returns SQL that selects and exclusively locks several active tags, 
ordered by tag ID so that
+   * concurrent callers take the row locks in the same order.
+   */
+  public String listTagPOsByTagIdsForUpdate(@Param("tagIds") List<Long> 
tagIds) {
+    return "<script>"
+        + "SELECT tag_id as tagId, tag_name as tagName,"
+        + " metalake_id as metalakeId,"
+        + " tag_comment as comment,"
+        + " properties as properties,"
+        + " allowed_values as allowedValues,"
+        + " audit_info as auditInfo,"
+        + " current_version as currentVersion,"
+        + " last_version as lastVersion,"
+        + " deleted_at as deletedAt"
+        + " FROM "
+        + TAG_TABLE_NAME
+        + " WHERE deleted_at = 0"
+        + " AND tag_id IN ("
+        + "<foreach collection='tagIds' item='tagId' separator=','>"
+        + "#{tagId}"
+        + "</foreach>"
+        + ")"
+        + " ORDER BY tag_id"
+        + " FOR UPDATE"
+        + "</script>";
+  }
+
   public String batchSelectTagByIdentifier(
       @Param("metalakeName") String metalakeName, @Param("tagNames") 
List<String> tagNames) {
     return "<script>"
@@ -267,6 +265,7 @@ public class TagMetaBaseSQLProvider {
         + " tm.metalake_id as metalakeId,"
         + " tm.tag_comment as comment,"
         + " tm.properties as properties,"
+        + " tm.allowed_values as allowedValues,"
         + " tm.audit_info as auditInfo,"
         + " tm.current_version as currentVersion,"
         + " tm.last_version as lastVersion,"
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetadataObjectRelBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetadataObjectRelBaseSQLProvider.java
index b3b1b8d6e9..100b7f0694 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetadataObjectRelBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/TagMetadataObjectRelBaseSQLProvider.java
@@ -191,6 +191,15 @@ public class TagMetadataObjectRelBaseSQLProvider {
         + " AND tm.tag_name = #{tagName} AND tm.deleted_at = 0) AND 
te.deleted_at = 0";
   }
 
+  /** Returns SQL that soft-deletes every active metadata-object assignment 
for a tag ID. */
+  public String softDeleteTagMetadataObjectRelsByTagId(@Param("tagId") Long 
tagId) {
+    return "UPDATE "
+        + TagMetadataObjectRelMapper.TAG_METADATA_OBJECT_RELATION_TABLE_NAME
+        + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
+        + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
+        + " WHERE tag_id = #{tagId} AND deleted_at = 0";
+  }
+
   public String 
softDeleteTagMetadataObjectRelsByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
     return "UPDATE "
         + TagMetadataObjectRelMapper.TAG_METADATA_OBJECT_RELATION_TABLE_NAME
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TagMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TagMetaPostgreSQLProvider.java
index 422f6aee73..8b10154454 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TagMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TagMetaPostgreSQLProvider.java
@@ -20,7 +20,6 @@ package 
org.apache.gravitino.storage.relational.mapper.provider.postgresql;
 
 import static 
org.apache.gravitino.storage.relational.mapper.TagMetaMapper.TAG_TABLE_NAME;
 
-import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
 import org.apache.gravitino.storage.relational.mapper.provider.DatabaseTimeSQL;
 import 
org.apache.gravitino.storage.relational.mapper.provider.base.TagMetaBaseSQLProvider;
 import org.apache.gravitino.storage.relational.po.TagPO;
@@ -28,16 +27,13 @@ import org.apache.ibatis.annotations.Param;
 
 public class TagMetaPostgreSQLProvider extends TagMetaBaseSQLProvider {
   @Override
-  public String softDeleteTagMetaByMetalakeAndTagName(String metalakeName, 
String tagName) {
+  public String softDeleteTagMetaByIdAndVersion(Long tagId, Long 
currentVersion) {
     return "UPDATE "
         + TAG_TABLE_NAME
-        + " tm SET deleted_at = "
+        + " SET deleted_at = "
         + DatabaseTimeSQL.POSTGRESQL
-        + " WHERE tm.metalake_id IN ("
-        + " SELECT mm.metalake_id FROM "
-        + MetalakeMetaMapper.TABLE_NAME
-        + " mm WHERE mm.metalake_name = #{metalakeName} AND mm.deleted_at = 0)"
-        + " AND tm.tag_name = #{tagName} AND tm.deleted_at = 0";
+        + " WHERE tag_id = #{tagId} AND current_version = #{currentVersion}"
+        + " AND deleted_at = 0";
   }
 
   @Override
@@ -49,37 +45,6 @@ public class TagMetaPostgreSQLProvider extends 
TagMetaBaseSQLProvider {
         + " WHERE metalake_id = #{metalakeId} AND deleted_at = 0";
   }
 
-  @Override
-  public String insertTagMetaOnDuplicateKeyUpdate(TagPO tagPO) {
-    return "INSERT INTO "
-        + TAG_TABLE_NAME
-        + " (tag_id, tag_name,"
-        + " metalake_id, tag_comment, properties, allowed_values, audit_info,"
-        + " current_version, last_version, deleted_at)"
-        + " VALUES ("
-        + " #{tagMeta.tagId},"
-        + " #{tagMeta.tagName},"
-        + " #{tagMeta.metalakeId},"
-        + " #{tagMeta.comment},"
-        + " #{tagMeta.properties},"
-        + " #{tagMeta.allowedValues},"
-        + " #{tagMeta.auditInfo},"
-        + " #{tagMeta.currentVersion},"
-        + " #{tagMeta.lastVersion},"
-        + " #{tagMeta.deletedAt}"
-        + " )"
-        + " ON CONFLICT(tag_id) DO UPDATE SET"
-        + " tag_name = #{tagMeta.tagName},"
-        + " metalake_id = #{tagMeta.metalakeId},"
-        + " tag_comment = #{tagMeta.comment},"
-        + " properties = #{tagMeta.properties},"
-        + " allowed_values = #{tagMeta.allowedValues},"
-        + " audit_info = #{tagMeta.auditInfo},"
-        + " current_version = #{tagMeta.currentVersion},"
-        + " last_version = #{tagMeta.lastVersion},"
-        + " deleted_at = #{tagMeta.deletedAt}";
-  }
-
   @Override
   public String updateTagMeta(
       @Param("newTagMeta") TagPO newTagPO, @Param("oldTagMeta") TagPO 
oldTagPO) {
@@ -94,14 +59,7 @@ public class TagMetaPostgreSQLProvider extends 
TagMetaBaseSQLProvider {
         + " last_version = #{newTagMeta.lastVersion},"
         + " deleted_at = #{newTagMeta.deletedAt}"
         + " WHERE tag_id = #{oldTagMeta.tagId}"
-        + " AND metalake_id = #{oldTagMeta.metalakeId}"
-        + " AND tag_name = #{oldTagMeta.tagName}"
-        + " AND (tag_comment = #{oldTagMeta.comment} "
-        + "   OR (CAST(tag_comment AS VARCHAR) IS NULL AND 
CAST(#{oldTagMeta.comment} AS VARCHAR) IS NULL))"
-        + " AND properties = #{oldTagMeta.properties}"
-        + " AND audit_info = #{oldTagMeta.auditInfo}"
         + " AND current_version = #{oldTagMeta.currentVersion}"
-        + " AND last_version = #{oldTagMeta.lastVersion}"
         + " AND deleted_at = 0";
   }
 
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TagMetadataObjectRelPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TagMetadataObjectRelPostgreSQLProvider.java
index 3fa974c44f..c1292e8dff 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TagMetadataObjectRelPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TagMetadataObjectRelPostgreSQLProvider.java
@@ -39,6 +39,14 @@ import 
org.apache.gravitino.storage.relational.po.TagMetadataObjectRelPO;
 import org.apache.ibatis.annotations.Param;
 
 public class TagMetadataObjectRelPostgreSQLProvider extends 
TagMetadataObjectRelBaseSQLProvider {
+  @Override
+  public String softDeleteTagMetadataObjectRelsByTagId(Long tagId) {
+    return "UPDATE "
+        + TAG_METADATA_OBJECT_RELATION_TABLE_NAME
+        + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
+        + " WHERE tag_id = #{tagId} AND deleted_at = 0";
+  }
+
   @Override
   public String 
batchDeleteTagMetadataObjectRelsByTagIdsAndValuesAndMetadataObject(
       Long metadataObjectId, String metadataObjectType, 
List<TagMetadataObjectRelPO> tagRelPOs) {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/PolicyTagRelService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/PolicyTagRelService.java
index fc011456c2..49ae66a8d9 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/PolicyTagRelService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/PolicyTagRelService.java
@@ -28,7 +28,6 @@ import java.util.Collections;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
@@ -338,21 +337,16 @@ public class PolicyTagRelService {
 
   private static TagPO lockTag(NameIdentifier tagIdentifier) {
     String metalake = tagIdentifier.namespace().level(0);
-    return SessionUtils.getWithoutCommit(
-        TagMetaMapper.class,
-        mapper -> {
-          TagPO observed = mapper.selectTagMetaByMetalakeAndName(metalake, 
tagIdentifier.name());
-          if (observed == null) {
-            throw noSuchEntity(Entity.EntityType.TAG, tagIdentifier.name());
-          }
-          TagPO locked = mapper.selectTagByTagIdForUpdate(observed.getTagId());
-          if (locked == null
-              || !Objects.equals(locked.getTagName(), tagIdentifier.name())
-              || !Objects.equals(locked.getMetalakeId(), 
observed.getMetalakeId())) {
-            throw noSuchEntity(Entity.EntityType.TAG, tagIdentifier.name());
-          }
-          return locked;
-        });
+    TagPO observed =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class,
+            mapper -> mapper.selectTagMetaByMetalakeAndName(metalake, 
tagIdentifier.name()));
+    if (observed == null) {
+      throw noSuchEntity(Entity.EntityType.TAG, tagIdentifier.name());
+    }
+    // The tag row is locked before any policy row, so every path through this 
service takes its
+    // locks in the same tag-then-policy order.
+    return 
TagMetaService.lockTags(Collections.singletonList(observed)).get(observed.getTagId());
   }
 
   private static Map<String, Long> resolvePolicyIds(
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java
index 6726bc8de7..a704a74f11 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TagMetaService.java
@@ -21,9 +21,11 @@ package org.apache.gravitino.storage.relational.service;
 import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATIONAL_STORE_METRIC_NAME;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
 import com.google.common.collect.Lists;
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
@@ -49,9 +51,14 @@ import org.apache.gravitino.json.JsonUtils;
 import org.apache.gravitino.meta.GenericEntity;
 import org.apache.gravitino.meta.TagEntity;
 import org.apache.gravitino.metrics.Monitored;
+import org.apache.gravitino.storage.relational.mapper.MetalakeMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.OwnerMetaMapper;
+import 
org.apache.gravitino.storage.relational.mapper.PolicyMetadataObjectRelMapper;
 import org.apache.gravitino.storage.relational.mapper.PolicyTagRelMapper;
+import org.apache.gravitino.storage.relational.mapper.SecurableObjectMapper;
 import org.apache.gravitino.storage.relational.mapper.TagMetaMapper;
 import 
org.apache.gravitino.storage.relational.mapper.TagMetadataObjectRelMapper;
+import org.apache.gravitino.storage.relational.po.MetalakePO;
 import org.apache.gravitino.storage.relational.po.TagMetadataObjectRelPO;
 import org.apache.gravitino.storage.relational.po.TagPO;
 import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
@@ -100,20 +107,22 @@ public class TagMetaService {
     String metalakeName = ns.level(0);
 
     try {
-      Long metalakeId = 
MetalakeMetaService.getInstance().getMetalakeIdByName(metalakeName);
+      MetalakePO metalakePO =
+          SessionUtils.getWithoutCommit(
+              MetalakeMetaMapper.class, mapper -> 
mapper.selectMetalakeMetaByName(metalakeName));
+      if (metalakePO == null) {
+        throw new NoSuchEntityException(
+            NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+            Entity.EntityType.METALAKE.name().toLowerCase(),
+            metalakeName);
+      }
 
-      TagPO.Builder builder = TagPO.builder().withMetalakeId(metalakeId);
+      TagPO.Builder builder = 
TagPO.builder().withMetalakeId(metalakePO.getMetalakeId());
       TagPO tagPO = POConverters.initializeTagPOWithVersion(tagEntity, 
builder);
 
-      SessionUtils.doWithCommit(
-          TagMetaMapper.class,
-          mapper -> {
-            if (overwritten) {
-              mapper.insertTagMetaOnDuplicateKeyUpdate(tagPO);
-            } else {
-              mapper.insertTagMeta(tagPO);
-            }
-          });
+      SessionUtils.doMultipleWithCommit(
+          () -> lockMetalakeForTagCreate(metalakePO),
+          () -> insertTagWithoutCommit(tagEntity, tagPO, overwritten));
     } catch (RuntimeException e) {
       ExceptionUtils.checkSQLException(e, Entity.EntityType.TAG, 
tagEntity.toString());
       throw e;
@@ -143,7 +152,7 @@ public class TagMetaService {
                       POConverters.updateTagPOWithVersion(tagPO, 
updatedTagEntity), tagPO));
 
       if (result == null || result == 0) {
-        throw new IOException("Failed to update the entity: " + identifier);
+        throw tagWriteFailure(identifier, tagPO);
       }
 
       return updatedTagEntity;
@@ -156,27 +165,13 @@ public class TagMetaService {
 
   @Monitored(metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME, 
baseMetricName = "deleteTag")
   public boolean deleteTag(NameIdentifier identifier) {
-    String metalakeName = identifier.namespace().level(0);
-    int[] tagDeletedCount = new int[] {0};
-    int[] tagMetadataObjectRelDeletedCount = new int[] {0};
-
-    SessionUtils.doMultipleWithCommit(
-        () ->
-            tagDeletedCount[0] =
-                SessionUtils.getWithoutCommit(
-                    TagMetaMapper.class,
-                    mapper ->
-                        mapper.softDeleteTagMetaByMetalakeAndTagName(
-                            metalakeName, identifier.name())),
-        () ->
-            tagMetadataObjectRelDeletedCount[0] =
-                SessionUtils.getWithoutCommit(
-                    TagMetadataObjectRelMapper.class,
-                    mapper ->
-                        
mapper.softDeleteTagMetadataObjectRelsByMetalakeAndTagName(
-                            metalakeName, identifier.name())));
-
-    return tagDeletedCount[0] + tagMetadataObjectRelDeletedCount[0] > 0;
+    TagPO tagPO;
+    try {
+      tagPO = getTagPOByMetalakeAndName(identifier.namespace().level(0), 
identifier.name());
+    } catch (NoSuchEntityException e) {
+      return false;
+    }
+    return deleteTag(identifier, tagPO);
   }
 
   @Monitored(
@@ -342,6 +337,14 @@ public class TagMetaService {
         false /* failOnDuplicateValuelessAssignment */);
   }
 
+  /**
+   * Runs one assignment change in a single transaction, so the tag rows stay 
locked from the moment
+   * they are read until the relation rows are rewritten and read back. A 
conflict rolls the whole
+   * change back instead of leaving a half-applied assignment set behind.
+   *
+   * <p>The mapper handed to the callback is unused: the call is only here to 
open and close the
+   * transaction around work that talks to several mappers.
+   */
   private List<TagEntity> associateTagValuesWithMetadataObject(
       NameIdentifier objectIdent,
       Entity.EntityType objectType,
@@ -349,6 +352,35 @@ public class TagMetaService {
       TagValue[] tagsToRemove,
       boolean failOnDuplicateValuelessAssignment)
       throws NoSuchEntityException, EntityAlreadyExistsException, IOException {
+    try {
+      return SessionUtils.doWithCommitAndFetchResult(
+          TagMetaMapper.class,
+          ignored -> {
+            try {
+              return associateTagValuesWithMetadataObjectWithoutCommit(
+                  objectIdent,
+                  objectType,
+                  tagsToAdd,
+                  tagsToRemove,
+                  failOnDuplicateValuelessAssignment);
+            } catch (IOException e) {
+              // The callback cannot throw a checked exception, so the 
IOException raised while
+              // reading a tag's allowed values is carried across the boundary 
and unwrapped below.
+              throw new UncheckedIOException(e);
+            }
+          });
+    } catch (UncheckedIOException e) {
+      throw e.getCause();
+    }
+  }
+
+  private List<TagEntity> associateTagValuesWithMetadataObjectWithoutCommit(
+      NameIdentifier objectIdent,
+      Entity.EntityType objectType,
+      TagValue[] tagsToAdd,
+      TagValue[] tagsToRemove,
+      boolean failOnDuplicateValuelessAssignment)
+      throws NoSuchEntityException, EntityAlreadyExistsException, IOException {
     MetadataObject metadataObject = 
NameIdentifierUtil.toMetadataObject(objectIdent, objectType);
     String metalake = objectIdent.namespace().level(0);
 
@@ -366,6 +398,7 @@ public class TagMetaService {
           tagNamesToUpdate.isEmpty()
               ? Collections.emptyList()
               : getTagPOsByMetalakeAndNames(metalake, tagNamesToUpdate);
+      tagPOsToUpdate = lockTagsForAssignment(tagPOsToUpdate);
       Map<String, TagPO> tagPOsByName = tagPOsByName(tagPOsToUpdate);
 
       List<TagPO> currentTagPOs =
@@ -451,38 +484,25 @@ public class TagMetaService {
                 tagValueToAdd.value().orElse(null)));
       }
 
-      SessionUtils.doMultipleWithCommit(
-          () -> {
-            if (tagIdsToRemove.isEmpty()) {
-              return;
-            }
-
-            SessionUtils.doWithoutCommit(
-                TagMetadataObjectRelMapper.class,
-                mapper ->
-                    
mapper.batchDeleteTagMetadataObjectRelsByTagIdsAndMetadataObject(
-                        metadataObjectId, metadataObject.type().toString(), 
tagIdsToRemove));
-          },
-          () -> {
-            if (tagRelsToRemove.isEmpty()) {
-              return;
-            }
-
-            SessionUtils.doWithoutCommit(
-                TagMetadataObjectRelMapper.class,
-                mapper ->
-                    
mapper.batchDeleteTagMetadataObjectRelsByTagIdsAndValuesAndMetadataObject(
-                        metadataObjectId, metadataObject.type().toString(), 
tagRelsToRemove));
-          },
-          () -> {
-            if (tagRelsToAdd.isEmpty()) {
-              return;
-            }
-
-            SessionUtils.doWithoutCommit(
-                TagMetadataObjectRelMapper.class,
-                mapper -> 
mapper.batchInsertTagMetadataObjectRels(tagRelsToAdd));
-          });
+      if (!tagIdsToRemove.isEmpty()) {
+        SessionUtils.doWithoutCommit(
+            TagMetadataObjectRelMapper.class,
+            mapper ->
+                
mapper.batchDeleteTagMetadataObjectRelsByTagIdsAndMetadataObject(
+                    metadataObjectId, metadataObject.type().toString(), 
tagIdsToRemove));
+      }
+      if (!tagRelsToRemove.isEmpty()) {
+        SessionUtils.doWithoutCommit(
+            TagMetadataObjectRelMapper.class,
+            mapper ->
+                
mapper.batchDeleteTagMetadataObjectRelsByTagIdsAndValuesAndMetadataObject(
+                    metadataObjectId, metadataObject.type().toString(), 
tagRelsToRemove));
+      }
+      if (!tagRelsToAdd.isEmpty()) {
+        SessionUtils.doWithoutCommit(
+            TagMetadataObjectRelMapper.class,
+            mapper -> mapper.batchInsertTagMetadataObjectRels(tagRelsToAdd));
+      }
 
       List<TagPO> tagPOs =
           SessionUtils.getWithoutCommit(
@@ -527,6 +547,61 @@ public class TagMetaService {
     return tagDeletedCount[0] + tagMetadataObjectRelDeletedCount[0] + 
policyTagRelDeletedCount[0];
   }
 
+  /**
+   * Deletes the tag the caller observed. The delete is a compare-and-set on 
the observed version,
+   * so a tag that changed since it was read is rejected instead of being 
removed, and the dependent
+   * rows are cleaned up in the same transaction as the tag row itself. If 
another transaction
+   * deletes or renames the observed tag first, this method returns {@code 
false}, preserving the
+   * idempotent delete contract.
+   *
+   * <p>The observed row is a parameter so that a test can hand in a stale 
one; production callers
+   * use {@link #deleteTag(NameIdentifier)}, which reads it first.
+   */
+  @VisibleForTesting
+  boolean deleteTag(NameIdentifier identifier, TagPO tagPO) {
+    long tagId = tagPO.getTagId();
+
+    // The version-checked delete of the tag row must stay first: it is what 
decides whether this
+    // delete wins, and a losing delete throws there and rolls the transaction 
back before any
+    // dependent row is touched. The cleanups that follow are blanket soft 
deletes for this tag ID,
+    // so their row counts carry no information -- a tag with no assignments, 
no policies, no owner
+    // and no securable object legitimately clears zero rows -- and only the 
delete above is
+    // checked.
+    try {
+      SessionUtils.doMultipleWithCommit(
+          () -> deleteTagWithVersion(identifier, tagPO),
+          () ->
+              SessionUtils.doWithoutCommit(
+                  TagMetadataObjectRelMapper.class,
+                  mapper -> 
mapper.softDeleteTagMetadataObjectRelsByTagId(tagId)),
+          () ->
+              SessionUtils.doWithoutCommit(
+                  PolicyTagRelMapper.class, mapper -> 
mapper.softDeleteByTagId(tagId)),
+          () ->
+              SessionUtils.doWithoutCommit(
+                  PolicyMetadataObjectRelMapper.class,
+                  mapper ->
+                      
mapper.softDeletePolicyMetadataObjectRelsByMetadataObject(
+                          tagId, MetadataObject.Type.TAG.name())),
+          () ->
+              SessionUtils.doWithoutCommit(
+                  OwnerMetaMapper.class,
+                  mapper ->
+                      mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
+                          tagId, MetadataObject.Type.TAG.name())),
+          () ->
+              SessionUtils.doWithoutCommit(
+                  SecurableObjectMapper.class,
+                  mapper ->
+                      mapper.softDeleteObjectRelsByMetadataObject(
+                          tagId, MetadataObject.Type.TAG.name())));
+    } catch (NoSuchEntityException e) {
+      return false;
+    }
+
+    return true;
+  }
+
   private static List<TagEntity> tagPOsToTagEntities(List<TagPO> tagPOs, 
Namespace namespace) {
     Map<Long, List<TagPO>> tagPOsByTagId = new LinkedHashMap<>();
     for (TagPO tagPO : tagPOs) {
@@ -625,6 +700,171 @@ public class TagMetaService {
         Arrays.toString(allowedValues));
   }
 
+  /**
+   * Holds the parent metalake row for the rest of the transaction, so a tag 
cannot be created under
+   * a metalake that is going away.
+   *
+   * <p>The lock is shared, not exclusive: many tags can be created under the 
same metalake at the
+   * same time, while dropping the metalake takes an exclusive lock on this 
row, so a drop and a
+   * create cannot overlap.
+   *
+   * <p>The name is compared again because the ID alone cannot tell a rename 
apart: the caller
+   * looked the metalake up by name, so a renamed row means the name in the 
request no longer
+   * exists. The metalake version is deliberately not compared, matching 
{@code CatalogMetaService}:
+   * holding the row is what makes the create safe, and an unrelated metalake 
edit that commits in
+   * between would otherwise reject the create for no reason.
+   */
+  private void lockMetalakeForTagCreate(MetalakePO observedMetalakePO) {
+    OccWriteSupport.lockParentForChildWrite(
+        observedMetalakePO.getMetalakeName(),
+        Entity.EntityType.METALAKE,
+        () ->
+            SessionUtils.getWithoutCommit(
+                MetalakeMetaMapper.class,
+                mapper ->
+                    
mapper.selectMetalakeMetaByIdForShare(observedMetalakePO.getMetalakeId())),
+        null,
+        current -> Objects.equals(current.getMetalakeName(), 
observedMetalakePO.getMetalakeName()));
+  }
+
+  /**
+   * Writes a new tag or replaces the active tag selected by name or stable ID.
+   *
+   * <p>The overwrite path uses locking reads instead of a database-specific 
upsert. This keeps the
+   * stored tag ID and OCC sequence stable across all databases. A 
name-targeted overwrite that
+   * races with a rename creates a new row under the now-free name instead of 
reverting the renamed
+   * row.
+   */
+  private void insertTagWithoutCommit(
+      TagEntity tagEntity, TagPO initializedTagPO, boolean overwritten) {
+    if (!overwritten) {
+      insertNewTagWithoutCommit(initializedTagPO);
+      return;
+    }
+
+    TagPO existingTagPO = findAndLockTagForOverwrite(initializedTagPO);
+    if (existingTagPO == null) {
+      insertNewTagWithoutCommit(initializedTagPO);
+      return;
+    }
+
+    TagPO replacementTagPO = 
POConverters.updateTagPOWithVersion(existingTagPO, tagEntity);
+    NameIdentifier observedIdentifier =
+        NameIdentifier.of(tagEntity.namespace(), existingTagPO.getTagName());
+    updateTagRootWithVersion(observedIdentifier, existingTagPO, 
replacementTagPO);
+  }
+
+  private void insertNewTagWithoutCommit(TagPO tagPO) {
+    SessionUtils.doWithoutCommit(TagMetaMapper.class, mapper -> 
mapper.insertTagMeta(tagPO));
+  }
+
+  private TagPO findAndLockTagForOverwrite(TagPO initializedTagPO) {
+    TagPO sameNameTagPO =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class,
+            mapper ->
+                mapper.selectTagMetaByMetalakeIdAndNameForUpdate(
+                    initializedTagPO.getMetalakeId(), 
initializedTagPO.getTagName()));
+    if (sameNameTagPO != null) {
+      return sameNameTagPO;
+    }
+
+    TagPO sameIdTagPO =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class,
+            mapper -> 
mapper.selectTagByTagIdForUpdate(initializedTagPO.getTagId()));
+    if (sameIdTagPO == null
+        || !Objects.equals(sameIdTagPO.getMetalakeId(), 
initializedTagPO.getMetalakeId())) {
+      return null;
+    }
+    return sameIdTagPO;
+  }
+
+  private void updateTagRootWithVersion(NameIdentifier identifier, TagPO 
oldTagPO, TagPO newTagPO) {
+    Integer updated =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class, mapper -> mapper.updateTagMeta(newTagPO, 
oldTagPO));
+    if (updated == null || updated == 0) {
+      throw tagWriteFailure(identifier, oldTagPO);
+    }
+  }
+
+  private void deleteTagWithVersion(NameIdentifier identifier, TagPO 
observedTagPO) {
+    OccWriteSupport.deleteWithVersion(
+        () ->
+            SessionUtils.getWithoutCommit(
+                TagMetaMapper.class,
+                mapper ->
+                    mapper.softDeleteTagMetaByIdAndVersion(
+                        observedTagPO.getTagId(), 
observedTagPO.getCurrentVersion())),
+        () -> tagWriteFailure(identifier, observedTagPO));
+  }
+
+  private RuntimeException tagWriteFailure(NameIdentifier identifier, TagPO 
observedTagPO) {
+    return OccWriteSupport.writeFailure(
+        identifier,
+        Entity.EntityType.TAG,
+        () ->
+            SessionUtils.getWithoutCommit(
+                TagMetaMapper.class,
+                mapper -> 
mapper.selectTagByTagIdForUpdate(observedTagPO.getTagId())),
+        null,
+        current ->
+            Objects.equals(current.getTagName(), observedTagPO.getTagName())
+                && Objects.equals(current.getMetalakeId(), 
observedTagPO.getMetalakeId()));
+  }
+
+  /**
+   * Locks every tag taking part in an assignment change and returns the rows 
as they are now, so
+   * the assignment cannot be written against a tag that is being renamed or 
dropped.
+   *
+   * <p>See {@link #lockTags} for how the rows are locked and what counts as a 
missing tag.
+   */
+  private List<TagPO> lockTagsForAssignment(List<TagPO> observedTagPOs) {
+    return new ArrayList<>(lockTags(observedTagPOs).values());
+  }
+
+  /**
+   * Locks the given tag rows and returns them as they are now, keyed by tag 
ID.
+   *
+   * <p>The rows are locked by one statement that orders them by tag ID, so 
callers that touch
+   * overlapping tags take the row locks in the same order and queue up 
instead of deadlocking, and
+   * a change touching many tags still costs a single round trip.
+   *
+   * <p>A row that is gone, or whose name or metalake no longer matches what 
the caller resolved by
+   * name, is reported as missing: the caller asked for a tag name, and that 
name no longer points
+   * at this row.
+   */
+  static Map<Long, TagPO> lockTags(List<TagPO> observedTagPOs) {
+    Map<Long, TagPO> observedById = new LinkedHashMap<>();
+    observedTagPOs.forEach(tagPO -> observedById.put(tagPO.getTagId(), tagPO));
+    if (observedById.isEmpty()) {
+      return new LinkedHashMap<>();
+    }
+
+    List<TagPO> lockedRows =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class,
+            mapper -> mapper.listTagPOsByTagIdsForUpdate(new 
ArrayList<>(observedById.keySet())));
+    Map<Long, TagPO> lockedById = new LinkedHashMap<>();
+    lockedRows.forEach(tagPO -> lockedById.put(tagPO.getTagId(), tagPO));
+
+    Map<Long, TagPO> lockedTagPOs = new LinkedHashMap<>();
+    for (TagPO observedTagPO : observedById.values()) {
+      TagPO lockedTagPO =
+          OccWriteSupport.lockParentForChildWrite(
+              observedTagPO.getTagName(),
+              Entity.EntityType.TAG,
+              () -> lockedById.get(observedTagPO.getTagId()),
+              null,
+              current ->
+                  Objects.equals(current.getTagName(), 
observedTagPO.getTagName())
+                      && Objects.equals(current.getMetalakeId(), 
observedTagPO.getMetalakeId()));
+      lockedTagPOs.put(lockedTagPO.getTagId(), lockedTagPO);
+    }
+    return lockedTagPOs;
+  }
+
   private TagPO getTagPOByMetalakeAndName(String metalakeName, String tagName) 
{
     TagPO tagPO =
         SessionUtils.getWithoutCommit(
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 51f21421cf..07f98776c8 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
@@ -1423,10 +1423,7 @@ public class POConverters {
   }
 
   public static TagPO updateTagPOWithVersion(TagPO oldTagPO, TagEntity 
newEntity) {
-    Long lastVersion = oldTagPO.getLastVersion();
-    // TODO: set the version to the last version + 1 when having some fields 
need be multiple
-    // version
-    Long nextVersion = lastVersion;
+    Long nextVersion = oldTagPO.getCurrentVersion() + 1;
     try {
       return TagPO.builder()
           .withTagId(oldTagPO.getTagId())
diff --git a/core/src/main/java/org/apache/gravitino/tag/TagManager.java 
b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
index 27ccd5a518..69bc1c4d62 100644
--- a/core/src/main/java/org/apache/gravitino/tag/TagManager.java
+++ b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
@@ -46,6 +46,7 @@ import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
 import org.apache.gravitino.exceptions.NoSuchTagException;
 import org.apache.gravitino.exceptions.NotFoundException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.exceptions.PolicyAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.TagAlreadyAssociatedException;
 import org.apache.gravitino.exceptions.TagAlreadyExistsException;
@@ -192,6 +193,16 @@ public class TagManager implements TagDispatcher {
                     .orElse(name);
             throw new TagAlreadyExistsException(
                 e, "Tag with name %s under metalake %s already exists", 
newName, metalake);
+          } catch (OptimisticLockException ole) {
+            // The store now rejects a stale alter with this exception instead 
of an IOException,
+            // and the REST layer maps it to a conflict. Log it here so the 
operator-facing record
+            // still names the tag and the metalake.
+            LOG.warn(
+                "Failed to alter tag {} under metalake {} because it changed 
concurrently",
+                name,
+                metalake,
+                ole);
+            throw ole;
           } catch (IOException ioe) {
             LOG.error("Failed to alter tag {} under metalake {}", name, 
metalake, ioe);
             throw new RuntimeException(ioe);
@@ -208,6 +219,13 @@ public class TagManager implements TagDispatcher {
           try {
             return entityStore.delete(
                 NameIdentifierUtil.ofTag(metalake, name), 
Entity.EntityType.TAG);
+          } catch (OptimisticLockException ole) {
+            LOG.warn(
+                "Failed to delete tag {} under metalake {} because it changed 
concurrently",
+                name,
+                metalake,
+                ole);
+            throw ole;
           } catch (IOException ioe) {
             LOG.error("Failed to delete tag {} under metalake {}", name, 
metalake, ioe);
             throw new RuntimeException(ioe);
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTagMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTagMetaService.java
index f263a68e12..b971030a85 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTagMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTagMetaService.java
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import java.io.IOException;
 import java.sql.Connection;
@@ -34,30 +35,52 @@ import java.util.Arrays;
 import java.util.LinkedHashSet;
 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 org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.RelationEdgeTarget;
 import org.apache.gravitino.RelationQuery;
 import org.apache.gravitino.RelationUpdate;
 import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.authorization.AuthorizationUtils;
+import org.apache.gravitino.authorization.Privileges;
+import org.apache.gravitino.authorization.SecurableObjects;
 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;
 import org.apache.gravitino.meta.FilesetEntity;
 import org.apache.gravitino.meta.GenericEntity;
 import org.apache.gravitino.meta.ModelEntity;
+import org.apache.gravitino.meta.PolicyEntity;
+import org.apache.gravitino.meta.RoleEntity;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.meta.TagEntity;
 import org.apache.gravitino.meta.TopicEntity;
+import org.apache.gravitino.meta.UserEntity;
+import org.apache.gravitino.policy.PolicyContents;
 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.MetalakeMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.TagMetaMapper;
+import org.apache.gravitino.storage.relational.po.MetalakePO;
+import org.apache.gravitino.storage.relational.po.TagPO;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.gravitino.storage.relational.utils.POConverters;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.apache.gravitino.tag.TagValue;
+import org.apache.gravitino.tag.TagValueConstraint;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
 import org.apache.ibatis.session.SqlSession;
@@ -321,6 +344,526 @@ public class TestTagMetaService extends TestJDBCBackend {
     Assertions.assertEquals(tagEntity2, loadedTagEntity1);
   }
 
+  @TestTemplate
+  public void testTagAlterDeleteAndOverwriteUseMonotonicVersion() throws 
IOException {
+    createAndInsertMakeLake(METALAKE_NAME);
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_occ")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(tag, false);
+
+    TagPO initialPO =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class, mapper -> mapper.selectTagByTagId(tag.id()));
+    TagEntity replacement =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName(tag.name())
+            .withNamespace(tag.namespace())
+            .withComment("overwritten")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(replacement, true);
+    TagPO overwrittenPO =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class, mapper -> mapper.selectTagByTagId(tag.id()));
+    Assertions.assertEquals(tag.id(), overwrittenPO.getTagId().longValue());
+    Assertions.assertEquals("overwritten", overwrittenPO.getComment());
+    Assertions.assertEquals(
+        initialPO.getCurrentVersion() + 1, 
overwrittenPO.getCurrentVersion().longValue());
+    Assertions.assertEquals(overwrittenPO.getCurrentVersion(), 
overwrittenPO.getLastVersion());
+
+    TagEntity updatedTag = copyTagWithComment(tag, "updated");
+    TagPO nextPO = POConverters.updateTagPOWithVersion(overwrittenPO, 
updatedTag);
+    Assertions.assertEquals(
+        Integer.valueOf(1),
+        SessionUtils.doWithCommitAndFetchResult(
+            TagMetaMapper.class, mapper -> mapper.updateTagMeta(nextPO, 
overwrittenPO)));
+    Assertions.assertEquals(
+        Integer.valueOf(0),
+        SessionUtils.doWithCommitAndFetchResult(
+            TagMetaMapper.class, mapper -> mapper.updateTagMeta(nextPO, 
overwrittenPO)));
+    Assertions.assertEquals(
+        Integer.valueOf(0),
+        SessionUtils.doWithCommitAndFetchResult(
+            TagMetaMapper.class,
+            mapper ->
+                mapper.softDeleteTagMetaByIdAndVersion(
+                    tag.id(), overwrittenPO.getCurrentVersion())));
+    Assertions.assertEquals(
+        Integer.valueOf(1),
+        SessionUtils.doWithCommitAndFetchResult(
+            TagMetaMapper.class,
+            mapper ->
+                mapper.softDeleteTagMetaByIdAndVersion(tag.id(), 
nextPO.getCurrentVersion())));
+  }
+
+  @TestTemplate
+  public void testTagOverwriteByNameDoesNotRevertConcurrentRename() throws 
Exception {
+    createAndInsertMakeLake(METALAKE_NAME);
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity original =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_overwrite_rename_race")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(original, false);
+    TagPO observedPO = getTagPO(original.nameIdentifier());
+    TagEntity renamed = copyTag(original, "tag_overwrite_rename_winner", 
"rename winner");
+    TagPO renamedPO = POConverters.updateTagPOWithVersion(observedPO, renamed);
+    TagEntity replacement =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName(original.name())
+            .withNamespace(original.namespace())
+            .withComment("replacement")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+
+    CountDownLatch renameWritten = new CountDownLatch(1);
+    CountDownLatch allowRenameCommit = new CountDownLatch(1);
+    CountDownLatch overwriteStarted = new CountDownLatch(1);
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    Future<Throwable> renameResult =
+        executor.submit(
+            () -> {
+              try {
+                SessionUtils.doMultipleWithCommit(
+                    () ->
+                        Assertions.assertEquals(
+                            Integer.valueOf(1),
+                            SessionUtils.getWithoutCommit(
+                                TagMetaMapper.class,
+                                mapper -> mapper.updateTagMeta(renamedPO, 
observedPO))),
+                    () -> {
+                      renameWritten.countDown();
+                      await(allowRenameCommit);
+                    });
+                return null;
+              } catch (Throwable throwable) {
+                return throwable;
+              }
+            });
+
+    try {
+      assertTrue(renameWritten.await(30, TimeUnit.SECONDS));
+      Future<Throwable> overwriteResult =
+          executor.submit(
+              () -> {
+                overwriteStarted.countDown();
+                try {
+                  tagMetaService.insertTag(replacement, true);
+                  return null;
+                } catch (Throwable throwable) {
+                  return throwable;
+                }
+              });
+      assertTrue(overwriteStarted.await(30, TimeUnit.SECONDS));
+      Assertions.assertThrows(
+          TimeoutException.class, () -> overwriteResult.get(500, 
TimeUnit.MILLISECONDS));
+
+      allowRenameCommit.countDown();
+      Assertions.assertNull(renameResult.get(30, TimeUnit.SECONDS));
+      Assertions.assertNull(overwriteResult.get(30, TimeUnit.SECONDS));
+    } finally {
+      allowRenameCommit.countDown();
+      executor.shutdownNow();
+    }
+
+    Assertions.assertEquals(
+        original.id(), 
tagMetaService.getTagByIdentifier(renamed.nameIdentifier()).id());
+    Assertions.assertEquals(
+        replacement.id(), 
tagMetaService.getTagByIdentifier(replacement.nameIdentifier()).id());
+  }
+
+  @TestTemplate
+  public void testTagCreateWaitsForConcurrentParentDelete() throws Exception {
+    BaseMetalake metalake = createAndInsertMakeLake(METALAKE_NAME);
+    MetalakePO observedMetalakePO =
+        SessionUtils.getWithoutCommit(
+            MetalakeMetaMapper.class, mapper -> 
mapper.selectMetalakeMetaByName(metalake.name()));
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_parent_delete_race")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+
+    CountDownLatch deleteWritten = new CountDownLatch(1);
+    CountDownLatch allowDeleteCommit = new CountDownLatch(1);
+    CountDownLatch createStarted = new CountDownLatch(1);
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    Future<Throwable> deleteResult =
+        executor.submit(
+            () -> {
+              try {
+                SessionUtils.doMultipleWithCommit(
+                    () ->
+                        Assertions.assertEquals(
+                            Integer.valueOf(1),
+                            SessionUtils.getWithoutCommit(
+                                MetalakeMetaMapper.class,
+                                mapper ->
+                                    mapper.softDeleteMetalakeMetaByMetalakeId(
+                                        observedMetalakePO.getMetalakeId(),
+                                        
observedMetalakePO.getCurrentVersion()))),
+                    () -> {
+                      deleteWritten.countDown();
+                      await(allowDeleteCommit);
+                    });
+                return null;
+              } catch (Throwable throwable) {
+                return throwable;
+              }
+            });
+
+    try {
+      assertTrue(deleteWritten.await(30, TimeUnit.SECONDS));
+      Future<Throwable> createResult =
+          executor.submit(
+              () -> {
+                createStarted.countDown();
+                try {
+                  TagMetaService.getInstance().insertTag(tag, false);
+                  return null;
+                } catch (Throwable throwable) {
+                  return throwable;
+                }
+              });
+      assertTrue(createStarted.await(30, TimeUnit.SECONDS));
+      Assertions.assertThrows(
+          TimeoutException.class, () -> createResult.get(500, 
TimeUnit.MILLISECONDS));
+
+      allowDeleteCommit.countDown();
+      Assertions.assertNull(deleteResult.get(30, TimeUnit.SECONDS));
+      Throwable createFailure = createResult.get(30, TimeUnit.SECONDS);
+      Assertions.assertTrue(
+          createFailure instanceof NoSuchEntityException, 
String.valueOf(createFailure));
+    } finally {
+      allowDeleteCommit.countDown();
+      executor.shutdownNow();
+    }
+
+    assertFalse(backend.exists(tag.nameIdentifier(), Entity.EntityType.TAG));
+    Assertions.assertNull(
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class, mapper -> mapper.selectTagByTagId(tag.id())));
+  }
+
+  @TestTemplate
+  public void testTagAssignmentWaitsForConcurrentRenameAndRollsBack() throws 
Exception {
+    createAndInsertMakeLake(METALAKE_NAME);
+    CatalogEntity catalog = createAndInsertCatalog(METALAKE_NAME, 
"catalog_tag_rename_race");
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_assignment_rename_race")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(tag, false);
+    TagPO observedPO = getTagPO(tag.nameIdentifier());
+    TagEntity renamed = copyTag(tag, "tag_assignment_rename_winner", 
"renamed");
+    TagPO renamedPO = POConverters.updateTagPOWithVersion(observedPO, renamed);
+
+    CountDownLatch renameWritten = new CountDownLatch(1);
+    CountDownLatch allowRenameCommit = new CountDownLatch(1);
+    CountDownLatch assignmentStarted = new CountDownLatch(1);
+    ExecutorService executor = Executors.newFixedThreadPool(2);
+    Future<Throwable> renameResult =
+        executor.submit(
+            () -> {
+              try {
+                SessionUtils.doMultipleWithCommit(
+                    () ->
+                        Assertions.assertEquals(
+                            Integer.valueOf(1),
+                            SessionUtils.getWithoutCommit(
+                                TagMetaMapper.class,
+                                mapper -> mapper.updateTagMeta(renamedPO, 
observedPO))),
+                    () -> {
+                      renameWritten.countDown();
+                      await(allowRenameCommit);
+                    });
+                return null;
+              } catch (Throwable throwable) {
+                return throwable;
+              }
+            });
+
+    try {
+      assertTrue(renameWritten.await(30, TimeUnit.SECONDS));
+      Future<Throwable> assignmentResult =
+          executor.submit(
+              () -> {
+                assignmentStarted.countDown();
+                try {
+                  tagMetaService.associateTagsWithMetadataObject(
+                      catalog.nameIdentifier(),
+                      catalog.type(),
+                      new NameIdentifier[] {tag.nameIdentifier()},
+                      new NameIdentifier[0]);
+                  return null;
+                } catch (Throwable throwable) {
+                  return throwable;
+                }
+              });
+      assertTrue(assignmentStarted.await(30, TimeUnit.SECONDS));
+      Assertions.assertThrows(
+          TimeoutException.class, () -> assignmentResult.get(500, 
TimeUnit.MILLISECONDS));
+
+      allowRenameCommit.countDown();
+      Assertions.assertNull(renameResult.get(30, TimeUnit.SECONDS));
+      Throwable assignmentFailure = assignmentResult.get(30, TimeUnit.SECONDS);
+      Assertions.assertTrue(
+          assignmentFailure instanceof NoSuchEntityException, 
String.valueOf(assignmentFailure));
+    } finally {
+      allowRenameCommit.countDown();
+      executor.shutdownNow();
+    }
+
+    assertEquals(0, countActiveTagRel(tag.id()));
+    Assertions.assertEquals(
+        tag.id(), 
tagMetaService.getTagByIdentifier(renamed.nameIdentifier()).id());
+  }
+
+  @TestTemplate
+  public void testTagAlterReportsOptimisticLockConflict() throws IOException {
+    createAndInsertMakeLake(METALAKE_NAME);
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_alter_conflict")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(tag, false);
+
+    Assertions.assertThrows(
+        OptimisticLockException.class,
+        () ->
+            tagMetaService.updateTag(
+                tag.nameIdentifier(),
+                entity -> {
+                  TagEntity current = (TagEntity) entity;
+                  TagPO currentPO =
+                      SessionUtils.getWithoutCommit(
+                          TagMetaMapper.class, mapper -> 
mapper.selectTagByTagId(current.id()));
+                  TagPO competingPO =
+                      POConverters.updateTagPOWithVersion(
+                          currentPO, copyTagWithComment(current, "competing"));
+                  SessionUtils.doWithCommitAndFetchResult(
+                      TagMetaMapper.class, mapper -> 
mapper.updateTagMeta(competingPO, currentPO));
+                  return copyTagWithComment(current, "requested");
+                }));
+  }
+
+  @TestTemplate
+  public void testStaleTagDeleteRollsBackRelationshipCleanup() throws 
IOException {
+    createAndInsertMakeLake(METALAKE_NAME);
+    CatalogEntity catalog = createAndInsertCatalog(METALAKE_NAME, 
"catalog_tag_delete_occ");
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_delete_occ")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(tag, false);
+    tagMetaService.associateTagsWithMetadataObject(
+        catalog.nameIdentifier(),
+        catalog.type(),
+        new NameIdentifier[] {tag.nameIdentifier()},
+        new NameIdentifier[0]);
+    TagPO stalePO =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class, mapper -> mapper.selectTagByTagId(tag.id()));
+    tagMetaService.updateTag(
+        tag.nameIdentifier(), entity -> copyTagWithComment((TagEntity) entity, 
"updated"));
+
+    Assertions.assertThrows(
+        OptimisticLockException.class,
+        () -> tagMetaService.deleteTag(tag.nameIdentifier(), stalePO));
+    Assertions.assertEquals(1, countActiveTagRel(tag.id()));
+    Assertions.assertTrue(backend.exists(tag.nameIdentifier(), 
Entity.EntityType.TAG));
+
+    Assertions.assertTrue(tagMetaService.deleteTag(tag.nameIdentifier()));
+    Assertions.assertEquals(0, countActiveTagRel(tag.id()));
+  }
+
+  @TestTemplate
+  public void testTagCreateIsFencedByParentMetalake() {
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_without_metalake")
+            .withNamespace(NamespaceUtil.ofTag("metalake_that_does_not_exist"))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+
+    Assertions.assertThrows(
+        NoSuchEntityException.class, () -> tagMetaService.insertTag(tag, 
false));
+    Assertions.assertThrows(NoSuchEntityException.class, () -> 
tagMetaService.insertTag(tag, true));
+  }
+
+  @TestTemplate
+  public void testTagOverwriteAndAlterKeepAllowedValues() throws IOException {
+    createAndInsertMakeLake(METALAKE_NAME);
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_allowed_values_occ")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAllowedValues(new String[] {"dev", "prod"})
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(tag, false);
+
+    tagMetaService.insertTag(copyTagWithComment(tag, "overwritten"), true);
+    TagEntity overwritten = 
tagMetaService.getTagByIdentifier(tag.nameIdentifier());
+    Assertions.assertArrayEquals(
+        new String[] {"dev", "prod"}, 
overwritten.valueConstraint().allowedValues());
+
+    tagMetaService.updateTag(
+        tag.nameIdentifier(), entity -> copyTagWithComment((TagEntity) entity, 
"updated"));
+    TagEntity updated = 
tagMetaService.getTagByIdentifier(tag.nameIdentifier());
+    Assertions.assertEquals("updated", updated.comment());
+    Assertions.assertArrayEquals(
+        new String[] {"dev", "prod"}, 
updated.valueConstraint().allowedValues());
+  }
+
+  @TestTemplate
+  public void testDeleteTagCleansEveryDependentRelation() throws IOException {
+    createAndInsertMakeLake(METALAKE_NAME);
+    CatalogEntity catalog = createAndInsertCatalog(METALAKE_NAME, 
"catalog_tag_cascade");
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_cascade_occ")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(tag, false);
+    tagMetaService.associateTagsWithMetadataObject(
+        catalog.nameIdentifier(),
+        catalog.type(),
+        new NameIdentifier[] {tag.nameIdentifier()},
+        new NameIdentifier[0]);
+
+    PolicyEntity policy =
+        createAndInsertPolicyEntity(
+            "policy_tag_cascade",
+            "policy comment",
+            PolicyContents.custom(
+                ImmutableMap.of("k", "v"), 
ImmutableSet.of(MetadataObject.Type.TAG), null),
+            METALAKE_NAME);
+    backend.updateEntityRelations(
+        RelationUpdate.of(
+            SupportsRelationOperations.Type.POLICY_TAG_REL,
+            tag.nameIdentifier(),
+            Entity.EntityType.TAG,
+            new RelationEdgeTarget[] {
+              RelationEdgeTarget.of(
+                  policy.nameIdentifier(),
+                  Entity.EntityType.POLICY,
+                  "{\"type\":\"TAG_VALUE\",\"value\":\"finance\"}")
+            },
+            new RelationEdgeTarget[0]));
+    PolicyMetaService.getInstance()
+        .associatePoliciesWithMetadataObject(
+            tag.nameIdentifier(),
+            Entity.EntityType.TAG,
+            new NameIdentifier[] {policy.nameIdentifier()},
+            new NameIdentifier[0]);
+
+    UserEntity user =
+        createUserEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofUserNamespace(METALAKE_NAME),
+            "user_tag_cascade",
+            AUDIT_INFO);
+    backend.insert(user, false);
+    OwnerMetaService.getInstance()
+        .setOwner(tag.nameIdentifier(), Entity.EntityType.TAG, 
user.nameIdentifier(), user.type());
+
+    RoleEntity role =
+        createRoleEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            AuthorizationUtils.ofRoleNamespace(METALAKE_NAME),
+            "role_tag_cascade",
+            AUDIT_INFO,
+            Lists.newArrayList(
+                SecurableObjects.ofTag(
+                    tag.name(), 
Lists.newArrayList(Privileges.ApplyTag.allow()))),
+            null);
+    backend.insert(role, false);
+
+    String tagAsMetadataObject =
+        String.format("metadata_object_id = %d AND metadata_object_type = 
'TAG'", tag.id());
+    String tagAsSecurableObject =
+        String.format("metadata_object_id = %d AND type = 'TAG'", tag.id());
+    assertEquals(1, countActiveTagRel(tag.id()));
+    assertEquals(1, countActiveRows("policy_tag_relation_meta", "tag_id = " + 
tag.id()));
+    assertEquals(1, countActiveRows("policy_relation_meta", 
tagAsMetadataObject));
+    assertEquals(1, countActiveRows("owner_meta", tagAsMetadataObject));
+    assertEquals(1, countActiveRows("role_meta_securable_object", 
tagAsSecurableObject));
+
+    assertTrue(tagMetaService.deleteTag(tag.nameIdentifier()));
+
+    assertEquals(0, countActiveTagRel(tag.id()));
+    assertEquals(0, countActiveRows("policy_tag_relation_meta", "tag_id = " + 
tag.id()));
+    assertEquals(0, countActiveRows("policy_relation_meta", 
tagAsMetadataObject));
+    assertEquals(0, countActiveRows("owner_meta", tagAsMetadataObject));
+    assertEquals(0, countActiveRows("role_meta_securable_object", 
tagAsSecurableObject));
+  }
+
+  @TestTemplate
+  public void testDeleteOfAlreadyDeletedTagReportsMissingTagNotConflict() 
throws IOException {
+    createAndInsertMakeLake(METALAKE_NAME);
+    TagMetaService tagMetaService = TagMetaService.getInstance();
+    TagEntity tag =
+        TagEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("tag_gone_occ")
+            .withNamespace(NamespaceUtil.ofTag(METALAKE_NAME))
+            .withComment("initial")
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    tagMetaService.insertTag(tag, false);
+    TagPO observedPO =
+        SessionUtils.getWithoutCommit(
+            TagMetaMapper.class, mapper -> mapper.selectTagByTagId(tag.id()));
+    Assertions.assertTrue(tagMetaService.deleteTag(tag.nameIdentifier()));
+
+    // The row the caller observed is gone rather than merely moved on, so the 
idempotent delete
+    // contract reports false instead of turning a concurrent delete into an 
exception.
+    Assertions.assertFalse(tagMetaService.deleteTag(tag.nameIdentifier(), 
observedPO));
+    Assertions.assertFalse(tagMetaService.deleteTag(tag.nameIdentifier()));
+  }
+
   @TestTemplate
   public void testDeleteTag() throws IOException {
     createAndInsertMakeLake(METALAKE_NAME);
@@ -1284,6 +1827,42 @@ public class TestTagMetaService extends TestJDBCBackend {
         () -> tagMetaService.getTagIdByTagName(metalakeId, "missing_tag"));
   }
 
+  private TagEntity copyTagWithComment(TagEntity tag, String comment) {
+    return copyTag(tag, tag.name(), comment);
+  }
+
+  private TagEntity copyTag(TagEntity tag, String name, String comment) {
+    TagEntity.Builder builder =
+        TagEntity.builder()
+            .withId(tag.id())
+            .withName(name)
+            .withNamespace(tag.namespace())
+            .withComment(comment)
+            .withProperties(tag.properties())
+            .withAuditInfo(tag.auditInfo());
+    if (tag.valueConstraint().type() != TagValueConstraint.Type.ANY_VALUE) {
+      builder.withAllowedValues(tag.valueConstraint().allowedValues());
+    }
+    return builder.build();
+  }
+
+  private TagPO getTagPO(NameIdentifier identifier) {
+    return SessionUtils.getWithoutCommit(
+        TagMetaMapper.class,
+        mapper ->
+            mapper.selectTagMetaByMetalakeAndName(
+                identifier.namespace().level(0), identifier.name()));
+  }
+
+  private void await(CountDownLatch latch) {
+    try {
+      assertTrue(latch.await(30, TimeUnit.SECONDS));
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      throw new RuntimeException(e);
+    }
+  }
+
   private boolean containsValuelessTagAssignment(
       List<TagEntity> tagEntities, TagEntity expectedTagEntity) {
     return tagEntities.stream()
@@ -1307,6 +1886,24 @@ public class TestTagMetaService extends TestJDBCBackend {
     return genericEntities.stream().anyMatch(e -> e.name().equals(name) && 
e.type() == entityType);
   }
 
+  private int countActiveRows(String table, String whereClause) {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement();
+        ResultSet rs =
+            statement.executeQuery(
+                String.format(
+                    "SELECT count(*) FROM %s WHERE %s AND deleted_at = 0", 
table, whereClause))) {
+      if (rs.next()) {
+        return rs.getInt(1);
+      }
+      throw new RuntimeException("Doesn't contain data");
+    } catch (SQLException se) {
+      throw new RuntimeException("SQL execution failed", se);
+    }
+  }
+
   private Integer countAllTagRel(Long tagId) {
     try (SqlSession sqlSession =
             
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);

Reply via email to