This is an automated email from the ASF dual-hosted git repository.

jerryshao 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 c7b3f47c01 [Cherry-pick to branch-1.3] [#11262] fix: Clean up orphaned 
schema entities after table or view drop (#11362) (#11445)
c7b3f47c01 is described below

commit c7b3f47c01a64ed38afbbbe8443441955bb48727
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jun 5 13:55:43 2026 +0800

    [Cherry-pick to branch-1.3] [#11262] fix: Clean up orphaned schema entities 
after table or view drop (#11362) (#11445)
    
    **Cherry-pick Information:**
    - Original commit: 35fdb435171782fe03b4ceae54b1612bd88baf50
    - Target branch: `branch-1.3`
    - Status: ✅ Clean cherry-pick (no conflicts)
    
    Co-authored-by: roryqi <[email protected]>
---
 .../gravitino/catalog/OrphanedSchemaCleanup.java   |  59 +++++++++
 .../catalog/SchemaOperationDispatcher.java         |  60 ++-------
 .../catalog/TableOperationDispatcher.java          |  10 +-
 .../gravitino/catalog/ViewOperationDispatcher.java |   3 +
 .../gravitino/utils/SchemaEntityCleaner.java       |  93 ++++++++++++++
 .../catalog/TestTableOperationDispatcher.java      | 134 +++++++++++++++++++++
 .../catalog/TestViewOperationDispatcher.java       |  82 +++++++++++++
 .../gravitino/connector/TestCatalogOperations.java |   5 +
 .../org/apache/gravitino/iceberg/RESTService.java  |  14 ++-
 .../dispatcher/IcebergNamespaceHookDispatcher.java |  56 ++-------
 .../dispatcher/IcebergOrphanSchemaCleanup.java     |  79 ++++++++++++
 .../dispatcher/IcebergTableHookDispatcher.java     |   8 +-
 .../dispatcher/IcebergViewHookDispatcher.java      |   9 +-
 .../dispatcher/TestIcebergTableHookDispatcher.java |  19 ++-
 .../dispatcher/TestIcebergViewHookDispatcher.java  |  19 ++-
 15 files changed, 545 insertions(+), 105 deletions(-)

diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/OrphanedSchemaCleanup.java 
b/core/src/main/java/org/apache/gravitino/catalog/OrphanedSchemaCleanup.java
new file mode 100644
index 0000000000..ddcc9a5df1
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/catalog/OrphanedSchemaCleanup.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.catalog;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.utils.SchemaEntityCleaner;
+
+/**
+ * Shared orphaned-schema cleanup for the core operation dispatchers.
+ *
+ * <p>Both the table and view drop paths need to remove Gravitino schema 
entities whose backing
+ * catalog schema no longer exists. The existence probe goes through {@link
+ * OperationDispatcher#doWithCatalog} against the {@link 
OperationDispatcher#store}, so this helper
+ * lives in the same package to reuse those {@code protected} members and keep 
the logic in one
+ * place.
+ */
+final class OrphanedSchemaCleanup {
+
+  private OrphanedSchemaCleanup() {}
+
+  /**
+   * Removes Gravitino schema entities whose backing catalog schema no longer 
exists, starting from
+   * {@code schemaIdentifier} and walking toward its outermost ancestor.
+   *
+   * @param dispatcher the dispatcher whose entity store and catalog access 
are used for cleanup
+   * @param catalogIdent the identifier of the catalog owning the schema
+   * @param schemaIdentifier the schema identifier to start the orphan check 
from
+   */
+  static void cleanUp(
+      OperationDispatcher dispatcher,
+      NameIdentifier catalogIdent,
+      NameIdentifier schemaIdentifier) {
+    SchemaEntityCleaner.deleteOrphanedSchemaEntities(
+        dispatcher.store,
+        schemaIdentifier,
+        true,
+        schemaIdent ->
+            dispatcher.doWithCatalog(
+                catalogIdent,
+                c -> c.doWithSchemaOps(s -> s.schemaExists(schemaIdent)),
+                RuntimeException.class));
+  }
+}
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 9ab9df5588..eb57d46fe0 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/SchemaOperationDispatcher.java
@@ -23,7 +23,6 @@ import static 
org.apache.gravitino.catalog.PropertiesMetadataHelpers.validatePro
 import static 
org.apache.gravitino.utils.NameIdentifierUtil.getCatalogIdentifier;
 
 import java.time.Instant;
-import java.util.List;
 import java.util.Map;
 import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.EntityStore;
@@ -44,9 +43,8 @@ import org.apache.gravitino.lock.TreeLockUtils;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.storage.IdGenerator;
-import org.apache.gravitino.utils.HierarchicalSchemaUtil;
-import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
+import org.apache.gravitino.utils.SchemaEntityCleaner;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -357,57 +355,19 @@ public class SchemaOperationDispatcher extends 
OperationDispatcher implements Sc
             throw new RuntimeException(e);
           }
 
-          cleanupOrphanedAncestors(catalogIdent, ident);
+          SchemaEntityCleaner.deleteOrphanedSchemaEntities(
+              store,
+              ident,
+              false,
+              schemaIdent ->
+                  doWithCatalog(
+                      catalogIdent,
+                      c -> c.doWithSchemaOps(s -> s.schemaExists(schemaIdent)),
+                      RuntimeException.class));
           return droppedFromCatalog;
         });
   }
 
-  /**
-   * Reconciles auto-created ancestor entities after a hierarchical schema 
leaf is dropped. This
-   * mirrors {@code IcebergNamespaceHookDispatcher.dropNamespace}: walk the 
ancestors
-   * innermost-to-outermost to find the outermost ancestor that no longer 
exists in the catalog,
-   * stopping at the first ancestor that still exists (its existence implies 
all of its outer
-   * ancestors exist). A single cascade delete of that outermost orphaned 
ancestor removes it and
-   * all descendant schema entities (the intermediate ancestors) in one 
batched operation, rather
-   * than issuing one delete per ancestor. For a flat schema name this is a 
no-op.
-   *
-   * @param catalogIdent the identifier of the catalog the schema belongs to
-   * @param ident the identifier of the dropped (leaf) schema
-   */
-  private void cleanupOrphanedAncestors(NameIdentifier catalogIdent, 
NameIdentifier ident) {
-    String separator = HierarchicalSchemaUtil.schemaSeparator();
-    List<String> ancestorNames = 
HierarchicalSchemaUtil.getAncestorNames(ident.name(), separator);
-    String metalake = ident.namespace().level(0);
-    String catalog = ident.namespace().level(1);
-    NameIdentifier outermostOrphan = null;
-    for (int i = ancestorNames.size() - 1; i >= 0; i--) {
-      NameIdentifier ancestorIdent =
-          NameIdentifierUtil.ofSchema(metalake, catalog, ancestorNames.get(i));
-      boolean ancestorExistsInCatalog =
-          doWithCatalog(
-              catalogIdent,
-              c -> c.doWithSchemaOps(s -> s.schemaExists(ancestorIdent)),
-              RuntimeException.class);
-      if (ancestorExistsInCatalog) {
-        break;
-      }
-      outermostOrphan = ancestorIdent;
-    }
-    if (outermostOrphan == null) {
-      return;
-    }
-    // The leaf was already deleted by the caller; cascade-deleting the 
outermost orphaned ancestor
-    // cleans up it and every intermediate ancestor (the relational store 
matches descendant schemas
-    // by name prefix) in a single batched delete.
-    try {
-      store.delete(outermostOrphan, SCHEMA, true);
-    } catch (NoSuchEntityException e) {
-      LOG.warn("The orphaned ancestor schema does not exist in the store: {}", 
outermostOrphan, e);
-    } catch (Exception e) {
-      throw new RuntimeException(e);
-    }
-  }
-
   private void importSchema(NameIdentifier identifier) {
     EntityCombinedSchema schema = internalLoadSchema(identifier);
     if (schema.imported()) {
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 34677f3e31..c2646f4b40 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/TableOperationDispatcher.java
@@ -379,7 +379,7 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
           // 2. Is found in the catalog but not in the store (not managed by 
Gravitino)
           // 3. Is found in the catalog and the store (managed by Gravitino)
           // 4. Neither found in the catalog nor in the store.
-          // In all situations, we try to delete the schema from the store, 
but we don't take the
+          // In all situations, we try to delete the table from the store, but 
we don't take the
           // return value of the store operation into account. We only take 
the return value of the
           // catalog into account.
           try {
@@ -389,6 +389,9 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
           } catch (Exception e) {
             throw new RuntimeException(e);
           }
+          // Run unconditionally: an out-of-band drop may have left orphaned 
schema entities. The
+          // cleanup is best-effort and stops as soon as a schema still exists.
+          OrphanedSchemaCleanup.cleanUp(this, catalogIdent, schemaIdentifier);
           return droppedFromCatalog;
         });
   }
@@ -432,7 +435,7 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
           // 2. Is found in the catalog but not in the store (not managed by 
Gravitino)
           // 3. Is found in the catalog and the store (managed by Gravitino)
           // 4. Neither found in the catalog nor in the store.
-          // In all situations, we try to delete the schema from the store, 
but we don't take the
+          // In all situations, we try to delete the table from the store, but 
we don't take the
           // return value of the store operation into account. We only take 
the return value of the
           // catalog into account.
           try {
@@ -443,6 +446,9 @@ public class TableOperationDispatcher extends 
OperationDispatcher implements Tab
           } catch (Exception e) {
             throw new RuntimeException(e);
           }
+          // Run unconditionally: an out-of-band purge may have left orphaned 
schema entities. The
+          // cleanup is best-effort and stops as soon as a schema still exists.
+          OrphanedSchemaCleanup.cleanUp(this, catalogIdent, schemaIdentifier);
           return droppedFromCatalog;
         });
   }
diff --git 
a/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java 
b/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
index 5b3937ee48..f8960737bf 100644
--- 
a/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
+++ 
b/core/src/main/java/org/apache/gravitino/catalog/ViewOperationDispatcher.java
@@ -314,6 +314,9 @@ public class ViewOperationDispatcher extends 
OperationDispatcher implements View
           } catch (Exception e) {
             throw new RuntimeException(e);
           }
+          // Run unconditionally: an out-of-band drop may have left orphaned 
schema entities. The
+          // cleanup is best-effort and stops as soon as a schema still exists.
+          OrphanedSchemaCleanup.cleanUp(this, catalogIdent, schemaIdentifier);
           return droppedFromCatalog;
         });
   }
diff --git 
a/core/src/main/java/org/apache/gravitino/utils/SchemaEntityCleaner.java 
b/core/src/main/java/org/apache/gravitino/utils/SchemaEntityCleaner.java
new file mode 100644
index 0000000000..bc56b0f4d9
--- /dev/null
+++ b/core/src/main/java/org/apache/gravitino/utils/SchemaEntityCleaner.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.utils;
+
+import static org.apache.gravitino.Entity.EntityType.SCHEMA;
+
+import java.util.ArrayList;
+import java.util.function.Predicate;
+import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Utility for deleting schema entities whose backing catalog schemas no 
longer exist. */
+public final class SchemaEntityCleaner {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(SchemaEntityCleaner.class);
+
+  private SchemaEntityCleaner() {}
+
+  /**
+   * Deletes the outermost orphaned schema entity and its descendants from the 
store.
+   *
+   * <p>Candidates are checked from the starting schema toward the outermost 
ancestor. The first
+   * candidate that still exists in the catalog stops the walk; every 
more-specific candidate
+   * checked before that point is stale. A single cascade delete on the 
outermost stale schema
+   * removes it and all descendant schema entities.
+   *
+   * <p>This is best-effort: callers invoke it after the primary drop has 
already succeeded, so any
+   * failure (a store error or a catalog existence probe failure) is logged 
and swallowed rather
+   * than propagated. The orphan, if any, is retried on a subsequent drop.
+   *
+   * @param store the Gravitino entity store; if {@code null}, this method is 
a no-op
+   * @param schemaIdent the schema identifier to start checking from
+   * @param includeSelf whether to check {@code schemaIdent} itself before 
checking ancestors
+   * @param schemaExists predicate reporting whether a schema still exists in 
the underlying catalog
+   */
+  public static void deleteOrphanedSchemaEntities(
+      EntityStore store,
+      NameIdentifier schemaIdent,
+      boolean includeSelf,
+      Predicate<NameIdentifier> schemaExists) {
+    if (store == null) {
+      return;
+    }
+
+    try {
+      String separator = HierarchicalSchemaUtil.schemaSeparator();
+      ArrayList<String> schemaNames =
+          new ArrayList<>(HierarchicalSchemaUtil.allScopes(schemaIdent.name(), 
separator));
+      if (!includeSelf && !schemaNames.isEmpty()) {
+        schemaNames.remove(0);
+      }
+
+      NameIdentifier outermostOrphan = null;
+      for (String schemaName : schemaNames) {
+        NameIdentifier candidate = NameIdentifier.of(schemaIdent.namespace(), 
schemaName);
+        if (schemaExists.test(candidate)) {
+          break;
+        }
+        outermostOrphan = candidate;
+      }
+
+      if (outermostOrphan == null) {
+        return;
+      }
+
+      store.delete(outermostOrphan, SCHEMA, true);
+    } catch (NoSuchEntityException e) {
+      LOG.debug("The orphaned schema entity was already removed from the 
store", e);
+    } catch (Exception e) {
+      // Best-effort: the primary drop already succeeded, so swallow and log 
rather than fail it.
+      LOG.warn("Failed to clean up orphaned schema entities starting from {}", 
schemaIdent, e);
+    }
+  }
+}
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 10b21dc41d..36c142cc4f 100644
--- 
a/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestTableOperationDispatcher.java
@@ -61,6 +61,7 @@ import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.ColumnEntity;
+import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.Table;
@@ -532,6 +533,127 @@ public class TestTableOperationDispatcher extends 
TestOperationDispatcher {
         RuntimeException.class, () -> 
tableOperationDispatcher.dropTable(tableIdent));
   }
 
+  @Test
+  public void testDropTableCleansUpAutoDroppedParentSchemas() throws Exception 
{
+    reset(entityStore);
+    NameIdentifier schemaIdent =
+        NameIdentifier.of(metalake, catalog, 
"dropTableParentA:dropTableParentB");
+    NameIdentifier ancestorIdent = NameIdentifier.of(metalake, catalog, 
"dropTableParentA");
+    Namespace tableNs =
+        Namespace.of(
+            schemaIdent.namespace().level(0), 
schemaIdent.namespace().level(1), schemaIdent.name());
+    NameIdentifier tableIdent = NameIdentifier.of(tableNs, 
"table_auto_drop_parent");
+    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+    Column[] columns =
+        new Column[] {
+          TestColumn.builder()
+              .withName("col1")
+              .withPosition(0)
+              .withType(Types.StringType.get())
+              .build()
+        };
+
+    schemaOperationDispatcher.createSchema(schemaIdent, "comment", props);
+    putSchemaEntity(ancestorIdent);
+    tableOperationDispatcher.createTable(tableIdent, columns, "comment", 
props, new Transform[0]);
+
+    TestCatalog testCatalog =
+        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, 
catalog));
+    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) 
testCatalog.ops();
+    Assertions.assertTrue(testCatalogOperations.dropSchema(schemaIdent, 
false));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(schemaIdent));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(ancestorIdent));
+
+    Assertions.assertTrue(tableOperationDispatcher.dropTable(tableIdent));
+    Assertions.assertFalse(entityStore.exists(schemaIdent, SCHEMA));
+    Assertions.assertFalse(entityStore.exists(ancestorIdent, SCHEMA));
+  }
+
+  @Test
+  public void testDropMissingTableCleansUpSchemas() throws Exception {
+    reset(entityStore);
+    NameIdentifier schemaIdent =
+        NameIdentifier.of(metalake, catalog, "dropTableGoneA:dropTableGoneB");
+    NameIdentifier ancestorIdent = NameIdentifier.of(metalake, catalog, 
"dropTableGoneA");
+    Namespace tableNs =
+        Namespace.of(
+            schemaIdent.namespace().level(0), 
schemaIdent.namespace().level(1), schemaIdent.name());
+    NameIdentifier tableIdent = NameIdentifier.of(tableNs, 
"table_already_gone");
+    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+    Column[] columns =
+        new Column[] {
+          TestColumn.builder()
+              .withName("col1")
+              .withPosition(0)
+              .withType(Types.StringType.get())
+              .build()
+        };
+
+    schemaOperationDispatcher.createSchema(schemaIdent, "comment", props);
+    putSchemaEntity(ancestorIdent);
+    tableOperationDispatcher.createTable(tableIdent, columns, "comment", 
props, new Transform[0]);
+
+    // Simulate an out-of-band drop: the backend already removed the table and 
auto-dropped the
+    // now-empty namespaces, so the catalog no longer knows the table 
(dropTable returns false),
+    // while Gravitino still holds the orphaned schema entities.
+    TestCatalog testCatalog =
+        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, 
catalog));
+    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) 
testCatalog.ops();
+    Assertions.assertTrue(testCatalogOperations.dropTable(tableIdent));
+    Assertions.assertTrue(testCatalogOperations.dropSchema(schemaIdent, 
false));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(schemaIdent));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(ancestorIdent));
+
+    // dropTable returns false because the table is already gone from the 
catalog, but the
+    // orphaned schema entities must still be cleaned up.
+    Assertions.assertFalse(tableOperationDispatcher.dropTable(tableIdent));
+    Assertions.assertFalse(entityStore.exists(schemaIdent, SCHEMA));
+    Assertions.assertFalse(entityStore.exists(ancestorIdent, SCHEMA));
+  }
+
+  @Test
+  public void testPurgeMissingTableCleansUpSchemas() throws Exception {
+    reset(entityStore);
+    NameIdentifier schemaIdent =
+        NameIdentifier.of(metalake, catalog, 
"purgeTableGoneA:purgeTableGoneB");
+    NameIdentifier ancestorIdent = NameIdentifier.of(metalake, catalog, 
"purgeTableGoneA");
+    Namespace tableNs =
+        Namespace.of(
+            schemaIdent.namespace().level(0), 
schemaIdent.namespace().level(1), schemaIdent.name());
+    NameIdentifier tableIdent = NameIdentifier.of(tableNs, 
"table_already_gone");
+    Map<String, String> props = ImmutableMap.of("k1", "v1", "k2", "v2");
+    Column[] columns =
+        new Column[] {
+          TestColumn.builder()
+              .withName("col1")
+              .withPosition(0)
+              .withType(Types.StringType.get())
+              .build()
+        };
+
+    schemaOperationDispatcher.createSchema(schemaIdent, "comment", props);
+    putSchemaEntity(ancestorIdent);
+    tableOperationDispatcher.createTable(tableIdent, columns, "comment", 
props, new Transform[0]);
+
+    // Simulate an out-of-band drop: the backend already removed the table and 
auto-dropped the
+    // now-empty namespaces, so the catalog no longer knows the table 
(purgeTable returns false),
+    // while Gravitino still holds the orphaned schema entities.
+    TestCatalog testCatalog =
+        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, 
catalog));
+    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) 
testCatalog.ops();
+    Assertions.assertTrue(testCatalogOperations.purgeTable(tableIdent));
+    Assertions.assertTrue(testCatalogOperations.dropSchema(schemaIdent, 
false));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(schemaIdent));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(ancestorIdent));
+
+    // purgeTable returns false because the table is already gone from the 
catalog, but the
+    // orphaned schema entities must still be cleaned up. A regression that 
re-guards the cleanup
+    // behind the catalog drop result would leave the stale schema entities 
behind.
+    Assertions.assertFalse(tableOperationDispatcher.purgeTable(tableIdent));
+    Assertions.assertFalse(entityStore.exists(schemaIdent, SCHEMA));
+    Assertions.assertFalse(entityStore.exists(ancestorIdent, SCHEMA));
+  }
+
   @Test
   public void testCreateTableNeedImportingSchema() throws IOException {
     Namespace tableNs = Namespace.of(metalake, catalog, "schema181");
@@ -1040,6 +1162,18 @@ public class TestTableOperationDispatcher extends 
TestOperationDispatcher {
         });
   }
 
+  private void putSchemaEntity(NameIdentifier ident) throws IOException {
+    SchemaEntity entity =
+        SchemaEntity.builder()
+            .withId(idGenerator.nextId())
+            .withName(ident.name())
+            .withNamespace(ident.namespace())
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("tester").withCreateTime(Instant.now()).build())
+            .build();
+    entityStore.put(entity, true);
+  }
+
   public static TableOperationDispatcher getTableOperationDispatcher() {
     return tableOperationDispatcher;
   }
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestViewOperationDispatcher.java
 
b/core/src/test/java/org/apache/gravitino/catalog/TestViewOperationDispatcher.java
index 5f28bcd20e..7f75fe1984 100644
--- 
a/core/src/test/java/org/apache/gravitino/catalog/TestViewOperationDispatcher.java
+++ 
b/core/src/test/java/org/apache/gravitino/catalog/TestViewOperationDispatcher.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.catalog;
 import static org.apache.gravitino.Configs.TREE_LOCK_CLEAN_INTERVAL;
 import static org.apache.gravitino.Configs.TREE_LOCK_MAX_NODE_IN_MEMORY;
 import static org.apache.gravitino.Configs.TREE_LOCK_MIN_NODE_IN_MEMORY;
+import static org.apache.gravitino.Entity.EntityType.SCHEMA;
 import static org.apache.gravitino.Entity.EntityType.VIEW;
 import static org.apache.gravitino.StringIdentifier.ID_KEY;
 import static org.apache.gravitino.TestBasePropertiesMetadata.COMMENT_KEY;
@@ -53,6 +54,7 @@ import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchViewException;
 import org.apache.gravitino.lock.LockManager;
 import org.apache.gravitino.meta.AuditInfo;
+import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.ViewEntity;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.Representation;
@@ -499,6 +501,74 @@ public class TestViewOperationDispatcher extends 
TestOperationDispatcher {
     Assertions.assertFalse(viewOperationDispatcher.dropView(viewIdent));
   }
 
+  @Test
+  public void testDropViewCleansUpAutoDroppedParentSchemas() throws Exception {
+    NameIdentifier schemaIdent =
+        NameIdentifier.of(metalake, catalog, 
"dropViewParentA:dropViewParentB");
+    NameIdentifier ancestorIdent = NameIdentifier.of(metalake, catalog, 
"dropViewParentA");
+    Namespace viewNs =
+        Namespace.of(
+            schemaIdent.namespace().level(0), 
schemaIdent.namespace().level(1), schemaIdent.name());
+    NameIdentifier viewIdent = NameIdentifier.of(viewNs, 
"view_auto_drop_parent");
+    Representation[] representations = {
+      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 
1").build()
+    };
+
+    schemaOperationDispatcher.createSchema(
+        schemaIdent, "comment", ImmutableMap.of("k1", "v1", "k2", "v2"));
+    putSchemaEntity(ancestorIdent);
+    viewOperationDispatcher.createView(
+        viewIdent, null, new Column[0], representations, null, null, 
ImmutableMap.of("k1", "v1"));
+
+    TestCatalog testCatalog =
+        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, 
catalog));
+    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) 
testCatalog.ops();
+    Assertions.assertTrue(testCatalogOperations.dropSchema(schemaIdent, 
false));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(schemaIdent));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(ancestorIdent));
+
+    Assertions.assertTrue(viewOperationDispatcher.dropView(viewIdent));
+    Assertions.assertFalse(entityStore.exists(schemaIdent, SCHEMA));
+    Assertions.assertFalse(entityStore.exists(ancestorIdent, SCHEMA));
+  }
+
+  @Test
+  public void testDropMissingViewCleansUpSchemas() throws Exception {
+    NameIdentifier schemaIdent =
+        NameIdentifier.of(metalake, catalog, "dropViewGoneA:dropViewGoneB");
+    NameIdentifier ancestorIdent = NameIdentifier.of(metalake, catalog, 
"dropViewGoneA");
+    Namespace viewNs =
+        Namespace.of(
+            schemaIdent.namespace().level(0), 
schemaIdent.namespace().level(1), schemaIdent.name());
+    NameIdentifier viewIdent = NameIdentifier.of(viewNs, "view_already_gone");
+    Representation[] representations = {
+      SQLRepresentation.builder().withDialect("spark").withSql("SELECT 
1").build()
+    };
+
+    schemaOperationDispatcher.createSchema(
+        schemaIdent, "comment", ImmutableMap.of("k1", "v1", "k2", "v2"));
+    putSchemaEntity(ancestorIdent);
+    viewOperationDispatcher.createView(
+        viewIdent, null, new Column[0], representations, null, null, 
ImmutableMap.of("k1", "v1"));
+
+    // Simulate an out-of-band drop: the backend already removed the view and 
auto-dropped the
+    // now-empty namespaces, so the catalog no longer knows the view (dropView 
returns false),
+    // while Gravitino still holds the orphaned schema entities.
+    TestCatalog testCatalog =
+        (TestCatalog) catalogManager.loadCatalog(NameIdentifier.of(metalake, 
catalog));
+    TestCatalogOperations testCatalogOperations = (TestCatalogOperations) 
testCatalog.ops();
+    Assertions.assertTrue(testCatalogOperations.dropView(viewIdent));
+    Assertions.assertTrue(testCatalogOperations.dropSchema(schemaIdent, 
false));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(schemaIdent));
+    Assertions.assertFalse(testCatalogOperations.schemaExists(ancestorIdent));
+
+    // dropView returns false because the view is already gone from the 
catalog, but the
+    // orphaned schema entities must still be cleaned up.
+    Assertions.assertFalse(viewOperationDispatcher.dropView(viewIdent));
+    Assertions.assertFalse(entityStore.exists(schemaIdent, SCHEMA));
+    Assertions.assertFalse(entityStore.exists(ancestorIdent, SCHEMA));
+  }
+
   @Test
   public void testListViews() throws IOException {
     Namespace viewNs = Namespace.of(metalake, catalog, "schema_list_view");
@@ -607,4 +677,16 @@ public class TestViewOperationDispatcher extends 
TestOperationDispatcher {
                 .orElseThrow(AssertionError::new);
     Assertions.assertEquals("SELECT 2", trino.sql());
   }
+
+  private void putSchemaEntity(NameIdentifier ident) throws IOException {
+    SchemaEntity entity =
+        SchemaEntity.builder()
+            .withId(idGenerator.nextId())
+            .withName(ident.name())
+            .withNamespace(ident.namespace())
+            .withAuditInfo(
+                
AuditInfo.builder().withCreator("tester").withCreateTime(Instant.now()).build())
+            .build();
+    entityStore.put(entity, true);
+  }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
index 27e6780a21..b6324652ca 100644
--- 
a/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
+++ 
b/core/src/test/java/org/apache/gravitino/connector/TestCatalogOperations.java
@@ -305,6 +305,11 @@ public class TestCatalogOperations
     }
   }
 
+  @Override
+  public boolean purgeTable(NameIdentifier ident) {
+    return dropTable(ident);
+  }
+
   @Override
   public View createView(
       NameIdentifier ident,
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
index ea2b54fa6f..d536d01cd2 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/RESTService.java
@@ -139,13 +139,20 @@ public class RESTService implements 
GravitinoAuxiliaryService {
           "Async Iceberg table cleanup is only available in auxiliary mode; "
               + "purge requests with async mode will fall back to synchronous 
purge.");
     }
+
+    // The raw namespace operation executor is shared with the table and view 
hook dispatchers so
+    // their orphan-schema cleanup can probe namespace existence without 
firing namespace events.
+    IcebergNamespaceOperationDispatcher namespaceOperationDispatcher =
+        new IcebergNamespaceOperationExecutor(icebergCatalogWrapperManager, 
cleanupManager);
+
     // Table: HookDispatcher -> EventDispatcher -> OperationExecutor
     IcebergTableOperationDispatcher icebergTableOperationDispatcher =
         new IcebergTableOperationExecutor(icebergCatalogWrapperManager, 
cleanupManager);
     IcebergTableOperationDispatcher icebergTableEventDispatcher =
         new IcebergTableEventDispatcher(icebergTableOperationDispatcher, 
eventBus, metalakeName);
     if (authorizationContext.isAuthorizationEnabled()) {
-      icebergTableEventDispatcher = new 
IcebergTableHookDispatcher(icebergTableEventDispatcher);
+      icebergTableEventDispatcher =
+          new IcebergTableHookDispatcher(icebergTableEventDispatcher, 
namespaceOperationDispatcher);
     }
     IcebergTableOperationDispatcher icebergTableDispatcher = 
icebergTableEventDispatcher;
 
@@ -156,13 +163,12 @@ public class RESTService implements 
GravitinoAuxiliaryService {
         new IcebergViewEventDispatcher(icebergViewOperationDispatcher, 
eventBus, metalakeName);
     if (authorizationContext.isAuthorizationEnabled()) {
       icebergViewEventDispatcher =
-          new IcebergViewHookDispatcher(icebergViewEventDispatcher, 
metalakeName);
+          new IcebergViewHookDispatcher(
+              icebergViewEventDispatcher, namespaceOperationDispatcher, 
metalakeName);
     }
     IcebergViewOperationDispatcher icebergViewDispatcher = 
icebergViewEventDispatcher;
 
     // Namespace: HookDispatcher -> EventDispatcher -> OperationExecutor
-    IcebergNamespaceOperationDispatcher namespaceOperationDispatcher =
-        new IcebergNamespaceOperationExecutor(icebergCatalogWrapperManager, 
cleanupManager);
     IcebergNamespaceOperationDispatcher icebergNamespaceEventDispatcher =
         new IcebergNamespaceEventDispatcher(namespaceOperationDispatcher, 
eventBus, metalakeName);
     if (authorizationContext.isAuthorizationEnabled()) {
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
index e316352339..635f766256 100644
--- 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergNamespaceHookDispatcher.java
@@ -18,18 +18,13 @@
  */
 package org.apache.gravitino.iceberg.service.dispatcher;
 
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
-import java.util.regex.Pattern;
-import org.apache.gravitino.Entity;
-import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.catalog.SchemaDispatcher;
 import org.apache.gravitino.catalog.TableDispatcher;
-import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
 import 
org.apache.gravitino.iceberg.service.authorization.IcebergRESTServerContext;
 import org.apache.gravitino.listener.api.event.IcebergRequestContext;
@@ -117,7 +112,8 @@ public class IcebergNamespaceHookDispatcher implements 
IcebergNamespaceOperation
     // probing once we hit one that exists.
     List<Namespace> missing = new ArrayList<>();
     for (int i = ancestorNames.size() - 1; i >= 0; i--) {
-      Namespace ancestor = 
Namespace.of(ancestorNames.get(i).split(Pattern.quote(separator)));
+      Namespace ancestor =
+          
Namespace.of(HierarchicalSchemaUtil.splitSchemaName(ancestorNames.get(i), 
separator));
       if (dispatcher.namespaceExists(context, ancestor)) {
         break;
       }
@@ -147,49 +143,19 @@ public class IcebergNamespaceHookDispatcher implements 
IcebergNamespaceOperation
         () -> {
           dispatcher.dropNamespace(context, namespace);
 
-          // Only the leaf namespace is dropped from the Iceberg catalog 
above. getAncestorNames
-          // returns ancestors outermost-to-innermost, so we walk it in 
reverse (innermost outward),
-          // advancing outermostStale past every ancestor whose Iceberg 
namespace no longer exists
-          // and stopping at the first ancestor that still exists. 
outermostStale therefore ends up
-          // as the outermost missing ancestor; everything from there down to 
the leaf is now stale
-          // in Gravitino.
-          //
-          // An inner Iceberg namespace cannot exist unless all of its outer 
ancestors do, so an
-          // ancestor missing from the catalog implies none of its descendants 
exist there
-          // either. A single cascade delete of the outermost empty namespace 
therefore removes
-          // it and every descendant Gravitino entity (the leaf plus the 
intermediate ancestors)
-          // in one batched operation, rather than issuing one delete per 
target.
+          // Only the leaf namespace is dropped from the Iceberg catalog 
above. Dropping it may have
+          // also emptied its ancestors, so clean up every Gravitino schema 
entity whose Iceberg
+          // namespace no longer exists. An inner Iceberg namespace cannot 
exist unless all of its
+          // outer ancestors do, so the cleaner cascade-deletes the outermost 
stale namespace and
+          // every descendant entity in one batched operation.
           //
           // Ancestor entities are still only cleaned up when the underlying 
Iceberg catalog
           // removes the empty parents on leaf-drop, which is 
catalog-implementation-dependent.
           // For catalogs that keep empty parents, operators may need to drop 
them manually.
-          String separator = HierarchicalSchemaUtil.schemaSeparator();
-          Namespace outermostStale = namespace;
-          String namespaceName = String.join(separator, namespace.levels());
-          List<String> ancestorNames =
-              HierarchicalSchemaUtil.getAncestorNames(namespaceName, 
separator);
-          for (int i = ancestorNames.size() - 1; i >= 0; i--) {
-            Namespace ancestor = 
Namespace.of(ancestorNames.get(i).split(Pattern.quote(separator)));
-            if (dispatcher.namespaceExists(context, ancestor)) {
-              break;
-            }
-            outermostStale = ancestor;
-          }
-
-          EntityStore store = GravitinoEnv.getInstance().entityStore();
-          if (store != null) {
-            try {
-              store.delete(
-                  IcebergIdentifierUtils.toGravitinoSchemaIdentifier(
-                      metalake, catalogName, outermostStale, separator),
-                  Entity.EntityType.SCHEMA,
-                  true);
-            } catch (NoSuchEntityException ignore) {
-              // Already gone.
-            } catch (IOException ioe) {
-              throw new RuntimeException("io exception when deleting schema 
entity", ioe);
-            }
-          }
+          //
+          // Routed through the same guarded helper as the table and view drop 
paths so the cleanup
+          // never surfaces an error after the namespace drop has already 
succeeded.
+          IcebergOrphanSchemaCleanup.bestEffortCleanUp(metalake, dispatcher, 
context, namespace);
           return null;
         });
   }
diff --git 
a/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergOrphanSchemaCleanup.java
 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergOrphanSchemaCleanup.java
new file mode 100644
index 0000000000..75d60b7747
--- /dev/null
+++ 
b/iceberg/iceberg-rest-server/src/main/java/org/apache/gravitino/iceberg/service/dispatcher/IcebergOrphanSchemaCleanup.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.gravitino.iceberg.service.dispatcher;
+
+import org.apache.gravitino.GravitinoEnv;
+import org.apache.gravitino.iceberg.common.utils.IcebergIdentifierUtils;
+import org.apache.gravitino.listener.api.event.IcebergRequestContext;
+import org.apache.gravitino.utils.HierarchicalSchemaUtil;
+import org.apache.gravitino.utils.SchemaEntityCleaner;
+import org.apache.iceberg.catalog.Namespace;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Shared best-effort cleanup of Gravitino schema entities that become 
orphaned when an Iceberg
+ * table or view drop empties their backing namespaces.
+ */
+final class IcebergOrphanSchemaCleanup {
+
+  private static final Logger LOG = 
LoggerFactory.getLogger(IcebergOrphanSchemaCleanup.class);
+
+  private IcebergOrphanSchemaCleanup() {}
+
+  /**
+   * Removes Gravitino schema entities whose backing Iceberg namespace no 
longer exists after a
+   * table or view drop.
+   *
+   * <p>Best-effort: the primary drop has already succeeded, so any failure 
here is logged and
+   * swallowed rather than propagated to the caller.
+   *
+   * @param metalake the metalake the catalog belongs to
+   * @param namespaceDispatcher dispatcher used to probe whether a namespace 
still exists in the
+   *     catalog
+   * @param context the Iceberg request context carrying the catalog name
+   * @param namespace the namespace of the dropped table or view
+   */
+  static void bestEffortCleanUp(
+      String metalake,
+      IcebergNamespaceOperationDispatcher namespaceDispatcher,
+      IcebergRequestContext context,
+      Namespace namespace) {
+    try {
+      String separator = HierarchicalSchemaUtil.schemaSeparator();
+      SchemaEntityCleaner.deleteOrphanedSchemaEntities(
+          GravitinoEnv.getInstance().entityStore(),
+          IcebergIdentifierUtils.toGravitinoSchemaIdentifier(
+              metalake, context.catalogName(), namespace, separator),
+          true,
+          schemaIdent ->
+              namespaceDispatcher.namespaceExists(
+                  context,
+                  Namespace.of(
+                      
HierarchicalSchemaUtil.splitSchemaName(schemaIdent.name(), separator))));
+    } catch (RuntimeException e) {
+      LOG.warn(
+          "Failed to clean up orphaned Gravitino schema entities after the 
Iceberg backend operation "
+              + "succeeded. catalog={}, namespace={}",
+          context.catalogName(),
+          namespace,
+          e);
+    }
+  }
+}
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 d39d93f1ae..bf95e0a5a3 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
@@ -53,10 +53,14 @@ public class IcebergTableHookDispatcher implements 
IcebergTableOperationDispatch
   private static final Logger LOG = 
LoggerFactory.getLogger(IcebergTableHookDispatcher.class);
 
   private final IcebergTableOperationDispatcher dispatcher;
+  private final IcebergNamespaceOperationDispatcher namespaceDispatcher;
   private String metalake;
 
-  public IcebergTableHookDispatcher(IcebergTableOperationDispatcher 
dispatcher) {
+  public IcebergTableHookDispatcher(
+      IcebergTableOperationDispatcher dispatcher,
+      IcebergNamespaceOperationDispatcher namespaceDispatcher) {
     this.dispatcher = dispatcher;
+    this.namespaceDispatcher = namespaceDispatcher;
     this.metalake = IcebergRESTServerContext.getInstance().metalakeName();
   }
 
@@ -100,6 +104,8 @@ public class IcebergTableHookDispatcher implements 
IcebergTableOperationDispatch
     // 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);
+    IcebergOrphanSchemaCleanup.bestEffortCleanUp(
+        metalake, namespaceDispatcher, context, tableIdentifier.namespace());
   }
 
   @Override
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 dcde32b92c..bce140877b 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
@@ -53,10 +53,15 @@ public class IcebergViewHookDispatcher implements 
IcebergViewOperationDispatcher
   private static final Logger LOG = 
LoggerFactory.getLogger(IcebergViewHookDispatcher.class);
 
   private final IcebergViewOperationDispatcher dispatcher;
+  private final IcebergNamespaceOperationDispatcher namespaceDispatcher;
   private final String metalake;
 
-  public IcebergViewHookDispatcher(IcebergViewOperationDispatcher dispatcher, 
String metalake) {
+  public IcebergViewHookDispatcher(
+      IcebergViewOperationDispatcher dispatcher,
+      IcebergNamespaceOperationDispatcher namespaceDispatcher,
+      String metalake) {
     this.dispatcher = dispatcher;
+    this.namespaceDispatcher = namespaceDispatcher;
     this.metalake = metalake;
   }
 
@@ -101,6 +106,8 @@ public class IcebergViewHookDispatcher implements 
IcebergViewOperationDispatcher
     // 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);
+    IcebergOrphanSchemaCleanup.bestEffortCleanUp(
+        metalake, namespaceDispatcher, context, viewIdentifier.namespace());
   }
 
   @Override
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 40a11ec015..27cf388ffd 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
@@ -74,6 +74,7 @@ public class TestIcebergTableHookDispatcher {
 
   private IcebergTableHookDispatcher hookDispatcher;
   private IcebergTableOperationDispatcher mockDispatcher;
+  private IcebergNamespaceOperationDispatcher mockNamespaceDispatcher;
   private EntityStore mockEntityStore;
   private TableDispatcher mockTableDispatcher;
   private TableDispatcher mockInternalTableDispatcher;
@@ -92,6 +93,7 @@ public class TestIcebergTableHookDispatcher {
   public void setUp() throws IllegalAccessException {
     // Mock the underlying dispatcher
     mockDispatcher = mock(IcebergTableOperationDispatcher.class);
+    mockNamespaceDispatcher = mock(IcebergNamespaceOperationDispatcher.class);
 
     // Mock GravitinoEnv components
     mockEntityStore = mock(EntityStore.class);
@@ -131,7 +133,7 @@ public class TestIcebergTableHookDispatcher {
     IcebergRESTServerContext.create(mockConfigProvider, false, false, true, 
null);
 
     // Create hook dispatcher
-    hookDispatcher = new IcebergTableHookDispatcher(mockDispatcher);
+    hookDispatcher = new IcebergTableHookDispatcher(mockDispatcher, 
mockNamespaceDispatcher);
 
     // Mock request context
     mockContext = mock(IcebergRequestContext.class);
@@ -244,6 +246,21 @@ public class TestIcebergTableHookDispatcher {
     verify(mockTableDispatcher, never()).loadTable(any());
   }
 
+  @Test
+  public void testDropTableCleansUpOrphanedSchemaEntities() throws IOException 
{
+    TableIdentifier tableId = TableIdentifier.of(Namespace.of("parent", 
"child"), "test_table");
+    NameIdentifier schemaIdent = NameIdentifier.of(TEST_METALAKE, 
TEST_CATALOG, "parent");
+    when(mockNamespaceDispatcher.namespaceExists(mockContext, 
Namespace.of("parent", "child")))
+        .thenReturn(false);
+    when(mockNamespaceDispatcher.namespaceExists(mockContext, 
Namespace.of("parent")))
+        .thenReturn(false);
+
+    hookDispatcher.dropTable(mockContext, tableId, false);
+
+    verify(mockDispatcher).dropTable(mockContext, tableId, false);
+    verify(mockEntityStore).delete(schemaIdent, Entity.EntityType.SCHEMA, 
true);
+  }
+
   @Test
   public void testDropTableIgnoresNoSuchEntityException() throws IOException {
     TableIdentifier tableId = TableIdentifier.of("test_schema", "test_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 929aeac842..d81cf9fee0 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
@@ -72,6 +72,7 @@ public class TestIcebergViewHookDispatcher {
 
   private IcebergViewHookDispatcher hookDispatcher;
   private IcebergViewOperationDispatcher mockExecutor;
+  private IcebergNamespaceOperationDispatcher mockNamespaceDispatcher;
   private EntityStore mockEntityStore;
   private ViewDispatcher mockViewDispatcher;
   private ViewDispatcher mockInternalViewDispatcher;
@@ -90,6 +91,7 @@ public class TestIcebergViewHookDispatcher {
   @BeforeEach
   public void setUp() {
     mockExecutor = mock(IcebergViewOperationDispatcher.class);
+    mockNamespaceDispatcher = mock(IcebergNamespaceOperationDispatcher.class);
     mockEntityStore = mock(EntityStore.class);
     mockViewDispatcher = mock(ViewDispatcher.class);
     mockInternalViewDispatcher = mock(ViewDispatcher.class);
@@ -127,7 +129,7 @@ public class TestIcebergViewHookDispatcher {
       throw new RuntimeException("Failed to setup test", e);
     }
 
-    hookDispatcher = new IcebergViewHookDispatcher(mockExecutor, METALAKE);
+    hookDispatcher = new IcebergViewHookDispatcher(mockExecutor, 
mockNamespaceDispatcher, METALAKE);
   }
 
   @AfterEach
@@ -309,6 +311,21 @@ public class TestIcebergViewHookDispatcher {
     verify(mockViewDispatcher, never()).loadView(any());
   }
 
+  @Test
+  public void testDropViewCleansUpOrphanedSchemaEntities() throws Exception {
+    TableIdentifier viewIdent = TableIdentifier.of(Namespace.of("parent", 
"child"), VIEW_NAME);
+    NameIdentifier schemaIdent = NameIdentifier.of(METALAKE, CATALOG, 
"parent");
+    when(mockNamespaceDispatcher.namespaceExists(mockContext, 
Namespace.of("parent", "child")))
+        .thenReturn(false);
+    when(mockNamespaceDispatcher.namespaceExists(mockContext, 
Namespace.of("parent")))
+        .thenReturn(false);
+
+    hookDispatcher.dropView(mockContext, viewIdent);
+
+    verify(mockExecutor, times(1)).dropView(mockContext, viewIdent);
+    verify(mockEntityStore, times(1)).delete(schemaIdent, 
Entity.EntityType.SCHEMA, true);
+  }
+
   @Test
   public void testDropViewHandlesMissingEntity() throws Exception {
     TableIdentifier viewIdent = TableIdentifier.of(Namespace.of(SCHEMA_NAME), 
VIEW_NAME);

Reply via email to