yuqi1129 commented on code in PR #12350:
URL: https://github.com/apache/gravitino/pull/12350#discussion_r3766497923


##########
core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java:
##########
@@ -227,20 +234,23 @@ public <E extends Entity & HasIdentifier> SchemaEntity 
updateSchema(
         NameIdentifierUtil.ofSchema(metalakeName, catalogName, 
oldSchemaEntity.name()).toString();
     boolean isRenamed = !Objects.equals(oldSchemaEntity.name(), 
newEntity.name());
 
-    AtomicInteger updateResult = new AtomicInteger(0);
     try {
       SessionUtils.doMultipleWithCommit(
-          () ->
-              updateResult.set(
-                  SessionUtils.getWithoutCommit(
-                      SchemaMetaMapper.class,
-                      mapper ->
-                          ops.updatePO(
-                              mapper,
-                              
POConverters.updateSchemaPOWithVersion(oldSchemaPO, newEntity),
-                              oldSchemaPO))),
           () -> {
-            if (isRenamed && updateResult.get() > 0) {
+            int updated =
+                SessionUtils.getWithoutCommit(
+                    SchemaMetaMapper.class,
+                    mapper ->
+                        ops.updatePO(
+                            mapper,
+                            
POConverters.updateSchemaPOWithVersion(oldSchemaPO, newEntity),
+                            oldSchemaPO));

Review Comment:
   The observation is accurate — this CAS locks and validates the schema row 
only, so neither a catalog nor a metalake rename is fenced here. This is 
intentional.
   
   Rename in Gravitino is identity-preserving: it changes only the name column 
of the renamed row, and the ids this request already resolved 
(`metalakeId`/`catalogId`/`schemaId`) keep denoting the same entities. So the 
write lands on exactly the entity the caller resolved, just one that is now 
reachable under a different path — nothing is orphaned and no update is lost. 
The anomaly this PR closes is the other one: the parent is *deleted*, or 
replaced by a same-named entity with a fresh id, and a stale child write 
survives it. That case is caught by the id + existence checks these fences 
already do.
   
   Making a stale fully qualified path fail would require shared locks on the 
whole ancestor chain, root-to-leaf, on every write in the hierarchy — a 
metalake row read on every catalog/schema/table/model write. That is a much 
larger change than this PR, and it buys strictness rather than integrity, so 
I'd rather track it separately if we decide we want strict path semantics.
   



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java:
##########
@@ -492,13 +470,151 @@ private List<SchemaPO> listSchemaPOs(Namespace 
namespace) {
         mapper -> POStorageReadRouting.listPOs(mapper, namespace, ops, 
Entity.EntityType.SCHEMA));
   }
 
+  private void lockCatalogForSchemaCreate(
+      CatalogPO observedCatalogPO, boolean createsImplicitAncestors) {
+    CatalogPO currentCatalogPO =
+        SessionUtils.getWithoutCommit(
+            CatalogMetaMapper.class,

Review Comment:
   Correct — the create fence revalidates the catalog row, and a metalake 
rename leaves that row and its `metalakeId` untouched. This is intentional.
   
   Rename in Gravitino is identity-preserving: it changes only the name column 
of the renamed row, and the ids this request already resolved 
(`metalakeId`/`catalogId`/`schemaId`) keep denoting the same entities. So the 
write lands on exactly the entity the caller resolved, just one that is now 
reachable under a different path — nothing is orphaned and no update is lost. 
The anomaly this PR closes is the other one: the parent is *deleted*, or 
replaced by a same-named entity with a fresh id, and a stale child write 
survives it. That case is caught by the id + existence checks these fences 
already do.
   
   Making a stale fully qualified path fail would require shared locks on the 
whole ancestor chain, root-to-leaf, on every write in the hierarchy — a 
metalake row read on every catalog/schema/table/model write. That is a much 
larger change than this PR, and it buys strictness rather than integrity, so 
I'd rather track it separately if we decide we want strict path semantics.
   



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java:
##########
@@ -492,13 +470,151 @@ private List<SchemaPO> listSchemaPOs(Namespace 
namespace) {
         mapper -> POStorageReadRouting.listPOs(mapper, namespace, ops, 
Entity.EntityType.SCHEMA));
   }
 
+  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());
+    }
+  }
+
+  private void lockCatalogForSchemaDelete(NameIdentifier identifier, SchemaPO 
observedSchemaPO) {
+    CatalogPO currentCatalogPO =
+        SessionUtils.getWithoutCommit(
+            CatalogMetaMapper.class,
+            mapper -> 
mapper.selectCatalogMetaByIdForUpdate(observedSchemaPO.getCatalogId()));

Review Comment:
   Correct — the delete fence takes the catalog row and validates its name and 
`metalakeId`, neither of which changes when the metalake is renamed. This is 
intentional.
   
   Rename in Gravitino is identity-preserving: it changes only the name column 
of the renamed row, and the ids this request already resolved 
(`metalakeId`/`catalogId`/`schemaId`) keep denoting the same entities. So the 
write lands on exactly the entity the caller resolved, just one that is now 
reachable under a different path — nothing is orphaned and no update is lost. 
The anomaly this PR closes is the other one: the parent is *deleted*, or 
replaced by a same-named entity with a fresh id, and a stale child write 
survives it. That case is caught by the id + existence checks these fences 
already do.
   
   Making a stale fully qualified path fail would require shared locks on the 
whole ancestor chain, root-to-leaf, on every write in the hierarchy — a 
metalake row read on every catalog/schema/table/model write. That is a much 
larger change than this PR, and it buys strictness rather than integrity, so 
I'd rather track it separately if we decide we want strict path semantics.
   



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java:
##########
@@ -226,41 +240,37 @@ public <E extends Entity & HasIdentifier> CatalogEntity 
updateCatalog(
     String oldFullName =
         NameIdentifierUtil.ofCatalog(metalakeName, 
oldCatalogEntity.name()).toString();
 
-    AtomicInteger updateResult = new AtomicInteger(0);
     try {
       SessionUtils.doMultipleWithCommit(
-          () ->
-              updateResult.set(
-                  SessionUtils.getWithoutCommit(
-                      CatalogMetaMapper.class,
-                      mapper ->
-                          mapper.updateCatalogMeta(
-                              POConverters.updateCatalogPOWithVersion(
-                                  oldCatalogPO, newEntity, 
oldCatalogPO.getMetalakeId()),
-                              oldCatalogPO))),
           () -> {
-            if (updateResult.get() > 0) {
+            int updated =
+                SessionUtils.getWithoutCommit(
+                    CatalogMetaMapper.class,
+                    mapper ->
+                        mapper.updateCatalogMeta(
+                            POConverters.updateCatalogPOWithVersion(
+                                oldCatalogPO, newEntity, 
oldCatalogPO.getMetalakeId()),
+                            oldCatalogPO));

Review Comment:
   Correct — this CAS validates the catalog row and its version only, and a 
metalake rename changes neither. This is intentional.
   
   Rename in Gravitino is identity-preserving: it changes only the name column 
of the renamed row, and the ids this request already resolved 
(`metalakeId`/`catalogId`/`schemaId`) keep denoting the same entities. So the 
write lands on exactly the entity the caller resolved, just one that is now 
reachable under a different path — nothing is orphaned and no update is lost. 
The anomaly this PR closes is the other one: the parent is *deleted*, or 
replaced by a same-named entity with a fresh id, and a stale child write 
survives it. That case is caught by the id + existence checks these fences 
already do.
   
   Making a stale fully qualified path fail would require shared locks on the 
whole ancestor chain, root-to-leaf, on every write in the hierarchy — a 
metalake row read on every catalog/schema/table/model write. That is a much 
larger change than this PR, and it buys strictness rather than integrity, so 
I'd rather track it separately if we decide we want strict path semantics.
   



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/CatalogMetaService.java:
##########
@@ -415,6 +420,72 @@ public boolean deleteCatalog(NameIdentifier identifier, 
boolean cascade) {
     return true;
   }
 
+  private void deleteCatalogWithVersion(NameIdentifier identifier, CatalogPO 
observedCatalogPO) {
+    int deleted =
+        SessionUtils.getWithoutCommit(
+            CatalogMetaMapper.class,
+            mapper ->
+                mapper.softDeleteCatalogMetasByCatalogId(
+                    observedCatalogPO.getCatalogId(), 
observedCatalogPO.getCurrentVersion()));

Review Comment:
   Correct — the catalog delete CAS checks the catalog row and its version, 
which a metalake rename leaves unchanged. This is intentional.
   
   Rename in Gravitino is identity-preserving: it changes only the name column 
of the renamed row, and the ids this request already resolved 
(`metalakeId`/`catalogId`/`schemaId`) keep denoting the same entities. So the 
write lands on exactly the entity the caller resolved, just one that is now 
reachable under a different path — nothing is orphaned and no update is lost. 
The anomaly this PR closes is the other one: the parent is *deleted*, or 
replaced by a same-named entity with a fresh id, and a stale child write 
survives it. That case is caught by the id + existence checks these fences 
already do.
   
   Making a stale fully qualified path fail would require shared locks on the 
whole ancestor chain, root-to-leaf, on every write in the hierarchy — a 
metalake row read on every catalog/schema/table/model write. That is a much 
larger change than this PR, and it buys strictness rather than integrity, so 
I'd rather track it separately if we decide we want strict path semantics.
   



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java:
##########
@@ -492,13 +470,151 @@ private List<SchemaPO> listSchemaPOs(Namespace 
namespace) {
         mapper -> POStorageReadRouting.listPOs(mapper, namespace, ops, 
Entity.EntityType.SCHEMA));
   }
 
+  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());
+    }
+  }
+
+  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));
+    }
+  }
+
+  void lockSchemaForEntityWrite(
+      NameIdentifier entityIdentifier,
+      Long observedSchemaId,
+      Long observedCatalogId,
+      Long observedMetalakeId) {

Review Comment:
   The premise is right: `lockSchemaForEntityWrite` is taken by the child 
*inserts* and by the cross-schema table move, not by same-schema updates. I 
looked into each path you named and confirmed the concrete defects:
   
   - `TableMetaService.updateTable` writes the new `table_version_info` row 
outside the `updateResult > 0` guard, so a lost CAS still commits an active 
version row. Since the legacy cleanup only removes rows with `deleted_at > 0`, 
that row is never collected.
   - `FunctionMetaService.updateFunction` discards the meta update result 
entirely, so a concurrent delete leaves an active orphan version row *and* 
returns success — a silent lost update.
   - `insertModelVersion` takes no schema fence at all.
   
   All three are pre-existing on `main` and sit outside this PR's scope 
(metalake/catalog/schema OCC), and this PR is already large, so I'm going to 
fix them in a follow-up issue rather than grow the diff here.
   
   I don't plan to take the broader suggestion of acquiring the shared schema 
lock at the start of every child write transaction. Same-schema updates are 
already mutually exclusive with a cascade delete through the per-row CAS 
(`current_version` + `deleted_at = 0`) — the defects above are about what the 
losing transaction commits, not about missing exclusion. Guarding the losing 
path (and adding the missing fence to `insertModelVersion`) is the smaller and 
more targeted fix.
   



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to