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


##########
core/src/main/java/org/apache/gravitino/catalog/OperationDispatcher.java:
##########
@@ -83,6 +83,49 @@ protected OperationDispatcher(
     this.secretManager = secretManager;
   }
 
+  /**
+   * Creates the user-facing error for an external identifier retained by 
deleted metadata.
+   *
+   * <p>Restoring that metadata automatically could also restore governance 
data that the operator
+   * intended to retire. The error instead gives operators an explicit cleanup 
path that imports the
+   * external objects with new identifiers after manual reconciliation.
+   *
+   * @param ident the external entity identifier
+   * @param entityId the stable identifier read from the external system
+   * @param entityType the type of the external entity
+   * @return an exception containing manual reconciliation instructions
+   */
+  protected UnsupportedOperationException 
softDeletedExternalIdentifierException(
+      NameIdentifier ident, long entityId, Entity.EntityType entityType) {
+    String cleanupTarget;
+    switch (entityType) {
+      case SCHEMA:
+        cleanupTarget = "the external schema and all of its tables and views";
+        break;
+      case TABLE:
+        cleanupTarget = "the external table";
+        break;
+      case VIEW:
+        cleanupTarget = "the external view";
+        break;
+      default:
+        cleanupTarget = "the external object";

Review Comment:
   [Important] This helper covers SCHEMA, TABLE and VIEW, but topic import was 
left on the old overwrite path, so the identity reassignment this PR fixes is 
still reachable.
   
   `TopicOperationDispatcher.importTopic` builds a `TopicEntity` with `uid = 
stringId.id()` read from the external topic and still calls 
`store.put(topicEntity, true)` (TopicOperationDispatcher.java:312), under the 
same "created externally" rationale the other three dispatchers just dropped 
(TopicOperationDispatcher.java:286-291). Topics are securable objects with 
their own privileges (AuthorizationUtils.java:116-118, 
MetadataObjectUtil.java:266), so an overwrite by stable ID moves the topic row 
- and the privilege relations keyed on that ID - to whatever path is being 
loaded. That is the failure mode described in #13770, just for a different 
entity type.
   
   Either extend the create-only write to `importTopic` (adding a `case TOPIC` 
here, e.g. "the external topic") or state in the PR description why topics are 
deliberately out of scope.
   
   Verified by: read TopicOperationDispatcher.java:267-316 on this branch; 
grepped every `store.put` under core/src/main/java/org/apache/gravitino/catalog 
- after this PR only topic import still passes `true` from an import path; 
confirmed TOPIC privileges at AuthorizationUtils.java:116-118 and 
MetadataObjectUtil.java:266.



##########
catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/integration/test/CatalogHive2IT.java:
##########
@@ -1458,14 +1458,21 @@ public void testOutOfBandRenameKeepsColumnTags() throws 
InterruptedException {
             return null;
           });
 
-      // The column keeps its id, so the tag follows the table to its new name.
+      // Loading the external name now requires manual reconciliation. The 
rejected import must
+      // leave the tag relation attached to the original Gravitino identity.
       NameIdentifier newIdent = NameIdentifier.of(schema, newName);
-      Column column = loadColumn(newIdent, HIVE_COL_NAME1);
-      Assertions.assertArrayEquals(new String[] {tagName}, 
column.supportsTags().listTags());
+      UnsupportedOperationException exception =
+          assertThrows(
+              UnsupportedOperationException.class, () -> loadColumn(newIdent, 
HIVE_COL_NAME1));
+      Assertions.assertTrue(
+          exception
+              .getMessage()
+              .contains("does not automatically reconcile external table 
renames"));
       MetadataObject[] objects = 
metalake.getTag(tagName).associatedObjects().objects();
       Assertions.assertEquals(1, objects.length);
       Assertions.assertEquals(
-          String.join(".", catalogName, schema, newName, 
HIVE_COL_NAME1).toLowerCase(Locale.ROOT),
+          String.join(".", catalogName, schema, ident.name(), HIVE_COL_NAME1)

Review Comment:
   [Important] This inverted assertion locks in dangling governance metadata, 
which the PR description does not mention.
   
   After the out-of-band rename, Hive no longer has `ident.name()`, yet the 
test now asserts the column tag stays associated with 
`catalog.schema.<oldName>.col1`. The new name cannot be loaded either 
(assertion at :1464-1470), so the tagged object is unreachable from both sides: 
listing it still works (names come from the stored entity), but resolving it 
goes through `MetadataObjectUtil.checkMetadataObject` -> `tableExists` 
(MetadataObjectUtil.java:257-263 -> TableCatalog.java:91), which now throws 
instead of returning false. Before this PR the tag followed the rename and 
stayed resolvable.
   
   Two asks: call this out under "user-facing change" (existing tag/privilege 
relations are left pointing at a path that exists in neither system), and say 
how an operator is expected to re-point or drop them, since the recovery path 
in the error message only covers clearing `gravitino.identifier` on the 
external object.
   
   Verified by: read CatalogHive2IT.java:1420-1480 on this branch against 
origin/main (the test was renamed from `testOutOfBandRenameKeepsColumnTags`); 
traced the object-resolution path through MetadataObjectUtil.java:257-263.



##########
core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java:
##########
@@ -252,6 +261,30 @@ public void 
testConcurrentImportSchemaReusesExistingEntity() throws IOException
     Schema loadedSchema = Assertions.assertDoesNotThrow(() -> 
dispatcher.loadSchema(schemaIdent));
     Assertions.assertEquals(schemaIdent.name(), loadedSchema.name());
     Assertions.assertEquals("comment", loadedSchema.comment());
+    verify(entityStore).put(any(SchemaEntity.class), eq(false));
+  }
+
+  @Test
+  public void testImportSchemaWithSoftDeletedIdentifierRequiresManualCleanup() 
throws IOException {
+    NameIdentifier schemaIdent =
+        NameIdentifier.of(metalake, catalog, "schema_soft_deleted_identifier");
+    dispatcher.createSchema(schemaIdent, "comment", ImmutableMap.of("k1", 
"v1", "k2", "v2"));
+    SchemaEntity schemaEntity = entityStore.get(schemaIdent, SCHEMA, 
SchemaEntity.class);
+    entityStore.delete(schemaIdent, SCHEMA);
+
+    reset(entityStore);
+    doThrow(new SoftDeletedEntityIdentifierException(SCHEMA, 
schemaEntity.id()))

Review Comment:
   [Question] This test stubs `entityStore.put` to throw 
`SoftDeletedEntityIdentifierException`, so it proves the dispatcher's message 
mapping but not that the backend raises that exception on the import path. The 
backend half is proved separately in 
`TestSchemaMetaService.testCatalogReregistrationRejectsSoftDeletedIdentifiers`, 
which calls `backend.insert` directly. The same split applies to the new table 
and view soft-deleted tests.
   
   Nothing joins the two halves: remove catalog -> re-register -> 
`loadSchema`/`loadTable` is never exercised. If duplicates ever surface 
differently from `insertEntity` (a name conflict instead of a primary-key 
conflict, or a wrapped cause), `isSoftDeleted(e.id())` would return false and 
the generic multi-catalog message would be shown, while both existing unit 
tests keep passing.
   
   Is an integration-level test over the relational store planned for that 
flow? It would also give the reviewer's catalog re-registration scenario a 
regression guard.
   
   Verified by: read the new tests in TestSchemaOperationDispatcher, 
TestTableOperationDispatcher and TestViewOperationDispatcher (all stub 
`entityStore.put`), the new `TestSchemaMetaService` template test, and 
JDBCBackend.insert:194-206, whose catch block is reached only by the latter.



##########
core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java:
##########
@@ -177,16 +178,21 @@ public Table loadTable(NameIdentifier ident) throws 
NoSuchTableException {
         entityCombinedTable =
             TreeLockUtils.doWithTreeLock(schemaIdent, LockType.WRITE, () -> 
importTable(ident));
       } catch (EntityAlreadyExistsException e) {
-        // HA race: another Gravitino node concurrently imported this table. 
Reload from the
-        // entity store to pick up the entity stored by the winning node.
-        LOG.info(
-            "Table {} was concurrently imported by another node; reloading 
from store.", ident);
+        // A create-only import can conflict either because another Gravitino 
node won an HA race,
+        // or because the external identifier is already stored under another 
Gravitino path.
+        // Reload to distinguish a successful concurrent import from an 
identity conflict.
+        LOG.info("Table {} import conflicted with existing metadata; reloading 
from store.", ident);
         entityCombinedTable =
             TreeLockUtils.doWithTreeLock(ident, LockType.READ, () -> 
internalLoadTable(ident));
         if (!entityCombinedTable.imported()) {
           throw new UnsupportedOperationException(
-              "Table managed by multiple catalogs. This may cause unexpected 
issues such as privilege conflicts. "
-                  + "To resolve: Remove all catalogs managing this table, then 
recreate one catalog to ensure single-catalog management.");
+              "Table "
+                  + ident
+                  + " cannot be imported because its name or external 
identifier is already "
+                  + "managed under a different Gravitino identifier. Gravitino 
does not "
+                  + "automatically reconcile external table renames or manage 
the same table "
+                  + "through multiple paths because overwriting its stored 
identity can corrupt "
+                  + "privilege relationships. Reconcile the external metadata 
manually.");

Review Comment:
   [Important] This refusal reaches callers that only expect 
`NoSuchTableException`, so an identity conflict breaks existence pre-checks 
rather than returning false.
   
   `TableCatalog.tableExists` 
(api/src/main/java/org/apache/gravitino/rel/TableCatalog.java:91-97) and 
`SupportsSchemas.schemaExists` 
(api/src/main/java/org/apache/gravitino/SupportsSchemas.java:83-90) implement 
existence as "load and catch `NoSuchXException`", so the new 
`UnsupportedOperationException` propagates out of a boolean check. Affected 
call sites: LoadTableAuthorizationExecutor.java:91 (authorization filter, runs 
before the REST handler), MetadataObjectUtil.java:247/257/263 (privilege and 
tag object validation), FunctionOperationDispatcher.java:111/176/195 and 
SemanticModelOperationDispatcher.java:91/128.
   
   Net effect: once an external rename or multi-path conflict exists, the 
operator cannot check existence, and cannot validate or revoke the governance 
metadata that the new message asks them to reconcile - and the error surfaces 
from an authorization filter instead of the operation they called. Consider 
treating the import refusal as "exists" inside the `*Exists` paths, or 
confirming the filter maps it to a sane status instead of a 500.
   
   Verified by: read both API default methods and each call site listed on this 
branch; the same throw is added in ViewOperationDispatcher (new block at 
151-171) and SchemaOperationDispatcher (new block at 286-294), so all three 
types are affected.



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