This is an automated email from the ASF dual-hosted git repository.
yuqi1129 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 7beb6aed96 [Cherry-pick to branch-1.3] [#11040] fix(core): make
Iceberg import idempotent under concurrent load (#11041) (#11340)
7beb6aed96 is described below
commit 7beb6aed96854ac5a34d7062c1e40a0826fff565
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Jun 2 17:12:07 2026 +0800
[Cherry-pick to branch-1.3] [#11040] fix(core): make Iceberg import
idempotent under concurrent load (#11041) (#11340)
**Cherry-pick Information:**
- Original commit: 562c56772c9b6965e7ed2debb47b55da49c5d6b6
- Target branch: `branch-1.3`
- Status: ✅ Clean cherry-pick (no conflicts)
Co-authored-by: Qi Yu <[email protected]>
---
.../catalog/SchemaOperationDispatcher.java | 33 ++++--
.../catalog/TableOperationDispatcher.java | 23 +++-
.../catalog/TestSchemaOperationDispatcher.java | 73 ++++++++++++
.../catalog/TestTableOperationDispatcher.java | 126 +++++++++++++++++++++
.../dispatcher/IcebergTableHookDispatcher.java | 99 ++++++++++++----
.../dispatcher/IcebergViewHookDispatcher.java | 113 ++++++++++++------
.../dispatcher/TestIcebergTableHookDispatcher.java | 90 ++++++++++++++-
.../dispatcher/TestIcebergViewHookDispatcher.java | 74 +++++++++++-
8 files changed, 541 insertions(+), 90 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
b/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
index ffcdc0ad6a..9ab9df5588 100644
---
a/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
+++
b/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
@@ -192,13 +192,27 @@ public class SchemaOperationDispatcher extends
OperationDispatcher implements Sc
TreeLockUtils.doWithTreeLock(ident, LockType.READ, () ->
internalLoadSchema(ident));
if (!schema.imported()) {
- TreeLockUtils.doWithTreeLock(
- NameIdentifier.of(ident.namespace().levels()),
- LockType.WRITE,
- () -> {
- importSchema(ident);
- return null;
- });
+ try {
+ TreeLockUtils.doWithTreeLock(
+ NameIdentifier.of(ident.namespace().levels()),
+ LockType.WRITE,
+ () -> {
+ importSchema(ident);
+ return null;
+ });
+ } catch (EntityAlreadyExistsException e) {
+ // HA race: another Gravitino node concurrently imported this schema.
Reload from the
+ // entity store to verify the entity stored by the winning node is
consistent.
+ LOG.info(
+ "Schema {} was concurrently imported by another node; reloading
from store.", ident);
+ EntityCombinedSchema reloaded =
+ TreeLockUtils.doWithTreeLock(ident, LockType.READ, () ->
internalLoadSchema(ident));
+ if (!reloaded.imported()) {
+ throw new UnsupportedOperationException(
+ "Schema managed by multiple catalogs. This may cause unexpected
issues such as privilege conflicts. "
+ + "To resolve: Remove all catalogs managing this schema,
then recreate one catalog to ensure single-catalog management.");
+ }
+ }
}
return schema;
@@ -438,10 +452,7 @@ public class SchemaOperationDispatcher extends
OperationDispatcher implements Sc
try {
store.put(schemaEntity, true);
} catch (EntityAlreadyExistsException e) {
- LOG.error("Failed to import schema {} with id {} to the store.",
identifier, uid, e);
- throw new UnsupportedOperationException(
- "Schema managed by multiple catalogs. This may cause unexpected
issues such as privilege conflicts. "
- + "To resolve: Remove all catalogs managing this schema, then
recreate one catalog to ensure single-catalog management.");
+ throw e;
} catch (Exception e) {
LOG.error(FormattedErrorMessages.STORE_OP_FAILURE, "put", identifier, e);
throw new RuntimeException("Fail to import schema entity to the store.",
e);
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
index 57499b6f6d..244edec88d 100644
---
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
+++
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
@@ -123,8 +123,22 @@ public class TableOperationDispatcher extends
OperationDispatcher implements Tab
schemaDispatcher.loadSchema(schemaIdent);
// Import the table.
- entityCombinedTable =
- TreeLockUtils.doWithTreeLock(schemaIdent, LockType.WRITE, () ->
importTable(ident));
+ try {
+ 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);
+ 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.");
+ }
+ }
}
// Update the column entities in Gravitino store if the columns are
different from the ones
@@ -477,10 +491,7 @@ public class TableOperationDispatcher extends
OperationDispatcher implements Tab
try {
store.put(tableEntity, true);
} catch (EntityAlreadyExistsException e) {
- LOG.error("Failed to import table {} with id {} to the store.",
identifier, uid, e);
- 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.");
+ throw e;
} catch (Exception e) {
LOG.error(FormattedErrorMessages.STORE_OP_FAILURE, "put", identifier, e);
throw new RuntimeException("Fail to import the table entity to the
store.", e);
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java
b/core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java
index 084eb6c105..455e428c34 100644
---
a/core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestSchemaOperationDispatcher.java
@@ -40,6 +40,7 @@ import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Config;
import org.apache.gravitino.Configs;
import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
@@ -205,6 +206,78 @@ public class TestSchemaOperationDispatcher extends
TestOperationDispatcher {
Assertions.assertEquals("test", loadedSchema3.auditInfo().creator());
}
+ @Test
+ public void testConcurrentImportSchemaReusesExistingEntity() throws
IOException {
+ NameIdentifier schemaIdent = NameIdentifier.of(metalake, catalog,
"schemaConcurrent");
+ Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+ dispatcher.createSchema(schemaIdent, "comment", props);
+ SchemaEntity importedSchemaEntity = entityStore.get(schemaIdent, SCHEMA,
SchemaEntity.class);
+
+ AuditInfo concurrentAudit =
+
AuditInfo.builder().withCreator("concurrent").withCreateTime(Instant.now()).build();
+ SchemaEntity concurrentSchemaEntity =
+ SchemaEntity.builder()
+ .withId(importedSchemaEntity.id())
+ .withName(schemaIdent.name())
+ .withNamespace(schemaIdent.namespace())
+ .withAuditInfo(concurrentAudit)
+ .build();
+
+ // Simulate HA race: first two gets return not-found (so both the
pre-import check and the
+ // internalLoadSchema inside importSchema proceed to store.put), then put
throws
+ // EntityAlreadyExistsException, and the dispatcher-level retry sees the
entity on the third
+ // get.
+ reset(entityStore);
+ doThrow(new NoSuchEntityException("mock error"))
+ .doThrow(new NoSuchEntityException("mock error"))
+ .doReturn(concurrentSchemaEntity)
+ .when(entityStore)
+ .get(any(), eq(Entity.EntityType.SCHEMA), any());
+ doThrow(new EntityAlreadyExistsException("mock conflict"))
+ .when(entityStore)
+ .put(any(), anyBoolean());
+
+ Schema loadedSchema = Assertions.assertDoesNotThrow(() ->
dispatcher.loadSchema(schemaIdent));
+ Assertions.assertEquals(schemaIdent.name(), loadedSchema.name());
+ Assertions.assertEquals("comment", loadedSchema.comment());
+ }
+
+ @Test
+ public void testConcurrentImportSchemaFailsOnMismatchedIdentifier() throws
IOException {
+ NameIdentifier schemaIdent = NameIdentifier.of(metalake, catalog,
"schemaConcurrentMismatch");
+ Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+ dispatcher.createSchema(schemaIdent, "comment", props);
+ SchemaEntity importedSchemaEntity = entityStore.get(schemaIdent, SCHEMA,
SchemaEntity.class);
+
+ AuditInfo concurrentAudit =
+
AuditInfo.builder().withCreator("concurrent").withCreateTime(Instant.now()).build();
+ SchemaEntity mismatchedSchemaEntity =
+ SchemaEntity.builder()
+ .withId(importedSchemaEntity.id() + 1)
+ .withName(schemaIdent.name())
+ .withNamespace(schemaIdent.namespace())
+ .withAuditInfo(concurrentAudit)
+ .build();
+
+ // Simulate genuine multi-catalog conflict: put fails, and the
dispatcher-level retry finds
+ // an entity with a mismatched ID (operateOnEntity returns null →
imported=false → error
+ // thrown).
+ reset(entityStore);
+ doThrow(new NoSuchEntityException("mock error"))
+ .doThrow(new NoSuchEntityException("mock error"))
+ .doReturn(mismatchedSchemaEntity)
+ .when(entityStore)
+ .get(any(), eq(Entity.EntityType.SCHEMA), any());
+ doThrow(new EntityAlreadyExistsException("mock conflict"))
+ .when(entityStore)
+ .put(any(), anyBoolean());
+
+ UnsupportedOperationException exception =
+ Assertions.assertThrows(
+ UnsupportedOperationException.class, () ->
dispatcher.loadSchema(schemaIdent));
+ Assertions.assertTrue(exception.getMessage().contains("Schema managed by
multiple catalogs"));
+ }
+
@Test
public void testCreateAndAlterSchema() throws IOException {
NameIdentifier schemaIdent = NameIdentifier.of(metalake, catalog,
"schema21");
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
index d0016776c9..9c44e46c7e 100644
---
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
@@ -44,9 +44,11 @@ import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
+import java.util.stream.IntStream;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Config;
import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
@@ -256,6 +258,130 @@ public class TestTableOperationDispatcher extends
TestOperationDispatcher {
Assertions.assertEquals("test", loadedTable4.auditInfo().creator());
}
+ @Test
+ public void testConcurrentImportTableReusesExistingEntity() throws
IOException {
+ Namespace tableNs = Namespace.of(metalake, catalog, "schema52");
+ Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+
schemaOperationDispatcher.createSchema(NameIdentifier.of(tableNs.levels()),
"comment", props);
+
+ NameIdentifier tableIdent = NameIdentifier.of(tableNs, "tableConcurrent");
+ Column[] columns =
+ new Column[] {
+ TestColumn.builder()
+ .withName("col1")
+ .withPosition(0)
+ .withType(Types.StringType.get())
+ .build(),
+ TestColumn.builder()
+ .withName("col2")
+ .withPosition(1)
+ .withType(Types.StringType.get())
+ .build()
+ };
+
+ Table table =
+ tableOperationDispatcher.createTable(
+ tableIdent, columns, "comment", props, new Transform[0]);
+ TableEntity importedTableEntity = entityStore.get(tableIdent, TABLE,
TableEntity.class);
+
+ AuditInfo concurrentAudit =
+
AuditInfo.builder().withCreator("concurrent").withCreateTime(Instant.now()).build();
+ TableEntity concurrentTableEntity =
+ TableEntity.builder()
+ .withId(importedTableEntity.id())
+ .withName(tableIdent.name())
+ .withNamespace(tableIdent.namespace())
+ .withColumns(
+ IntStream.range(0, table.columns().length)
+ .mapToObj(
+ i ->
+ ColumnEntity.toColumnEntity(table.columns()[i], i,
0L, concurrentAudit))
+ .collect(Collectors.toList()))
+ .withAuditInfo(concurrentAudit)
+ .build();
+
+ // Simulate HA race: first two gets return not-found (so both the
pre-import check and the
+ // internalLoadTable inside importTable proceed to store.put), then put
throws
+ // EntityAlreadyExistsException, and the dispatcher-level retry sees the
entity on the third
+ // get.
+ reset(entityStore);
+ doThrow(new NoSuchEntityException("mock error"))
+ .doThrow(new NoSuchEntityException("mock error"))
+ .doReturn(concurrentTableEntity)
+ .when(entityStore)
+ .get(any(), eq(Entity.EntityType.TABLE), any());
+ doThrow(new EntityAlreadyExistsException("mock conflict"))
+ .when(entityStore)
+ .put(any(), anyBoolean());
+
+ Table loadedTable =
+ Assertions.assertDoesNotThrow(() ->
tableOperationDispatcher.loadTable(tableIdent));
+ Assertions.assertEquals(tableIdent.name(), loadedTable.name());
+ Assertions.assertEquals("comment", loadedTable.comment());
+ }
+
+ @Test
+ public void testConcurrentImportTableFailsOnMismatchedIdentifier() throws
IOException {
+ Namespace tableNs = Namespace.of(metalake, catalog,
"schemaConcurrentMismatch");
+ Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+
schemaOperationDispatcher.createSchema(NameIdentifier.of(tableNs.levels()),
"comment", props);
+
+ NameIdentifier tableIdent = NameIdentifier.of(tableNs,
"tableConcurrentMismatch");
+ Column[] columns =
+ new Column[] {
+ TestColumn.builder()
+ .withName("col1")
+ .withPosition(0)
+ .withType(Types.StringType.get())
+ .build(),
+ TestColumn.builder()
+ .withName("col2")
+ .withPosition(1)
+ .withType(Types.StringType.get())
+ .build()
+ };
+
+ Table table =
+ tableOperationDispatcher.createTable(
+ tableIdent, columns, "comment", props, new Transform[0]);
+ TableEntity importedTableEntity = entityStore.get(tableIdent, TABLE,
TableEntity.class);
+
+ AuditInfo concurrentAudit =
+
AuditInfo.builder().withCreator("concurrent").withCreateTime(Instant.now()).build();
+ TableEntity mismatchedTableEntity =
+ TableEntity.builder()
+ .withId(importedTableEntity.id() + 1)
+ .withName(tableIdent.name())
+ .withNamespace(tableIdent.namespace())
+ .withColumns(
+ IntStream.range(0, table.columns().length)
+ .mapToObj(
+ i ->
+ ColumnEntity.toColumnEntity(table.columns()[i], i,
0L, concurrentAudit))
+ .collect(Collectors.toList()))
+ .withAuditInfo(concurrentAudit)
+ .build();
+
+ // Simulate genuine multi-catalog conflict: put fails, and the
dispatcher-level retry finds
+ // an entity with a mismatched ID (operateOnEntity returns null →
imported=false → error
+ // thrown).
+ reset(entityStore);
+ doThrow(new NoSuchEntityException("mock error"))
+ .doThrow(new NoSuchEntityException("mock error"))
+ .doReturn(mismatchedTableEntity)
+ .when(entityStore)
+ .get(any(), eq(Entity.EntityType.TABLE), any());
+ doThrow(new EntityAlreadyExistsException("mock conflict"))
+ .when(entityStore)
+ .put(any(), anyBoolean());
+
+ UnsupportedOperationException exception =
+ Assertions.assertThrows(
+ UnsupportedOperationException.class,
+ () -> tableOperationDispatcher.loadTable(tableIdent));
+ Assertions.assertTrue(exception.getMessage().contains("Table managed by
multiple catalogs"));
+ }
+
@Test
public void testCreateAndAlterTable() throws IOException {
Namespace tableNs = Namespace.of(metalake, catalog, "schema61");
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
index 1bd75e9b59..9f081571f5 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergTableHookDispatcher.java
@@ -45,9 +45,13 @@ import org.apache.iceberg.rest.responses.ListTablesResponse;
import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
import org.apache.iceberg.rest.responses.LoadTableResponse;
import org.apache.iceberg.rest.responses.PlanTableScanResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
public class IcebergTableHookDispatcher implements
IcebergTableOperationDispatcher {
+ private static final Logger LOG =
LoggerFactory.getLogger(IcebergTableHookDispatcher.class);
+
private final IcebergTableOperationDispatcher dispatcher;
private String metalake;
@@ -92,23 +96,10 @@ public class IcebergTableHookDispatcher implements
IcebergTableOperationDispatch
public void dropTable(
IcebergRequestContext context, TableIdentifier tableIdentifier, boolean
purgeRequested) {
dispatcher.dropTable(context, tableIdentifier, purgeRequested);
- EntityStore store = GravitinoEnv.getInstance().entityStore();
- try {
- if (store != null) {
- // Delete the entity for the dropped table.
- store.delete(
- IcebergIdentifierUtils.toGravitinoTableIdentifier(
- metalake,
- context.catalogName(),
- tableIdentifier,
- HierarchicalSchemaUtil.schemaSeparator()),
- Entity.EntityType.TABLE);
- }
- } catch (NoSuchEntityException ignore) {
- // Ignore if the table entity does not exist.
- } catch (IOException ioe) {
- throw new RuntimeException("io exception when deleting table entity",
ioe);
- }
+ // Reconcile against Iceberg backend state — without a distributed
TreeLock,
+ // another node may recreate the same table between the drop above and the
+ // EntityStore delete, leaving a stale Gravitino entity if we blindly
delete.
+ bestEffortReconcileTableEntity(context, tableIdentifier);
}
@Override
@@ -165,6 +156,12 @@ public class IcebergTableHookDispatcher implements
IcebergTableOperationDispatch
} catch (IOException ioe) {
throw new RuntimeException("io exception when renaming table entity",
ioe);
}
+
+ // IRC rename can race with another node's drop/create on either name.
+ // Reconcile both ends against the Iceberg backend so we don't leave a
+ // stale entity on the source or miss importing a re-created destination.
+ bestEffortReconcileTableEntity(context, renameTableRequest.source());
+ bestEffortReconcileTableEntity(context, renameTableRequest.destination());
}
@Override
@@ -205,21 +202,73 @@ public class IcebergTableHookDispatcher implements
IcebergTableOperationDispatch
// Import is intentionally NOT wrapped in try-catch: if it fails the table
exists in Iceberg
// but not in Gravitino, and silently swallowing that would mislead
callers into thinking the
// entity is registered. Surface the failure so the caller can react.
+ importTableEntity(context.catalogName(), namespace, tableName);
+ IcebergOwnershipUtils.setTableOwner(
+ metalake,
+ context.catalogName(),
+ namespace,
+ tableName,
+ context.userName(),
+ GravitinoEnv.getInstance().ownerDispatcher());
+ }
+
+ private void importTableEntity(String catalogName, Namespace namespace,
String tableName) {
TableDispatcher tableDispatcher =
GravitinoEnv.getInstance().tableDispatcher();
if (tableDispatcher != null) {
tableDispatcher.loadTable(
IcebergIdentifierUtils.toGravitinoTableIdentifier(
metalake,
- context.catalogName(),
+ catalogName,
TableIdentifier.of(namespace, tableName),
HierarchicalSchemaUtil.schemaSeparator()));
}
- IcebergOwnershipUtils.setTableOwner(
- metalake,
- context.catalogName(),
- namespace,
- tableName,
- context.userName(),
- GravitinoEnv.getInstance().ownerDispatcher());
+ }
+
+ private void reconcileTableEntity(
+ IcebergRequestContext context, TableIdentifier tableIdentifier) {
+ // IRC requests can be served by different Gravitino nodes. Without a
distributed TreeLock,
+ // another node may drop or recreate the same Iceberg table between the
backend operation and
+ // this hook's EntityStore mutation. Reconcile the local Gravitino entity
with the Iceberg
+ // backend state to avoid leaving stale/orphan table metadata in
multi-node deployments.
+ if (dispatcher.tableExists(context, tableIdentifier)) {
+ importTableEntity(context.catalogName(), tableIdentifier.namespace(),
tableIdentifier.name());
+ return;
+ }
+
+ deleteTableEntity(context.catalogName(), tableIdentifier);
+
+ if (dispatcher.tableExists(context, tableIdentifier)) {
+ importTableEntity(context.catalogName(), tableIdentifier.namespace(),
tableIdentifier.name());
+ }
+ }
+
+ private void bestEffortReconcileTableEntity(
+ IcebergRequestContext context, TableIdentifier tableIdentifier) {
+ try {
+ reconcileTableEntity(context, tableIdentifier);
+ } catch (RuntimeException e) {
+ LOG.warn(
+ "Failed to reconcile Gravitino table entity after the Iceberg
backend operation "
+ + "succeeded. catalog={}, table={}",
+ context.catalogName(),
+ tableIdentifier,
+ e);
+ }
+ }
+
+ private void deleteTableEntity(String catalogName, TableIdentifier
tableIdentifier) {
+ EntityStore store = GravitinoEnv.getInstance().entityStore();
+ try {
+ if (store != null) {
+ store.delete(
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ metalake, catalogName, tableIdentifier,
HierarchicalSchemaUtil.schemaSeparator()),
+ Entity.EntityType.TABLE);
+ }
+ } catch (NoSuchEntityException ignore) {
+ // Ignore if the table entity does not exist.
+ } catch (IOException ioe) {
+ throw new RuntimeException("io exception when deleting table entity",
ioe);
+ }
}
}
diff --git
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergViewHookDispatcher.java
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergViewHookDispatcher.java
index 22d22724cd..a6e35fef50 100644
---
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergViewHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergViewHookDispatcher.java
@@ -97,43 +97,10 @@ public class IcebergViewHookDispatcher implements
IcebergViewOperationDispatcher
@Override
public void dropView(IcebergRequestContext context, TableIdentifier
viewIdentifier) {
dispatcher.dropView(context, viewIdentifier);
-
- // Remove view from Gravitino entity store
- EntityStore store = GravitinoEnv.getInstance().entityStore();
- try {
- if (store != null) {
- store.delete(
- IcebergIdentifierUtils.toGravitinoTableIdentifier(
- metalake,
- context.catalogName(),
- viewIdentifier,
- HierarchicalSchemaUtil.schemaSeparator()),
- Entity.EntityType.VIEW);
- LOG.info(
- "Successfully removed view from Gravitino entity store:
{}.{}.{}.{}",
- metalake,
- context.catalogName(),
- viewIdentifier.namespace(),
- viewIdentifier.name());
- }
- } catch (NoSuchEntityException ignore) {
- // Ignore if the view entity does not exist in the store
- LOG.debug(
- "View entity does not exist in store: {}.{}.{}.{}",
- metalake,
- context.catalogName(),
- viewIdentifier.namespace(),
- viewIdentifier.name());
- } catch (IOException ioe) {
- LOG.error(
- "Failed to delete view entity from store: {}.{}.{}.{}",
- metalake,
- context.catalogName(),
- viewIdentifier.namespace(),
- viewIdentifier.name(),
- ioe);
- throw new RuntimeException("Failed to delete view entity from store",
ioe);
- }
+ // Reconcile against Iceberg backend state — without a distributed
TreeLock,
+ // another node may recreate the same view between the drop above and the
+ // EntityStore delete, leaving a stale Gravitino entity if we blindly
delete.
+ bestEffortReconcileViewEntity(context, viewIdentifier);
}
@Override
@@ -191,6 +158,12 @@ public class IcebergViewHookDispatcher implements
IcebergViewOperationDispatcher
LOG.error("Failed to rename view entity in store from {} to {}",
sourceIdent, destIdent, ioe);
throw new RuntimeException("Failed to rename view entity in store", ioe);
}
+
+ // IRC rename can race with another node's drop/create on either name.
+ // Reconcile both ends against the Iceberg backend so we don't leave a
+ // stale entity on the source or miss importing a re-created destination.
+ bestEffortReconcileViewEntity(context, renameViewRequest.source());
+ bestEffortReconcileViewEntity(context, renameViewRequest.destination());
}
/**
@@ -235,4 +208,70 @@ public class IcebergViewHookDispatcher implements
IcebergViewOperationDispatcher
}
}
}
+
+ private void reconcileViewEntity(IcebergRequestContext context,
TableIdentifier viewIdentifier) {
+ // IRC requests can be served by different Gravitino nodes. Without a
distributed TreeLock,
+ // another node may drop or recreate the same Iceberg view between the
backend operation and
+ // this hook's EntityStore mutation. Reconcile the local Gravitino entity
with the Iceberg
+ // backend state to avoid leaving stale/orphan view metadata in multi-node
deployments.
+ if (dispatcher.viewExists(context, viewIdentifier)) {
+ importView(context.catalogName(), viewIdentifier.namespace(),
viewIdentifier.name());
+ return;
+ }
+
+ deleteViewEntity(context.catalogName(), viewIdentifier);
+
+ if (dispatcher.viewExists(context, viewIdentifier)) {
+ importView(context.catalogName(), viewIdentifier.namespace(),
viewIdentifier.name());
+ }
+ }
+
+ private void bestEffortReconcileViewEntity(
+ IcebergRequestContext context, TableIdentifier viewIdentifier) {
+ try {
+ reconcileViewEntity(context, viewIdentifier);
+ } catch (RuntimeException e) {
+ LOG.warn(
+ "Failed to reconcile Gravitino view entity after the Iceberg backend
operation "
+ + "succeeded. catalog={}, view={}",
+ context.catalogName(),
+ viewIdentifier,
+ e);
+ }
+ }
+
+ private void deleteViewEntity(String catalogName, TableIdentifier
viewIdentifier) {
+ EntityStore store = GravitinoEnv.getInstance().entityStore();
+ try {
+ if (store != null) {
+ store.delete(
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ metalake, catalogName, viewIdentifier,
HierarchicalSchemaUtil.schemaSeparator()),
+ Entity.EntityType.VIEW);
+ LOG.info(
+ "Successfully removed view from Gravitino entity store:
{}.{}.{}.{}",
+ metalake,
+ catalogName,
+ viewIdentifier.namespace(),
+ viewIdentifier.name());
+ }
+ } catch (NoSuchEntityException ignore) {
+ // Ignore if the view entity does not exist in the store
+ LOG.debug(
+ "View entity does not exist in store: {}.{}.{}.{}",
+ metalake,
+ catalogName,
+ viewIdentifier.namespace(),
+ viewIdentifier.name());
+ } catch (IOException ioe) {
+ LOG.error(
+ "Failed to delete view entity from store: {}.{}.{}.{}",
+ metalake,
+ catalogName,
+ viewIdentifier.namespace(),
+ viewIdentifier.name(),
+ ioe);
+ throw new RuntimeException("Failed to delete view entity from store",
ioe);
+ }
+ }
}
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
index 6b03109c94..2d4f2e125c 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergTableHookDispatcher.java
@@ -184,6 +184,36 @@ public class TestIcebergTableHookDispatcher {
verify(mockEntityStore).delete(expectedIdentifier,
Entity.EntityType.TABLE);
}
+ @Test
+ public void testDropTableReimportsEntityWhenTableExistsAfterDrop() throws
IOException {
+ TableIdentifier tableId = TableIdentifier.of("test_schema", "test_table");
+ when(mockDispatcher.tableExists(mockContext, tableId)).thenReturn(true);
+
+ hookDispatcher.dropTable(mockContext, tableId, false);
+
+ verify(mockDispatcher).dropTable(mockContext, tableId, false);
+ NameIdentifier expectedIdentifier =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ TEST_METALAKE, TEST_CATALOG, tableId, ":");
+ verify(mockEntityStore, never()).delete(expectedIdentifier,
Entity.EntityType.TABLE);
+ verify(mockTableDispatcher).loadTable(expectedIdentifier);
+ }
+
+ @Test
+ public void testDropTableReimportsEntityWhenTableIsRecreatedDuringDelete()
throws IOException {
+ TableIdentifier tableId = TableIdentifier.of("test_schema", "test_table");
+ when(mockDispatcher.tableExists(mockContext, tableId)).thenReturn(false,
true);
+
+ hookDispatcher.dropTable(mockContext, tableId, false);
+
+ verify(mockDispatcher).dropTable(mockContext, tableId, false);
+ NameIdentifier expectedIdentifier =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(
+ TEST_METALAKE, TEST_CATALOG, tableId, ":");
+ verify(mockEntityStore).delete(expectedIdentifier,
Entity.EntityType.TABLE);
+ verify(mockTableDispatcher).loadTable(expectedIdentifier);
+ }
+
@Test
public void testDropTableIgnoresNoSuchEntityException() throws IOException {
TableIdentifier tableId = TableIdentifier.of("test_schema", "test_table");
@@ -202,16 +232,12 @@ public class TestIcebergTableHookDispatcher {
}
@Test
- public void testDropTableThrowsRuntimeExceptionOnIOException() throws
IOException {
+ public void testDropTableIgnoresReconciliationIOException() throws
IOException {
TableIdentifier tableId = TableIdentifier.of("test_schema", "test_table");
doThrow(new IOException("IO error")).when(mockEntityStore).delete(any(),
any());
- RuntimeException exception =
- Assertions.assertThrows(
- RuntimeException.class, () ->
hookDispatcher.dropTable(mockContext, tableId, false));
-
- Assertions.assertTrue(exception.getMessage().contains("io exception when
deleting table"));
+ Assertions.assertDoesNotThrow(() -> hookDispatcher.dropTable(mockContext,
tableId, false));
verify(mockDispatcher).dropTable(mockContext, tableId, false);
}
@@ -242,6 +268,58 @@ public class TestIcebergTableHookDispatcher {
.update(eq(sourceIdentifier), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any());
}
+ @Test
+ public void testRenameTableReconcilesSourceAndDestinationEntities() throws
IOException {
+ TableIdentifier source = TableIdentifier.of("schema1", "old_table");
+ TableIdentifier dest = TableIdentifier.of("schema2", "new_table");
+ RenameTableRequest request =
+
RenameTableRequest.builder().withSource(source).withDestination(dest).build();
+
+ TableEntity mockTableEntity = mock(TableEntity.class);
+ when(mockTableEntity.id()).thenReturn(1L);
+ when(mockTableEntity.columns()).thenReturn(Collections.emptyList());
+ AuditInfo auditInfo =
+
AuditInfo.builder().withCreator("original_creator").withCreateTime(Instant.now()).build();
+ when(mockTableEntity.auditInfo()).thenReturn(auditInfo);
+ when(mockEntityStore.update(any(), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenReturn(mockTableEntity);
+ when(mockDispatcher.tableExists(mockContext, source)).thenReturn(false,
false);
+ when(mockDispatcher.tableExists(mockContext, dest)).thenReturn(true);
+
+ hookDispatcher.renameTable(mockContext, request);
+
+ NameIdentifier sourceIdentifier =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(TEST_METALAKE,
TEST_CATALOG, source, ":");
+ NameIdentifier destIdentifier =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(TEST_METALAKE,
TEST_CATALOG, dest, ":");
+ verify(mockEntityStore).delete(sourceIdentifier, Entity.EntityType.TABLE);
+ verify(mockTableDispatcher).loadTable(destIdentifier);
+ }
+
+ @Test
+ public void testRenameTableIgnoresReconciliationImportFailure() throws
IOException {
+ TableIdentifier source = TableIdentifier.of("schema1", "old_table");
+ TableIdentifier dest = TableIdentifier.of("schema2", "new_table");
+ RenameTableRequest request =
+
RenameTableRequest.builder().withSource(source).withDestination(dest).build();
+
+ TableEntity mockTableEntity = mock(TableEntity.class);
+ when(mockTableEntity.id()).thenReturn(1L);
+ when(mockTableEntity.columns()).thenReturn(Collections.emptyList());
+ AuditInfo auditInfo =
+
AuditInfo.builder().withCreator("original_creator").withCreateTime(Instant.now()).build();
+ when(mockTableEntity.auditInfo()).thenReturn(auditInfo);
+ when(mockEntityStore.update(any(), eq(TableEntity.class),
eq(Entity.EntityType.TABLE), any()))
+ .thenReturn(mockTableEntity);
+ when(mockDispatcher.tableExists(mockContext, dest)).thenReturn(true);
+ doThrow(new RuntimeException("import
failed")).when(mockTableDispatcher).loadTable(any());
+
+ Assertions.assertDoesNotThrow(() ->
hookDispatcher.renameTable(mockContext, request));
+
+ verify(mockDispatcher).renameTable(mockContext, request);
+ verify(mockTableDispatcher).loadTable(any());
+ }
+
@Test
public void testRenameTableIgnoresNoSuchEntityException() throws IOException
{
TableIdentifier source = TableIdentifier.of("schema1", "old_table");
diff --git
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergViewHookDispatcher.java
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergViewHookDispatcher.java
index 45faf33f9b..4fdf1426b9 100644
---
a/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergViewHookDispatcher.java
+++
b/iceberg/iceberg-rest-server/src/test/java/org/apache/gravitino/iceberg/service/dispatcher/TestIcebergViewHookDispatcher.java
@@ -257,6 +257,34 @@ public class TestIcebergViewHookDispatcher {
verify(mockEntityStore, times(1)).delete(eq(expectedIdent),
eq(Entity.EntityType.VIEW));
}
+ @Test
+ public void testDropViewReimportsEntityWhenViewExistsAfterDrop() throws
Exception {
+ TableIdentifier viewIdent = TableIdentifier.of(Namespace.of(SCHEMA_NAME),
VIEW_NAME);
+ when(mockExecutor.viewExists(mockContext, viewIdent)).thenReturn(true);
+
+ hookDispatcher.dropView(mockContext, viewIdent);
+
+ verify(mockExecutor, times(1)).dropView(mockContext, viewIdent);
+ NameIdentifier expectedIdent =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(METALAKE, CATALOG,
viewIdent, ":");
+ verify(mockEntityStore, never()).delete(eq(expectedIdent),
eq(Entity.EntityType.VIEW));
+ verify(mockViewDispatcher, times(1)).loadView(eq(expectedIdent));
+ }
+
+ @Test
+ public void testDropViewReimportsEntityWhenViewIsRecreatedDuringDelete()
throws Exception {
+ TableIdentifier viewIdent = TableIdentifier.of(Namespace.of(SCHEMA_NAME),
VIEW_NAME);
+ when(mockExecutor.viewExists(mockContext, viewIdent)).thenReturn(false,
true);
+
+ hookDispatcher.dropView(mockContext, viewIdent);
+
+ verify(mockExecutor, times(1)).dropView(mockContext, viewIdent);
+ NameIdentifier expectedIdent =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(METALAKE, CATALOG,
viewIdent, ":");
+ verify(mockEntityStore, times(1)).delete(eq(expectedIdent),
eq(Entity.EntityType.VIEW));
+ verify(mockViewDispatcher, times(1)).loadView(eq(expectedIdent));
+ }
+
@Test
public void testDropViewHandlesMissingEntity() throws Exception {
TableIdentifier viewIdent = TableIdentifier.of(Namespace.of(SCHEMA_NAME),
VIEW_NAME);
@@ -276,7 +304,7 @@ public class TestIcebergViewHookDispatcher {
}
@Test
- public void testDropViewHandlesIOException() throws Exception {
+ public void testDropViewIgnoresReconciliationIOException() throws Exception {
TableIdentifier viewIdent = TableIdentifier.of(Namespace.of(SCHEMA_NAME),
VIEW_NAME);
// Simulate IO error
@@ -286,11 +314,8 @@ public class TestIcebergViewHookDispatcher {
.when(mockEntityStore)
.delete(eq(expectedIdent), eq(Entity.EntityType.VIEW));
- // Should throw RuntimeException wrapping the IOException
- RuntimeException exception =
- assertThrows(RuntimeException.class, () ->
hookDispatcher.dropView(mockContext, viewIdent));
+ hookDispatcher.dropView(mockContext, viewIdent);
- assertEquals("Failed to delete view entity from store",
exception.getMessage());
verify(mockExecutor, times(1)).dropView(mockContext, viewIdent);
}
@@ -313,6 +338,45 @@ public class TestIcebergViewHookDispatcher {
.update(eq(sourceGravitinoIdent), eq(ViewEntity.class),
eq(Entity.EntityType.VIEW), any());
}
+ @Test
+ public void testRenameViewReconcilesSourceAndDestinationEntities() throws
Exception {
+ TableIdentifier sourceIdent =
TableIdentifier.of(Namespace.of(SCHEMA_NAME), "old_view");
+ TableIdentifier destIdent = TableIdentifier.of(Namespace.of(SCHEMA_NAME),
"new_view");
+ RenameTableRequest renameRequest =
+
RenameTableRequest.builder().withSource(sourceIdent).withDestination(destIdent).build();
+ when(mockExecutor.viewExists(mockContext, sourceIdent)).thenReturn(false,
false);
+ when(mockExecutor.viewExists(mockContext, destIdent)).thenReturn(true);
+
+ hookDispatcher.renameView(mockContext, renameRequest);
+
+ NameIdentifier sourceGravitinoIdent =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(METALAKE, CATALOG,
sourceIdent, ":");
+ NameIdentifier destGravitinoIdent =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(METALAKE, CATALOG,
destIdent, ":");
+ verify(mockEntityStore, times(1)).delete(eq(sourceGravitinoIdent),
eq(Entity.EntityType.VIEW));
+ verify(mockViewDispatcher, times(1)).loadView(eq(destGravitinoIdent));
+ }
+
+ @Test
+ public void testRenameViewIgnoresReconciliationDeleteFailure() throws
Exception {
+ TableIdentifier sourceIdent =
TableIdentifier.of(Namespace.of(SCHEMA_NAME), "old_view");
+ TableIdentifier destIdent = TableIdentifier.of(Namespace.of(SCHEMA_NAME),
"new_view");
+ RenameTableRequest renameRequest =
+
RenameTableRequest.builder().withSource(sourceIdent).withDestination(destIdent).build();
+ when(mockExecutor.viewExists(mockContext, sourceIdent)).thenReturn(false);
+
+ NameIdentifier sourceGravitinoIdent =
+ IcebergIdentifierUtils.toGravitinoTableIdentifier(METALAKE, CATALOG,
sourceIdent, ":");
+ doThrow(new IOException("IO error"))
+ .when(mockEntityStore)
+ .delete(eq(sourceGravitinoIdent), eq(Entity.EntityType.VIEW));
+
+ hookDispatcher.renameView(mockContext, renameRequest);
+
+ verify(mockExecutor, times(1)).renameView(mockContext, renameRequest);
+ verify(mockEntityStore, times(1)).delete(eq(sourceGravitinoIdent),
eq(Entity.EntityType.VIEW));
+ }
+
@Test
public void testRenameViewHandlesMissingEntity() throws Exception {
TableIdentifier sourceIdent =
TableIdentifier.of(Namespace.of(SCHEMA_NAME), "old_view");