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

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 54567b407f [#12639] refactor(core): Extract shared OCC helpers into 
OccWriteSupport (#12666)
54567b407f is described below

commit 54567b407f1d0fd0c5932f559bc47dad667b0782
Author: Tanay Paul <[email protected]>
AuthorDate: Tue Sep 1 08:31:14 2026 +0530

    [#12639] refactor(core): Extract shared OCC helpers into OccWriteSupport 
(#12666)
    
    ### What changes were proposed in this pull request?
    
    Introduce `OccWriteSupport`, a utility class with four static generic
    helpers that cover the OCC patterns duplicated across relational meta
    services:
    
    - `writeFailure` - locking reread by id, compare natural-key fields,
    return `NoSuchEntityException` or `OptimisticLockException`
    - `deleteWithVersion` - single-row CAS soft delete guarded by version
    - `deleteChildrenWithVersions` - batch CAS soft delete of children,
    fail-fast on count mismatch
    - `lockParentForChildWrite` - shared/exclusive parent row lock before a
    child write or delete
    
    Migrate MetalakeMetaService, CatalogMetaService, SchemaMetaService, and
    TableMetaService to the shared helpers. No behavior change.
    
    ### Why are the changes needed?
    
    Each OCC PR copied the previous service's helpers. A future change to
    classification rules (extra field, different lock semantics) or a new
    entity type would need the same edit in four places.
    
    Fix: #12639
    
    ### Does this PR introduce _any_ user-facing change?
    
    No.
    
    ### How was this patch tested?
    
    Added `TestOccWriteSupport` (11 tests covering all helpers and edge
    cases). Ran the full relational service test suite: `./gradlew
    :core:test --tests "org.apache.gravitino.storage.relational.service.*"
    -PskipDockerTests=true` - 266 tests, 0 failures.
---
 .../relational/service/CatalogMetaService.java     |  88 ++++-----
 .../relational/service/MetalakeMetaService.java    |  81 ++++-----
 .../relational/service/OccWriteSupport.java        | 152 ++++++++++++++++
 .../relational/service/SchemaMetaService.java      | 154 +++++++---------
 .../relational/service/TableMetaService.java       |  54 +++---
 .../relational/service/TestOccWriteSupport.java    | 200 +++++++++++++++++++++
 6 files changed, 511 insertions(+), 218 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
index 8f32ac0563..66970fd4cc 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java
@@ -435,15 +435,14 @@ public class CatalogMetaService {
    * loses the race to another writer must not delete a catalog it never saw.
    */
   private void deleteCatalogWithVersion(NameIdentifier identifier, CatalogPO 
observedCatalogPO) {
-    int deleted =
-        SessionUtils.getWithoutCommit(
-            CatalogMetaMapper.class,
-            mapper ->
-                mapper.softDeleteCatalogMetasByCatalogId(
-                    observedCatalogPO.getCatalogId(), 
observedCatalogPO.getCurrentVersion()));
-    if (deleted == 0) {
-      throw catalogWriteFailure(identifier, observedCatalogPO);
-    }
+    OccWriteSupport.deleteWithVersion(
+        () ->
+            SessionUtils.getWithoutCommit(
+                CatalogMetaMapper.class,
+                mapper ->
+                    mapper.softDeleteCatalogMetasByCatalogId(
+                        observedCatalogPO.getCatalogId(), 
observedCatalogPO.getCurrentVersion())),
+        () -> catalogWriteFailure(identifier, observedCatalogPO));
   }
 
   /**
@@ -460,18 +459,16 @@ public class CatalogMetaService {
    * exists.
    */
   private void lockMetalakeForCatalogCreate(MetalakePO observedMetalakePO) {
-    MetalakePO currentMetalakePO =
-        SessionUtils.getWithoutCommit(
-            MetalakeMetaMapper.class,
-            mapper -> 
mapper.selectMetalakeMetaByIdForShare(observedMetalakePO.getMetalakeId()));
-    if (currentMetalakePO == null
-        || !Objects.equals(
-            currentMetalakePO.getMetalakeName(), 
observedMetalakePO.getMetalakeName())) {
-      throw new NoSuchEntityException(
-          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
-          Entity.EntityType.METALAKE.name().toLowerCase(),
-          observedMetalakePO.getMetalakeName());
-    }
+    OccWriteSupport.lockParentForChildWrite(
+        observedMetalakePO.getMetalakeName(),
+        Entity.EntityType.METALAKE,
+        () ->
+            SessionUtils.getWithoutCommit(
+                MetalakeMetaMapper.class,
+                mapper ->
+                    
mapper.selectMetalakeMetaByIdForShare(observedMetalakePO.getMetalakeId())),
+        null,
+        current -> Objects.equals(current.getMetalakeName(), 
observedMetalakePO.getMetalakeName()));
   }
 
   /**
@@ -481,23 +478,17 @@ public class CatalogMetaService {
    */
   private RuntimeException catalogWriteFailure(
       NameIdentifier identifier, CatalogPO observedCatalogPO) {
-    // Sessions run at READ_COMMITTED, so a plain read would already see the 
latest committed row.
-    // The locking read additionally waits for a writer that is still in 
flight, so a rename or
-    // delete that has not committed yet is classified as not-found instead of 
as a stale-version
-    // conflict. The lock is taken on the error path of a transaction that is 
about to roll back.
-    CatalogPO currentCatalogPO =
-        SessionUtils.getWithoutCommit(
-            CatalogMetaMapper.class,
-            mapper -> 
mapper.selectCatalogMetaByIdForUpdate(observedCatalogPO.getCatalogId()));
-    if (currentCatalogPO == null
-        || !Objects.equals(currentCatalogPO.getCatalogName(), 
observedCatalogPO.getCatalogName())
-        || !Objects.equals(currentCatalogPO.getMetalakeId(), 
observedCatalogPO.getMetalakeId())) {
-      return new NoSuchEntityException(
-          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
-          Entity.EntityType.CATALOG.name().toLowerCase(),
-          identifier.name());
-    }
-    return ExceptionUtils.concurrentModification(Entity.EntityType.CATALOG, 
identifier);
+    return OccWriteSupport.writeFailure(
+        identifier,
+        Entity.EntityType.CATALOG,
+        () ->
+            SessionUtils.getWithoutCommit(
+                CatalogMetaMapper.class,
+                mapper -> 
mapper.selectCatalogMetaByIdForUpdate(observedCatalogPO.getCatalogId())),
+        null,
+        current ->
+            Objects.equals(current.getCatalogName(), 
observedCatalogPO.getCatalogName())
+                && Objects.equals(current.getMetalakeId(), 
observedCatalogPO.getMetalakeId()));
   }
 
   /**
@@ -506,18 +497,15 @@ public class CatalogMetaService {
    */
   private void deleteSchemasWithVersions(NameIdentifier catalogIdentifier, 
Long catalogId) {
     List<SchemaPO> schemaPOs = listSchemaPOsForCascade(catalogId);
-    if (schemaPOs.isEmpty()) {
-      return;
-    }
-    int deleted =
-        SessionUtils.getWithoutCommit(
-            SchemaMetaMapper.class, mapper -> 
mapper.softDeleteSchemaMetasWithVersion(schemaPOs));
-    // A smaller count means one of these schemas was altered by someone who 
did not take the
-    // catalog row lock. Never commit half a cascade: roll the whole 
transaction back instead.
-    if (deleted != schemaPOs.size()) {
-      throw ExceptionUtils.concurrentChildModification(
-          Entity.EntityType.SCHEMA, Entity.EntityType.CATALOG, 
catalogIdentifier);
-    }
+    OccWriteSupport.deleteChildrenWithVersions(
+        catalogIdentifier,
+        Entity.EntityType.SCHEMA,
+        Entity.EntityType.CATALOG,
+        schemaPOs,
+        children ->
+            SessionUtils.getWithoutCommit(
+                SchemaMetaMapper.class,
+                mapper -> mapper.softDeleteSchemaMetasWithVersion(children)));
   }
 
   /**
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
index fb63973758..e7cf7a9cf5 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/MetalakeMetaService.java
@@ -403,33 +403,25 @@ public class MetalakeMetaService {
   }
 
   void deleteMetalakeWithVersion(NameIdentifier identifier, Long metalakeId, 
Long currentVersion) {
-    int deleted =
-        SessionUtils.getWithoutCommit(
-            MetalakeMetaMapper.class,
-            mapper -> mapper.softDeleteMetalakeMetaByMetalakeId(metalakeId, 
currentVersion));
-    if (deleted == 0) {
-      throw metalakeWriteFailure(identifier, metalakeId, identifier.name());
-    }
+    OccWriteSupport.deleteWithVersion(
+        () ->
+            SessionUtils.getWithoutCommit(
+                MetalakeMetaMapper.class,
+                mapper -> 
mapper.softDeleteMetalakeMetaByMetalakeId(metalakeId, currentVersion)),
+        () -> metalakeWriteFailure(identifier, metalakeId, identifier.name()));
   }
 
   private RuntimeException metalakeWriteFailure(
       NameIdentifier identifier, Long metalakeId, String observedName) {
-    // Sessions run at READ_COMMITTED, so a plain read would already see the 
latest committed row.
-    // The locking read additionally waits for a writer that is still in 
flight, so a delete or
-    // rename that has not committed yet is reported as a missing metalake 
instead of as a stale
-    // version conflict. The lock is taken on the error path of a transaction 
that is about to roll
-    // back.
-    MetalakePO currentMetalakePO =
-        SessionUtils.getWithoutCommit(
-            MetalakeMetaMapper.class, mapper -> 
mapper.selectMetalakeMetaByIdForUpdate(metalakeId));
-    if (currentMetalakePO == null
-        || !Objects.equals(currentMetalakePO.getMetalakeName(), observedName)) 
{
-      return new NoSuchEntityException(
-          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
-          Entity.EntityType.METALAKE.name().toLowerCase(),
-          identifier.name());
-    }
-    return ExceptionUtils.concurrentModification(Entity.EntityType.METALAKE, 
identifier);
+    return OccWriteSupport.writeFailure(
+        identifier,
+        Entity.EntityType.METALAKE,
+        () ->
+            SessionUtils.getWithoutCommit(
+                MetalakeMetaMapper.class,
+                mapper -> mapper.selectMetalakeMetaByIdForUpdate(metalakeId)),
+        null,
+        current -> Objects.equals(current.getMetalakeName(), observedName));
   }
 
   private void deleteCatalogsWithVersions(NameIdentifier metalakeIdentifier, 
Long metalakeId) {
@@ -441,19 +433,15 @@ public class MetalakeMetaService {
         SessionUtils.getWithoutCommit(
             CatalogMetaMapper.class,
             mapper -> mapper.listCatalogPOsByMetalakeIdForUpdate(metalakeId));
-    if (catalogPOs.isEmpty()) {
-      return;
-    }
-    int deleted =
-        SessionUtils.getWithoutCommit(
-            CatalogMetaMapper.class,
-            mapper -> mapper.softDeleteCatalogMetasWithVersion(catalogPOs));
-    // Never commit a partial cascade. A smaller count means that a catalog no 
longer matches the
-    // ID and version read above, so the outer transaction must roll back all 
deletes.
-    if (deleted != catalogPOs.size()) {
-      throw ExceptionUtils.concurrentChildModification(
-          Entity.EntityType.CATALOG, Entity.EntityType.METALAKE, 
metalakeIdentifier);
-    }
+    OccWriteSupport.deleteChildrenWithVersions(
+        metalakeIdentifier,
+        Entity.EntityType.CATALOG,
+        Entity.EntityType.METALAKE,
+        catalogPOs,
+        children ->
+            SessionUtils.getWithoutCommit(
+                CatalogMetaMapper.class,
+                mapper -> mapper.softDeleteCatalogMetasWithVersion(children)));
   }
 
   List<SchemaPO> listSchemaPOsForCascade(Long metalakeId) {
@@ -463,18 +451,15 @@ public class MetalakeMetaService {
 
   private void deleteSchemasWithVersions(
       NameIdentifier metalakeIdentifier, List<SchemaPO> schemaPOs) {
-    if (schemaPOs.isEmpty()) {
-      return;
-    }
-    int deleted =
-        SessionUtils.getWithoutCommit(
-            SchemaMetaMapper.class, mapper -> 
mapper.softDeleteSchemaMetasWithVersion(schemaPOs));
-    // The version check protects this snapshot from a schema alter that does 
not use the parent
-    // catalog lock. Roll back the whole cascade instead of silently losing 
that schema change.
-    if (deleted != schemaPOs.size()) {
-      throw ExceptionUtils.concurrentChildModification(
-          Entity.EntityType.SCHEMA, Entity.EntityType.METALAKE, 
metalakeIdentifier);
-    }
+    OccWriteSupport.deleteChildrenWithVersions(
+        metalakeIdentifier,
+        Entity.EntityType.SCHEMA,
+        Entity.EntityType.METALAKE,
+        schemaPOs,
+        children ->
+            SessionUtils.getWithoutCommit(
+                SchemaMetaMapper.class,
+                mapper -> mapper.softDeleteSchemaMetasWithVersion(children)));
   }
 
   @Monitored(
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/OccWriteSupport.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/OccWriteSupport.java
new file mode 100644
index 0000000000..3e5004a896
--- /dev/null
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/OccWriteSupport.java
@@ -0,0 +1,152 @@
+/*
+ * 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.service;
+
+import java.util.List;
+import java.util.Locale;
+import java.util.function.Function;
+import java.util.function.IntSupplier;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+import java.util.function.ToIntFunction;
+import javax.annotation.Nullable;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.storage.relational.utils.ExceptionUtils;
+
+/**
+ * Utility class providing shared helpers for optimistic concurrency control 
(OCC) operations across
+ * relational metadata services.
+ */
+public class OccWriteSupport {
+
+  private OccWriteSupport() {}
+
+  /**
+   * Classifies a write-failure for an entity during an optimistic concurrency 
control operation.
+   *
+   * <p>Executes a locking lookup to re-read the target entity. If the entity 
no longer exists or
+   * its natural key fields do not match the expected identity, returns a 
{@link
+   * NoSuchEntityException}. Otherwise, returns an {@link
+   * org.apache.gravitino.exceptions.OptimisticLockException} via {@link
+   * ExceptionUtils#concurrentModification(Entity.EntityType, NameIdentifier)}.
+   *
+   * @param <T> the persistent object (PO) type of the entity
+   * @param identifier the name identifier of the entity
+   * @param type the entity type
+   * @param lockingLookup a supplier that retrieves the current entity while 
locking its row
+   * @param poMapper an optional function to transform the retrieved PO (e.g. 
physical to logical)
+   * @param sameIdentity a predicate comparing the retrieved PO against 
expected natural key values
+   * @return the classified RuntimeException to be thrown
+   */
+  public static <T> RuntimeException writeFailure(
+      NameIdentifier identifier,
+      Entity.EntityType type,
+      Supplier<T> lockingLookup,
+      @Nullable Function<T, T> poMapper,
+      @Nullable Predicate<T> sameIdentity) {
+    T currentPO = lockingLookup.get();
+    if (currentPO != null && poMapper != null) {
+      currentPO = poMapper.apply(currentPO);
+    }
+    if (currentPO == null || (sameIdentity != null && 
!sameIdentity.test(currentPO))) {
+      return new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          type.name().toLowerCase(Locale.ROOT),
+          identifier.name());
+    }
+    return ExceptionUtils.concurrentModification(type, identifier);
+  }
+
+  /**
+   * Executes a single-row compare-and-set soft delete for an entity guarded 
by version.
+   *
+   * @param softDeleteOps an operation supplying the number of rows affected 
by soft delete
+   * @param onMissSupplier a supplier providing the RuntimeException when zero 
rows are deleted
+   */
+  public static void deleteWithVersion(
+      IntSupplier softDeleteOps, Supplier<RuntimeException> onMissSupplier) {
+    int deleted = softDeleteOps.getAsInt();
+    if (deleted == 0) {
+      throw onMissSupplier.get();
+    }
+  }
+
+  /**
+   * Executes a batch soft delete of child entities guarded by their 
individual versions.
+   *
+   * <p>If the affected row count does not match the size of the child list, 
throws a concurrent
+   * child modification exception via {@link 
ExceptionUtils#concurrentChildModification}.
+   *
+   * @param <T> the persistent object (PO) type of the child entities
+   * @param parentIdentifier the name identifier of the parent entity
+   * @param childType the entity type of the children
+   * @param parentType the entity type of the parent
+   * @param children the list of child persistent objects to delete
+   * @param softDeleteOps a function performing batch soft delete on the child 
list
+   */
+  public static <T> void deleteChildrenWithVersions(
+      NameIdentifier parentIdentifier,
+      Entity.EntityType childType,
+      Entity.EntityType parentType,
+      @Nullable List<T> children,
+      ToIntFunction<List<T>> softDeleteOps) {
+    if (children == null || children.isEmpty()) {
+      return;
+    }
+    int deleted = softDeleteOps.applyAsInt(children);
+    if (deleted != children.size()) {
+      throw ExceptionUtils.concurrentChildModification(childType, parentType, 
parentIdentifier);
+    }
+  }
+
+  /**
+   * Locks the parent row before executing a child write or delete operation 
to guarantee existence
+   * and identity consistency.
+   *
+   * @param <P> the persistent object (PO) type of the parent entity
+   * @param parentEntityName the name of the parent entity expected in 
exceptions
+   * @param parentType the entity type of the parent
+   * @param lockingLookup a supplier that retrieves the parent entity while 
locking its row
+   * @param poMapper an optional function to transform the retrieved parent PO
+   * @param sameParentIdentity a predicate verifying parent identity (e.g. 
name and ancestor ids)
+   * @return the locked parent persistent object
+   * @throws NoSuchEntityException if the parent entity is missing or fails 
identity validation
+   */
+  public static <P> P lockParentForChildWrite(
+      String parentEntityName,
+      Entity.EntityType parentType,
+      Supplier<P> lockingLookup,
+      @Nullable Function<P, P> poMapper,
+      @Nullable Predicate<P> sameParentIdentity) {
+    P currentParent = lockingLookup.get();
+    if (currentParent != null && poMapper != null) {
+      currentParent = poMapper.apply(currentParent);
+    }
+    if (currentParent == null
+        || (sameParentIdentity != null && 
!sameParentIdentity.test(currentParent))) {
+      throw new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          parentType.name().toLowerCase(Locale.ROOT),
+          parentEntityName);
+    }
+    return currentParent;
+  }
+}
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
index 40eacb0b71..037befda7e 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
@@ -404,15 +404,14 @@ public class SchemaMetaService {
    * lost the race must not delete a schema it never looked at.
    */
   private void deleteSchemaWithVersion(NameIdentifier identifier, SchemaPO 
observedSchemaPO) {
-    int deleted =
-        SessionUtils.getWithoutCommit(
-            SchemaMetaMapper.class,
-            mapper ->
-                mapper.softDeleteSchemaMetaBySchemaIdAndVersion(
-                    observedSchemaPO.getSchemaId(), 
observedSchemaPO.getCurrentVersion()));
-    if (deleted == 0) {
-      throw schemaWriteFailure(identifier, observedSchemaPO);
-    }
+    OccWriteSupport.deleteWithVersion(
+        () ->
+            SessionUtils.getWithoutCommit(
+                SchemaMetaMapper.class,
+                mapper ->
+                    mapper.softDeleteSchemaMetaBySchemaIdAndVersion(
+                        observedSchemaPO.getSchemaId(), 
observedSchemaPO.getCurrentVersion())),
+        () -> schemaWriteFailure(identifier, observedSchemaPO));
   }
 
   @Monitored(
@@ -465,21 +464,20 @@ public class SchemaMetaService {
    */
   private void lockCatalogForSchemaCreate(
       CatalogPO observedCatalogPO, boolean createsImplicitAncestors) {
-    CatalogPO currentCatalogPO =
-        SessionUtils.getWithoutCommit(
-            CatalogMetaMapper.class,
-            mapper ->
-                createsImplicitAncestors
-                    ? 
mapper.selectCatalogMetaByIdForUpdate(observedCatalogPO.getCatalogId())
-                    : 
mapper.selectCatalogMetaByIdForShare(observedCatalogPO.getCatalogId()));
-    if (currentCatalogPO == null
-        || !Objects.equals(currentCatalogPO.getCatalogName(), 
observedCatalogPO.getCatalogName())
-        || !Objects.equals(currentCatalogPO.getMetalakeId(), 
observedCatalogPO.getMetalakeId())) {
-      throw new NoSuchEntityException(
-          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
-          Entity.EntityType.CATALOG.name().toLowerCase(),
-          observedCatalogPO.getCatalogName());
-    }
+    OccWriteSupport.lockParentForChildWrite(
+        observedCatalogPO.getCatalogName(),
+        Entity.EntityType.CATALOG,
+        () ->
+            SessionUtils.getWithoutCommit(
+                CatalogMetaMapper.class,
+                mapper ->
+                    createsImplicitAncestors
+                        ? 
mapper.selectCatalogMetaByIdForUpdate(observedCatalogPO.getCatalogId())
+                        : 
mapper.selectCatalogMetaByIdForShare(observedCatalogPO.getCatalogId())),
+        null,
+        current ->
+            Objects.equals(current.getCatalogName(), 
observedCatalogPO.getCatalogName())
+                && Objects.equals(current.getMetalakeId(), 
observedCatalogPO.getMetalakeId()));
   }
 
   /**
@@ -489,18 +487,18 @@ public class SchemaMetaService {
    * two overlapping cascades cannot deadlock.
    */
   private void lockCatalogForSchemaDelete(NameIdentifier identifier, SchemaPO 
observedSchemaPO) {
-    CatalogPO currentCatalogPO =
-        SessionUtils.getWithoutCommit(
-            CatalogMetaMapper.class,
-            mapper -> 
mapper.selectCatalogMetaByIdForUpdate(observedSchemaPO.getCatalogId()));
-    if (currentCatalogPO == null
-        || !Objects.equals(currentCatalogPO.getCatalogName(), 
identifier.namespace().level(1))
-        || !Objects.equals(currentCatalogPO.getMetalakeId(), 
observedSchemaPO.getMetalakeId())) {
-      throw new NoSuchEntityException(
-          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
-          Entity.EntityType.CATALOG.name().toLowerCase(),
-          identifier.namespace().level(1));
-    }
+    String catalogName = identifier.namespace().level(1);
+    OccWriteSupport.lockParentForChildWrite(
+        catalogName,
+        Entity.EntityType.CATALOG,
+        () ->
+            SessionUtils.getWithoutCommit(
+                CatalogMetaMapper.class,
+                mapper -> 
mapper.selectCatalogMetaByIdForUpdate(observedSchemaPO.getCatalogId())),
+        null,
+        current ->
+            Objects.equals(current.getCatalogName(), catalogName)
+                && Objects.equals(current.getMetalakeId(), 
observedSchemaPO.getMetalakeId()));
   }
 
   /**
@@ -515,19 +513,18 @@ public class SchemaMetaService {
       Long observedCatalogId,
       Long observedMetalakeId) {
     NameIdentifier schemaIdentifier = 
NameIdentifierUtil.getSchemaIdentifier(entityIdentifier);
-    SchemaPO currentSchemaPO =
-        SessionUtils.getWithoutCommit(
-            SchemaMetaMapper.class,
-            mapper -> mapper.selectSchemaMetaByIdForShare(observedSchemaId));
-    if (currentSchemaPO != null) {
-      currentSchemaPO = physicalToLogicalSchemaPO(currentSchemaPO);
-    }
-    if (currentSchemaPO == null
-        || !Objects.equals(currentSchemaPO.getSchemaName(), 
schemaIdentifier.name())
-        || !Objects.equals(currentSchemaPO.getCatalogId(), observedCatalogId)
-        || !Objects.equals(currentSchemaPO.getMetalakeId(), 
observedMetalakeId)) {
-      throw noSuchSchemaException(schemaIdentifier);
-    }
+    OccWriteSupport.lockParentForChildWrite(
+        schemaIdentifier.name(),
+        Entity.EntityType.SCHEMA,
+        () ->
+            SessionUtils.getWithoutCommit(
+                SchemaMetaMapper.class,
+                mapper -> 
mapper.selectSchemaMetaByIdForShare(observedSchemaId)),
+        SchemaMetaService::physicalToLogicalSchemaPO,
+        current ->
+            Objects.equals(current.getSchemaName(), schemaIdentifier.name())
+                && Objects.equals(current.getCatalogId(), observedCatalogId)
+                && Objects.equals(current.getMetalakeId(), 
observedMetalakeId));
   }
 
   /**
@@ -537,32 +534,18 @@ public class SchemaMetaService {
    */
   private RuntimeException schemaWriteFailure(
       NameIdentifier identifier, SchemaPO observedSchemaPO) {
-    // Sessions run at READ_COMMITTED, so a plain read would already see the 
latest committed row.
-    // The locking read additionally waits for a writer that is still in 
flight, so a delete or
-    // rename that has not committed yet is reported as a missing schema 
instead of as a stale
-    // version conflict. The lock is taken on the error path of a transaction 
that is about to roll
-    // back.
-    SchemaPO currentSchemaPO =
-        SessionUtils.getWithoutCommit(
-            SchemaMetaMapper.class,
-            mapper -> 
mapper.selectSchemaMetaByIdForUpdate(observedSchemaPO.getSchemaId()));
-    if (currentSchemaPO == null) {
-      return noSuchSchemaException(identifier);
-    }
-    currentSchemaPO = physicalToLogicalSchemaPO(currentSchemaPO);
-    if (!Objects.equals(currentSchemaPO.getSchemaName(), 
observedSchemaPO.getSchemaName())
-        || !Objects.equals(currentSchemaPO.getCatalogId(), 
observedSchemaPO.getCatalogId())
-        || !Objects.equals(currentSchemaPO.getMetalakeId(), 
observedSchemaPO.getMetalakeId())) {
-      return noSuchSchemaException(identifier);
-    }
-    return ExceptionUtils.concurrentModification(Entity.EntityType.SCHEMA, 
identifier);
-  }
-
-  private NoSuchEntityException noSuchSchemaException(NameIdentifier 
identifier) {
-    return new NoSuchEntityException(
-        NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
-        Entity.EntityType.SCHEMA.name().toLowerCase(),
-        identifier.name());
+    return OccWriteSupport.writeFailure(
+        identifier,
+        Entity.EntityType.SCHEMA,
+        () ->
+            SessionUtils.getWithoutCommit(
+                SchemaMetaMapper.class,
+                mapper -> 
mapper.selectSchemaMetaByIdForUpdate(observedSchemaPO.getSchemaId())),
+        SchemaMetaService::physicalToLogicalSchemaPO,
+        current ->
+            Objects.equals(current.getSchemaName(), 
observedSchemaPO.getSchemaName())
+                && Objects.equals(current.getCatalogId(), 
observedSchemaPO.getCatalogId())
+                && Objects.equals(current.getMetalakeId(), 
observedSchemaPO.getMetalakeId()));
   }
 
   /**
@@ -571,18 +554,15 @@ public class SchemaMetaService {
    */
   private void deleteDescendantSchemasWithVersions(
       NameIdentifier schemaIdentifier, List<SchemaPO> descendants) {
-    if (descendants.isEmpty()) {
-      return;
-    }
-    int deleted =
-        SessionUtils.getWithoutCommit(
-            SchemaMetaMapper.class, mapper -> 
mapper.softDeleteSchemaMetasWithVersion(descendants));
-    // A smaller count means one of these schemas was altered by a request 
that did not take the
-    // catalog lock. Never commit half a cascade: roll the whole transaction 
back instead.
-    if (deleted != descendants.size()) {
-      throw ExceptionUtils.concurrentChildModification(
-          Entity.EntityType.SCHEMA, Entity.EntityType.SCHEMA, 
schemaIdentifier);
-    }
+    OccWriteSupport.deleteChildrenWithVersions(
+        schemaIdentifier,
+        Entity.EntityType.SCHEMA,
+        Entity.EntityType.SCHEMA,
+        descendants,
+        children ->
+            SessionUtils.getWithoutCommit(
+                SchemaMetaMapper.class,
+                mapper -> mapper.softDeleteSchemaMetasWithVersion(children)));
   }
 
   /**
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
index a387bc370f..5ad3569fd0 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
@@ -298,15 +298,14 @@ public class TableMetaService {
    * snapshot without copying the production CAS logic.
    */
   void deleteTableWithVersion(NameIdentifier identifier, TablePO 
observedTablePO) {
-    int deleted =
-        SessionUtils.getWithoutCommit(
-            TableMetaMapper.class,
-            mapper ->
-                mapper.softDeleteTableMetasByTableId(
-                    observedTablePO.getTableId(), 
observedTablePO.getCurrentVersion()));
-    if (deleted == 0) {
-      throw tableWriteFailure(identifier, observedTablePO);
-    }
+    OccWriteSupport.deleteWithVersion(
+        () ->
+            SessionUtils.getWithoutCommit(
+                TableMetaMapper.class,
+                mapper ->
+                    mapper.softDeleteTableMetasByTableId(
+                        observedTablePO.getTableId(), 
observedTablePO.getCurrentVersion())),
+        () -> tableWriteFailure(identifier, observedTablePO));
   }
 
   @Monitored(
@@ -432,29 +431,18 @@ public class TableMetaService {
   }
 
   private RuntimeException tableWriteFailure(NameIdentifier identifier, 
TablePO observedTablePO) {
-    // A zero-row CAS has two different meanings:
-    // 1. The same table is still here, but another writer changed its 
version. The caller should
-    //    retry, so return OptimisticLockException.
-    // 2. The table ID was deleted, renamed, or moved away from the requested 
name. From the
-    //    caller's point of view the requested table no longer exists, so 
return NoSuchEntity.
-    //
-    // Read by the stable table ID and lock the row. The lock waits for an 
in-flight writer to
-    // finish, which lets us classify the failure using committed data instead 
of guessing while
-    // the other transaction is still running.
-    TablePO currentTablePO =
-        SessionUtils.getWithoutCommit(
-            TableMetaMapper.class,
-            mapper -> 
mapper.selectTableMetaByIdForUpdate(observedTablePO.getTableId()));
-    if (currentTablePO == null
-        || !Objects.equals(currentTablePO.getTableName(), 
observedTablePO.getTableName())
-        || !Objects.equals(currentTablePO.getSchemaId(), 
observedTablePO.getSchemaId())
-        || !Objects.equals(currentTablePO.getCatalogId(), 
observedTablePO.getCatalogId())
-        || !Objects.equals(currentTablePO.getMetalakeId(), 
observedTablePO.getMetalakeId())) {
-      return new NoSuchEntityException(
-          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
-          Entity.EntityType.TABLE.name().toLowerCase(),
-          identifier.name());
-    }
-    return ExceptionUtils.concurrentModification(Entity.EntityType.TABLE, 
identifier);
+    return OccWriteSupport.writeFailure(
+        identifier,
+        Entity.EntityType.TABLE,
+        () ->
+            SessionUtils.getWithoutCommit(
+                TableMetaMapper.class,
+                mapper -> 
mapper.selectTableMetaByIdForUpdate(observedTablePO.getTableId())),
+        null,
+        current ->
+            Objects.equals(current.getTableName(), 
observedTablePO.getTableName())
+                && Objects.equals(current.getSchemaId(), 
observedTablePO.getSchemaId())
+                && Objects.equals(current.getCatalogId(), 
observedTablePO.getCatalogId())
+                && Objects.equals(current.getMetalakeId(), 
observedTablePO.getMetalakeId()));
   }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOccWriteSupport.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOccWriteSupport.java
new file mode 100644
index 0000000000..fb4a533b95
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestOccWriteSupport.java
@@ -0,0 +1,200 @@
+/*
+ * 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.service;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
+import org.junit.jupiter.api.Test;
+
+public class TestOccWriteSupport {
+
+  private static class DummyPO {
+    private final String name;
+    private final Long parentId;
+
+    DummyPO(String name, Long parentId) {
+      this.name = name;
+      this.parentId = parentId;
+    }
+
+    String name() {
+      return name;
+    }
+
+    Long parentId() {
+      return parentId;
+    }
+  }
+
+  @Test
+  void testWriteFailureReturnsNoSuchEntityWhenNotFound() {
+    NameIdentifier ident = NameIdentifier.of("metalake_test");
+    RuntimeException ex =
+        OccWriteSupport.writeFailure(
+            ident, Entity.EntityType.METALAKE, () -> null, null, po -> true);
+    assertInstanceOf(NoSuchEntityException.class, ex);
+  }
+
+  @Test
+  void testWriteFailureReturnsNoSuchEntityWhenIdentityMismatch() {
+    NameIdentifier ident = NameIdentifier.of("metalake_test");
+    DummyPO current = new DummyPO("different_name", 1L);
+    RuntimeException ex =
+        OccWriteSupport.writeFailure(
+            ident,
+            Entity.EntityType.METALAKE,
+            () -> current,
+            null,
+            po -> Objects.equals(po.name(), "metalake_test"));
+    assertInstanceOf(NoSuchEntityException.class, ex);
+  }
+
+  @Test
+  void testWriteFailureReturnsOptimisticLockExceptionWhenMatch() {
+    NameIdentifier ident = NameIdentifier.of("metalake_test");
+    DummyPO current = new DummyPO("metalake_test", 1L);
+    RuntimeException ex =
+        OccWriteSupport.writeFailure(
+            ident,
+            Entity.EntityType.METALAKE,
+            () -> current,
+            null,
+            po -> Objects.equals(po.name(), "metalake_test"));
+    assertInstanceOf(OptimisticLockException.class, ex);
+  }
+
+  @Test
+  void testDeleteWithVersionSuccess() {
+    assertDoesNotThrow(
+        () ->
+            OccWriteSupport.deleteWithVersion(
+                () -> 1, () -> new RuntimeException("Should not be thrown")));
+  }
+
+  @Test
+  void testDeleteWithVersionThrowsOnMiss() {
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            OccWriteSupport.deleteWithVersion(
+                () -> 0,
+                () ->
+                    new NoSuchEntityException(
+                        NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE, 
"metalake", "test")));
+  }
+
+  @Test
+  void testDeleteChildrenWithVersionsEmptyOrNull() {
+    NameIdentifier parentIdent = NameIdentifier.of("parent");
+    assertDoesNotThrow(
+        () ->
+            OccWriteSupport.deleteChildrenWithVersions(
+                parentIdent,
+                Entity.EntityType.CATALOG,
+                Entity.EntityType.METALAKE,
+                null,
+                list -> 0));
+
+    assertDoesNotThrow(
+        () ->
+            OccWriteSupport.deleteChildrenWithVersions(
+                parentIdent,
+                Entity.EntityType.CATALOG,
+                Entity.EntityType.METALAKE,
+                Collections.emptyList(),
+                list -> 0));
+  }
+
+  @Test
+  void testDeleteChildrenWithVersionsSuccess() {
+    NameIdentifier parentIdent = NameIdentifier.of("parent");
+    List<DummyPO> children = List.of(new DummyPO("c1", 1L), new DummyPO("c2", 
1L));
+
+    assertDoesNotThrow(
+        () ->
+            OccWriteSupport.deleteChildrenWithVersions(
+                parentIdent,
+                Entity.EntityType.CATALOG,
+                Entity.EntityType.METALAKE,
+                children,
+                list -> 2));
+  }
+
+  @Test
+  void testDeleteChildrenWithVersionsMismatchThrows() {
+    NameIdentifier parentIdent = NameIdentifier.of("parent");
+    List<DummyPO> children = List.of(new DummyPO("c1", 1L), new DummyPO("c2", 
1L));
+
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            OccWriteSupport.deleteChildrenWithVersions(
+                parentIdent,
+                Entity.EntityType.CATALOG,
+                Entity.EntityType.METALAKE,
+                children,
+                list -> 1));
+  }
+
+  @Test
+  void testLockParentForChildWriteSuccess() {
+    DummyPO parent = new DummyPO("parent_name", 10L);
+    DummyPO locked =
+        OccWriteSupport.lockParentForChildWrite(
+            "parent_name",
+            Entity.EntityType.METALAKE,
+            () -> parent,
+            null,
+            p -> Objects.equals(p.name(), "parent_name"));
+    assertEquals(parent, locked);
+  }
+
+  @Test
+  void testLockParentForChildWriteNotFoundThrows() {
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            OccWriteSupport.lockParentForChildWrite(
+                "parent_name", Entity.EntityType.METALAKE, () -> null, null, p 
-> true));
+  }
+
+  @Test
+  void testLockParentForChildWriteIdentityMismatchThrows() {
+    DummyPO parent = new DummyPO("wrong_parent_name", 10L);
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            OccWriteSupport.lockParentForChildWrite(
+                "parent_name",
+                Entity.EntityType.METALAKE,
+                () -> parent,
+                null,
+                p -> Objects.equals(p.name(), "parent_name")));
+  }
+}

Reply via email to