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


##########
core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java:
##########
@@ -573,18 +593,165 @@ private EntityCombinedTable importTable(NameIdentifier 
identifier) {
             .withAuditInfo(audit)
             .build();
     try {
-      store.put(tableEntity, true);
-    } catch (EntityAlreadyExistsException e) {
+      if (caseAlias) {
+        TableEntity registered = store.get(observedOwner, TABLE, 
TableEntity.class);
+        if (registered.id() != uid) {
+          throw new OptimisticLockException(
+              "The registered owner of table ID %d changed during import; 
retry the load", uid);
+        }
+        return EntityCombinedTable.of(table.tableFromCatalog(), registered)
+            .withHiddenProperties(table.hiddenProperties());
+      }
+      if (observedOwner != null && !observedOwner.equals(identifier)) {
+        // Updating the observed row uses the store's version check. A second 
server that observed
+        // the same old name cannot move the row again after the first import 
commits.
+        store.update(
+            observedOwner,
+            TableEntity.class,
+            TABLE,
+            current -> withReusedColumnIds(tableEntity, current));
+      } else {
+        // Import a new name without overwrite so a concurrent import of the 
same ID cannot move
+        // its row. Preserve overwrite when repairing a registration already 
stored at this name.
+        boolean overwriteByName = stringId == null || store.exists(identifier, 
TABLE);
+        store.put(tableEntity, overwriteByName);

Review Comment:
   [Important] Inserting without overwrite also collides with **soft-deleted** 
rows, so a re-registered catalog becomes unloadable for up to a week.
   
   `table_meta`'s primary key is `table_id` alone 
(scripts/mysql/schema-0.5.0-mysql.sql:76), and dropping a catalog only sets 
`deleted_at` (`softDeleteTableMetasByCatalogId`, 
TableMetaBaseSQLProvider.java:313). The dead row keeps the ID until the GC 
removes it after `gravitino.entity.store.deleteAfterTimeMs` (default 7 days, 
Configs.java:64).
   
   Drop a catalog in Gravitino and re-create it over the same Hive metastore - 
the usual way to change catalog properties - and every external table still 
carries its old `gravitino.identifier`. `findRegisteredTableById` finds no 
*live* owner in the new schema (`listTablePOsBySchemaId` filters `deleted_at = 
0`), so this line inserts without overwrite, the plain INSERT 
(TableMetaBaseSQLProvider.insertTableMeta:205) hits the dead row's primary key, 
the converter maps that to `EntityAlreadyExistsException` 
(H2ExceptionConverter.java:40-44), and loadTable:188 tells the operator to 
remove a copied identifier from a table nobody copied. Nothing in that catalog 
loads until the GC runs. Before this PR the overwrite revived the row.
   
   The store guard was written to permit exactly this - 
`selectTableMetaByIdForUpdate` filters `deleted_at = 0` 
(TableMetaBaseSQLProvider.java:202) so a soft-deleted owner never blocks a 
re-import - but that guard is no longer on this path. Falling back to an 
overwrite when the conflicting ID has no live owner would keep both properties. 
`SchemaOperationDispatcher.java:696-697` has the identical shape (`schema_meta` 
PK is `schema_id`), and a re-registered catalog hits the schema first, so the 
failure arrives before any table is touched.
   
   Verified by: read the MySQL DDL, 
`insertTableMeta`/`selectTableMetaByIdForUpdate`/`softDeleteTableMetasByCatalogId`
 in TableMetaBaseSQLProvider on this branch, 
H2ExceptionConverter.toGravitinoException, Configs.java:64, and both dispatcher 
import paths. The PR's own TestTableMetaService.java:141 asserts that 
`backend.insert(copiedId, false)` throws `EntityAlreadyExistsException` on a 
duplicate ID, which is the same collision - only there the owner row is live.



##########
core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java:
##########
@@ -573,18 +593,165 @@ private EntityCombinedTable importTable(NameIdentifier 
identifier) {
             .withAuditInfo(audit)
             .build();
     try {
-      store.put(tableEntity, true);
-    } catch (EntityAlreadyExistsException e) {
+      if (caseAlias) {
+        TableEntity registered = store.get(observedOwner, TABLE, 
TableEntity.class);
+        if (registered.id() != uid) {
+          throw new OptimisticLockException(
+              "The registered owner of table ID %d changed during import; 
retry the load", uid);
+        }
+        return EntityCombinedTable.of(table.tableFromCatalog(), registered)
+            .withHiddenProperties(table.hiddenProperties());
+      }
+      if (observedOwner != null && !observedOwner.equals(identifier)) {
+        // Updating the observed row uses the store's version check. A second 
server that observed
+        // the same old name cannot move the row again after the first import 
commits.
+        store.update(
+            observedOwner,
+            TableEntity.class,
+            TABLE,
+            current -> withReusedColumnIds(tableEntity, current));
+      } else {
+        // Import a new name without overwrite so a concurrent import of the 
same ID cannot move
+        // its row. Preserve overwrite when repairing a registration already 
stored at this name.
+        boolean overwriteByName = stringId == null || store.exists(identifier, 
TABLE);
+        store.put(tableEntity, overwriteByName);
+      }
+    } catch (EntityAlreadyExistsException | OptimisticLockException e) {
       throw e;
+    } catch (NoSuchEntityException e) {
+      throw new OptimisticLockException(
+          e, "The registered owner of table ID %d changed during import; retry 
the load", uid);
     } catch (Exception e) {
-      LOG.error(FormattedErrorMessages.STORE_OP_FAILURE, "put", identifier, e);
+      LOG.error(FormattedErrorMessages.STORE_OP_FAILURE, "import", identifier, 
e);
       throw new RuntimeException("Failed to import the table entity to the 
store", e);
     }
 
     return EntityCombinedTable.of(table.tableFromCatalog(), tableEntity)
         .withHiddenProperties(table.hiddenProperties());
   }
 
+  /**
+   * Tells an external rename or case alias apart from a copied id before 
import.
+   *
+   * <p>An import that finds a {@link StringIdentifier} but no row under this 
name overwrites the
+   * row that owns the id. That is right after an external rename: the old 
name is gone and the row
+   * should follow the table. It is wrong when the id was copied ({@code 
CREATE TABLE t2 LIKE t1}
+   * carries {@code TBLPROPERTIES}, so does a copy tool or a restored backup): 
the source table is
+   * still there, and re-binding would move its row and every attachment keyed 
by that id (owner,
+   * tags, policies, role grants) to the copy. The store cannot tell the two 
apart; only the
+   * external catalog can, so this asks it whether the id's current owner 
still exists.
+   */
+  private Pair<NameIdentifier, Boolean> checkImportedIdNotCopied(
+      NameIdentifier identifier, long id) {
+    NameIdentifier currentOwner = 
findRegisteredTableById(identifier.namespace(), id);

Review Comment:
   [Important] The owner lookup is namespace-scoped, so an external 
**cross-schema rename** is now reported as a copied identifier and the table 
cannot be loaded at all.
   
   Gravitino moves tables between schemas itself (`RenameTable` with a new 
schema name, handled by `TableMetaService.updateTable`, which branches on 
`isSchemaChanged` at TableMetaService.java:221), so an out-of-band `ALTER TABLE 
db1.orders RENAME TO db2.orders` - Hive and Iceberg both support it - is a real 
case rather than a hypothetical.
   
   After such a rename, loading `db2.orders`: this line lists only `db2` 
(findRegisteredTableById, TableOperationDispatcher.java:742), returns null, so 
the external `tableExists(currentOwner)` check below never runs; the import 
falls to the insert without overwrite, which collides with the ID's row in 
`db1`, and loadTable:188 asks the operator to remove `gravitino.identifier` 
from a table nobody copied. Removing it mints a fresh ID, so the owner, tags, 
policies, role grants and statistics stay behind on the stale `db1.orders` row 
- the same loss this PR set out to prevent, reached from the other side. Before 
this PR the upsert followed the table.
   
   The earlier review's other suggestion covers this: widen the lookup to the 
catalog, so `tableExists(currentOwner)` gets to run and separates a 
cross-schema rename (source gone -> re-bind) from a cross-schema copy (source 
alive -> reject with the good message). The cross-parent store guard still 
backstops a genuine copy. Failing that, the message at loadTable:188 should not 
assert that the identifier was copied when nothing here has checked whether the 
other object still exists.
   
   Verified by: read `checkImportedIdNotCopied` (:644-690), 
`findRegisteredTableById` (:742-753), `importTable` (:547-640) and `loadTable` 
(:163-215) on this branch; `TableMetaService.updateTable` (:204-313) for the 
cross-schema move it supports; the PR's tests cover the cross-schema *copy* 
(TestTableOperationDispatcher.java:356) but no cross-schema rename.



##########
core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java:
##########
@@ -573,18 +593,165 @@ private EntityCombinedTable importTable(NameIdentifier 
identifier) {
             .withAuditInfo(audit)
             .build();
     try {
-      store.put(tableEntity, true);
-    } catch (EntityAlreadyExistsException e) {
+      if (caseAlias) {
+        TableEntity registered = store.get(observedOwner, TABLE, 
TableEntity.class);
+        if (registered.id() != uid) {
+          throw new OptimisticLockException(
+              "The registered owner of table ID %d changed during import; 
retry the load", uid);
+        }
+        return EntityCombinedTable.of(table.tableFromCatalog(), registered)
+            .withHiddenProperties(table.hiddenProperties());
+      }

Review Comment:
   [Question] The alias no longer writes, but every load of the alias spelling 
still takes the schema WRITE lock and two catalog round trips.
   
   `internalLoadTable` decides `imported` from `store.get(ident)` 
(TableOperationDispatcher.java:805-814), and this branch deliberately leaves no 
row under the alias spelling. So each load of `T` while `t` is stored is still 
`!imported`: `loadTable` re-enters `importTable` under `LockType.WRITE` on the 
schema (:175-176), and `checkImportedIdNotCopied` calls `tableExists` and then 
`listTables` on the external catalog (:657-669) before arriving here and 
returning the stored entity.
   
   That is a clear improvement over the rename-per-load this replaced - no 
version bump, no column rewrite, and `testLoadTableAcceptsCaseAlias` pins it. 
What remains is that reads of the alias serialize against every import in that 
schema and pay two extra RPCs each time, on an Iceberg REST catalog where 
aliases are the normal case. Would resolving the requested name to the stored 
one before the import decision (rather than after) be worth it, or is the alias 
spelling expected to be rare enough in practice?
   
   Verified by: read `importTable` (:547-640), `checkImportedIdNotCopied` 
(:644-690), `loadTable` (:163-215) and `internalLoadTable` (:764-815) on this 
branch, and `testLoadTableAcceptsCaseAlias` 
(TestTableOperationDispatcher.java:321-354), which asserts no `put`/`update` 
but does not look at the lock or the catalog calls.



##########
core/src/main/java/org/apache/gravitino/storage/relational/service/TopicMetaService.java:
##########
@@ -81,6 +81,14 @@ public void insertTopic(TopicEntity topicEntity, boolean 
overwrite) throws IOExc
                       TopicMetaMapper.class,
                       mapper -> {
                         if (overwrite) {
+                          // A copied id must not move another schema's topic 
here.
+                          
OccWriteSupport.checkOverwriteIdNotOwnedByOtherParent(
+                              () -> 
mapper.selectTopicMetaByIdForUpdate(po.getTopicId()),
+                              owner -> Objects.equals(owner.getSchemaId(), 
po.getSchemaId()),
+                              owner ->
+                                  String.format(
+                                      "Topic ID %d belongs to schema ID %d, 
not schema ID %d",
+                                      po.getTopicId(), owner.getSchemaId(), 
po.getSchemaId()));

Review Comment:
   [Nit] For topics this guard's message never reaches the caller.
   
   `TopicOperationDispatcher.importTopic` still does `store.put(topicEntity, 
true)` inside a `try { ... } catch (Exception e)` that wraps everything into 
`RuntimeException("Failed to import topic entity to the store")` 
(TopicOperationDispatcher.java:311-316) - it has no 
`EntityAlreadyExistsException` branch, and no dispatcher-side 
`checkImportedIdNotCopied`. So the careful "Topic ID %d belongs to schema ID 
%d, not schema ID %d" you build here is swallowed into a 500 with a generic 
message, and the same-parent copy that the table and schema dispatchers now 
reject is still accepted for topics.
   
   Not necessarily wrong to leave as is - a Kafka topic's ID comes from the 
topic UUID rather than copied properties 
(TopicOperationDispatcher.java:283-290), so the copy vector is much weaker 
here. But it is worth a line in the PR description saying topics get the store 
guard only, or at least letting `importTopic` rethrow 
`EntityAlreadyExistsException` so the message survives.
   
   Verified by: read `TopicOperationDispatcher.importTopic` (:267-317) and 
`internalLoadTopic` (:319-340) on this branch, and grepped for 
`checkImportedIdNotCopied` - two definitions, in the table and schema 
dispatchers only.



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