jerryshao commented on code in PR #12456:
URL: https://github.com/apache/gravitino/pull/12456#discussion_r3841252170


##########
core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java:
##########
@@ -166,6 +166,15 @@ public void insertFileset(FilesetEntity filesetEntity, 
boolean overwrite) throws
 
       // insert both fileset meta table and version table
       SessionUtils.doMultipleWithCommit(
+          // Hold the parent schema row until this transaction ends, so the 
fileset cannot be
+          // written below a schema that is being dropped.
+          () ->
+              SchemaMetaService.getInstance()
+                  .lockSchemaForEntityWrite(

Review Comment:
   **[Confirmed bug, surfaces at `FilesetCatalogOperations.java:575`]** When 
this lock finds the schema gone (concurrently dropped), 
`lockSchemaForEntityWrite` throws `NoSuchEntityException`. But the call site in 
`FilesetCatalogOperations.createMultipleLocationFileset`'s final 
`store.put(filesetEntity, true)` only catches `IOException`, so this exception 
propagates uncaught — past the earlier schema-existence pre-check in that same 
method, which correctly catches `NoSuchEntityException` and maps it to 
`NoSuchSchemaException` (404).
   
   **Failure scenario:** Client A calls `createFileset` under schema S; the 
up-front `store.get(schemaIdent,...)` pre-check succeeds, then time passes 
doing filesystem mkdir/validation work. Meanwhile client B cascade-drops schema 
S. When A reaches `store.put(filesetEntity, true)`, this lock finds the schema 
gone and throws `NoSuchEntityException`, which isn't 
`NotFoundException`-derived and isn't special-cased in 
`ExceptionHandlers.java`, so `doWithCatalog` lets it through unwrapped and the 
REST layer returns a raw 500 instead of the intended 404. The analogous 
managed-table path, `ManagedTableOperations.createTable`, does catch 
`NoSuchEntityException` and convert it to `NoSuchSchemaException`, showing this 
is the intended pattern the fileset call site misses.



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java:
##########
@@ -450,12 +449,196 @@ private List<SchemaPO> listSchemaPOs(Namespace 
namespace) {
   }
 
   /**
-   * Collects the schema ids that participate in a cascade delete: the target 
schema itself plus
-   * every HierarchicalSchema descendant. The {@link SchemaPO} arrives in 
logical form (e.g. {@code
-   * A:B}); {@link HierarchicalConversionPOStorageOps} translates to storage 
form before running the
-   * SQL prefix match, so this method only deals in logical names.
+   * Holds the parent catalog row for the rest of the transaction, so a schema 
cannot be created
+   * below a catalog that is being dropped. Dropping a catalog locks this same 
row, so the two can
+   * never run at the same time: the loser either finds the catalog gone or 
inserts below a catalog
+   * that is still there.
+   *
+   * <p>A plain schema name only needs a shared lock, so many schemas can be 
created under one
+   * catalog at once. A nested name is different: this request may have to 
create the missing
+   * ancestors, and two requests can both find the same ancestor missing and 
both insert it. A
+   * shared lock does not stop that, so the ancestor case takes an exclusive 
lock and serializes
+   * every other schema create under the catalog until it finishes.
+   *
+   * <p>The name and the metalake are compared again because the caller looked 
the catalog up by
+   * name: if the row now has another name, the catalog named in the request 
no longer exists.
+   */
+  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());
+    }
+  }
+
+  /**
+   * Holds the parent catalog row while a schema is dropped. The lock is 
exclusive here, because a
+   * drop removes descendants and must not run next to another drop or create 
under the same
+   * catalog. Taking the catalog before any schema row also gives every drop 
the same lock order, so
+   * two overlapping cascades cannot deadlock.
    */
-  private List<Long> listSchemaIdsForCascade(SchemaPO schemaPO) {
+  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));
+    }
+  }
+
+  /**
+   * Holds the parent schema row while a table, view, fileset, function, 
model, model version, or
+   * topic is written, so a child cannot be added below a schema that is going 
away. The lock is
+   * shared, so children of the same schema can still be written in parallel; 
dropping the schema
+   * takes the row exclusively and therefore waits for them.
+   */
+  void lockSchemaForEntityWrite(
+      NameIdentifier entityIdentifier,
+      Long observedSchemaId,
+      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);
+    }
+  }
+
+  /**
+   * Decides which error a failed compare-and-set should report. The write 
matched no row either
+   * because somebody else changed the schema, which is a conflict, or because 
the schema was
+   * deleted or renamed away, which is a missing entity.
+   */
+  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());
+  }
+
+  /**
+   * Soft-deletes the nested schemas below the dropped one, each guarded by 
the version read in the
+   * same transaction.
+   */
+  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);
+    }
+  }
+
+  /**
+   * Checks that nothing is left under the schema. Views and functions are 
included: they used to be
+   * missing here, which let a non-cascade drop leave their rows behind with 
no parent.
+   */
+  private void checkSchemaIsEmpty(NameIdentifier identifier, SchemaPO 
schemaPO) {
+    boolean hasDescendantSchemas = 
!listDescendantSchemaPOs(schemaPO).isEmpty();

Review Comment:
   **[Plausible efficiency]** `checkSchemaIsEmpty` materializes six full PO 
lists (tables, filesets, models, topics, views, functions) via 
`listXxxPOsBySchemaId` just to test `.isEmpty()`, while holding an exclusive 
catalog-row lock the whole time.
   
   A non-cascade drop of a schema with thousands of tables fetches and 
deserializes every table/fileset/model/topic/view/function row under that 
schema (six separate full scans) purely to answer a yes/no question, all while 
every other schema-create/drop under the same catalog is blocked on the 
exclusive catalog lock taken earlier in the same transaction. An existence 
check (COUNT/LIMIT 1 or an exists-by-schema-id query) would answer the same 
question with a fraction of the I/O and shorten how long the catalog stays 
locked.



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java:
##########
@@ -450,12 +449,196 @@ private List<SchemaPO> listSchemaPOs(Namespace 
namespace) {
   }
 
   /**
-   * Collects the schema ids that participate in a cascade delete: the target 
schema itself plus
-   * every HierarchicalSchema descendant. The {@link SchemaPO} arrives in 
logical form (e.g. {@code
-   * A:B}); {@link HierarchicalConversionPOStorageOps} translates to storage 
form before running the
-   * SQL prefix match, so this method only deals in logical names.
+   * Holds the parent catalog row for the rest of the transaction, so a schema 
cannot be created
+   * below a catalog that is being dropped. Dropping a catalog locks this same 
row, so the two can
+   * never run at the same time: the loser either finds the catalog gone or 
inserts below a catalog
+   * that is still there.
+   *
+   * <p>A plain schema name only needs a shared lock, so many schemas can be 
created under one
+   * catalog at once. A nested name is different: this request may have to 
create the missing
+   * ancestors, and two requests can both find the same ancestor missing and 
both insert it. A
+   * shared lock does not stop that, so the ancestor case takes an exclusive 
lock and serializes
+   * every other schema create under the catalog until it finishes.
+   *
+   * <p>The name and the metalake are compared again because the caller looked 
the catalog up by
+   * name: if the row now has another name, the catalog named in the request 
no longer exists.
+   */
+  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());
+    }
+  }
+
+  /**
+   * Holds the parent catalog row while a schema is dropped. The lock is 
exclusive here, because a
+   * drop removes descendants and must not run next to another drop or create 
under the same
+   * catalog. Taking the catalog before any schema row also gives every drop 
the same lock order, so
+   * two overlapping cascades cannot deadlock.
    */
-  private List<Long> listSchemaIdsForCascade(SchemaPO schemaPO) {
+  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));
+    }
+  }
+
+  /**
+   * Holds the parent schema row while a table, view, fileset, function, 
model, model version, or
+   * topic is written, so a child cannot be added below a schema that is going 
away. The lock is
+   * shared, so children of the same schema can still be written in parallel; 
dropping the schema
+   * takes the row exclusively and therefore waits for them.
+   */
+  void lockSchemaForEntityWrite(
+      NameIdentifier entityIdentifier,
+      Long observedSchemaId,
+      Long observedCatalogId,
+      Long observedMetalakeId) {
+    NameIdentifier schemaIdentifier = 
NameIdentifierUtil.getSchemaIdentifier(entityIdentifier);

Review Comment:
   **[Plausible simplification]** The "lock the parent schema row before 
writing a child entity" pattern (this method) is hand-duplicated 
near-identically in six call sites (`FilesetMetaService`, 
`FunctionMetaService`, `ModelMetaService`, `ModelVersionMetaService`, 
`TableMetaService`, `TopicMetaService`, `ViewMetaService`) instead of living 
once in a shared write-path hook.
   
   A future PR adding a seventh schema-scoped entity type could copy an 
existing insert method without also adding the 
`SchemaMetaService.getInstance().lockSchemaForEntityWrite(...)` call — nothing 
in the type system or a shared base class forces it, so the new entity type 
could silently reintroduce the exact orphan-under-a-dropped-schema race this PR 
was written to close for every other type.



-- 
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