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 bf4136ce37 [Cherry-pick to branch-1.3] [#14119] fix(fileset): Preserve 
metadata on cascade cleanup failure (#14120) (#14203)
bf4136ce37 is described below

commit bf4136ce37d1d5f19c424179de9650664e060dc1
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Oct 26 17:08:59 2026 +0800

    [Cherry-pick to branch-1.3] [#14119] fix(fileset): Preserve metadata on 
cascade cleanup failure (#14120) (#14203)
    
    ### What changes were proposed in this pull request?
    
    Backports #14120 ([original
    
commit](https://github.com/apache/gravitino/commit/c816482b8d24d4a77aaa32c87434398f2d030302))
    to `branch-1.3` and resolves the cherry-pick conflicts.
    
    Managed filesets are deleted before their schema, stopping at the first
    storage failure. Schema and function metadata remain in one transaction
    until empty schema-directory cleanup succeeds. The implementation and
    tests use the existing 1.3 deletion, transaction, and change-log APIs.
    
    ### Why are the changes needed?
    
    Catalog and metalake force-drop can bypass the normal schema dispatcher.
    Previously, metadata was deleted before managed storage cleanup, leaving
    failed cleanup without metadata for retry.
    
    Fix: #14119
    
    ### Does this PR introduce _any_ user-facing change?
    
    Cleanup failures retain the failed fileset and its parents, or
    schema/function metadata when schema-directory cleanup fails. Earlier
    successful fileset deletions remain committed, and retries accept
    already-removed directories. EXTERNAL data and data in catalogs with
    filesystem operations disabled remain untouched.
    
    No REST API or configuration changes.
    
    ### How was this patch tested?
    
    - `:catalogs:catalog-fileset:test`: 252 tests passed.
    - `:core:test`: 82 focused tests passed across schema/fileset metadata
    services, catalog/metalake managers, schema/fileset dispatchers, and
    owner events; relational tests used H2.
    - Tests used `-PskipITs -PskipDockerTests=true`.
    - `spotlessApply`, `git diff --check`, and conflict-marker scans passed.
    - Docker/MySQL/PostgreSQL integration tests were not run.
    
    ---------
    
    Co-authored-by: mchades <[email protected]>
    Co-authored-by: Jerry Shao <[email protected]>
---
 .../catalog/fileset/FilesetCatalogOperations.java  | 238 +++++-------
 .../fileset/TestFilesetCatalogOperations.java      | 417 +++++++++++++++++++++
 .../java/org/apache/gravitino/EntityStore.java     |  23 ++
 .../gravitino/storage/relational/JDBCBackend.java  |  28 ++
 .../storage/relational/RelationalBackend.java      |  23 ++
 .../storage/relational/RelationalEntityStore.java  |  13 +
 .../relational/service/SchemaMetaService.java      |  37 ++
 .../gravitino/catalog/TestCatalogManager.java      |  55 +++
 .../listener/api/event/TestOwnerEvent.java         |   9 +
 .../gravitino/metalake/TestMetalakeManager.java    |  47 +++
 .../relational/service/TestSchemaMetaService.java  | 183 +++++++++
 11 files changed, 925 insertions(+), 148 deletions(-)

diff --git 
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
 
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
index 62db7197de..f16c3329a9 100644
--- 
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
+++ 
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
@@ -60,7 +60,6 @@ import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Supplier;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
@@ -106,6 +105,7 @@ import 
org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchFilesetException;
 import org.apache.gravitino.exceptions.NoSuchLocationNameException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
+import org.apache.gravitino.exceptions.NonEmptyEntityException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
 import org.apache.gravitino.file.FileInfo;
@@ -701,50 +701,10 @@ public class FilesetCatalogOperations extends 
ManagedSchemaOperations
   @Override
   public boolean dropFileset(NameIdentifier ident) {
     try {
-      FilesetEntity filesetEntity =
-          store.get(ident, Entity.EntityType.FILESET, FilesetEntity.class);
-
-      // For managed fileset, we should delete the related files.
-      if (!disableFSOps && filesetEntity.filesetType() == 
Fileset.Type.MANAGED) {
-        AtomicReference<IOException> exception = new AtomicReference<>();
-        Map<String, Path> storageLocations =
-            Maps.transformValues(filesetEntity.storageLocations(), Path::new);
-        storageLocations.forEach(
-            (locationName, location) -> {
-              try {
-                Map<String, String> fsConf =
-                    mergeUpLevelConfigurations(ident, 
filesetEntity.properties(), location);
-                FileSystem fs = getFileSystemWithCache(location, fsConf);
-                if (fs.exists(location)) {
-                  if (!fs.delete(location, true)) {
-                    LOG.warn(
-                        "Failed to delete fileset {} location {} with location 
name {}",
-                        ident,
-                        location,
-                        locationName);
-                  }
-                } else {
-                  LOG.warn(
-                      "Fileset {} location {} with location name {} does not 
exist",
-                      ident,
-                      location,
-                      locationName);
-                }
-              } catch (IOException ioe) {
-                LOG.warn(
-                    "Failed to delete fileset {} location {} with location 
name {}",
-                    ident,
-                    location,
-                    locationName,
-                    ioe);
-                exception.set(ioe);
-              }
-            });
-        if (exception.get() != null) {
-          throw exception.get();
-        }
+      FilesetEntity fileset = store.get(ident, Entity.EntityType.FILESET, 
FilesetEntity.class);
+      if (!disableFSOps && fileset.filesetType() == Fileset.Type.MANAGED) {
+        deleteManagedFilesetStorage(ident, fileset);
       }
-
       return store.delete(ident, Entity.EntityType.FILESET);
     } catch (NoSuchEntityException ne) {
       LOG.warn("Fileset {} does not exist", ident);
@@ -866,118 +826,45 @@ public class FilesetCatalogOperations extends 
ManagedSchemaOperations
         throw new NonEmptySchemaException("Schema %s is not empty", ident);
       }
 
-      SchemaEntity schemaEntity = store.get(ident, Entity.EntityType.SCHEMA, 
SchemaEntity.class);
-      Map<String, String> properties =
-          
Optional.ofNullable(schemaEntity.properties()).orElse(Collections.emptyMap());
-      Map<String, Path> schemaPaths = getAndCheckSchemaPaths(ident.name(), 
properties);
-
-      boolean dropped = super.dropSchema(ident, cascade);
-      if (disableFSOps) {
-        return dropped;
-      }
-
-      // If the schema entity is failed to be deleted, we should not delete 
the storage location
-      // and return false immediately.
-      if (!dropped) {
-        return false;
+      // Catalog force-drop reaches this method without 
SchemaOperationDispatcher's fileset
+      // cleanup. Drop each fileset before its schema so a storage failure 
preserves the failed
+      // fileset and its parent for retry. Stop at the first failure; earlier 
drops stay committed.
+      if (cascade) {
+        for (FilesetEntity fileset : filesets) {
+          dropFileset(fileset.nameIdentifier());
+        }
       }
 
-      // Delete all the managed filesets no matter whether the storage 
location is under the
-      // schema path or not.
-      // The reason why we delete the managed fileset's storage location one 
by one is because we
-      // may mis-delete the storage location of the external fileset if it 
happens to be under
-      // the schema path.
-      ClassLoader cl = Thread.currentThread().getContextClassLoader();
-      filesets.parallelStream()
-          .filter(f -> f.filesetType() == Fileset.Type.MANAGED)
-          .forEach(
-              f -> {
-                ClassLoader oldCl = 
Thread.currentThread().getContextClassLoader();
-                try {
-                  // parallelStream uses forkjoin thread pool, which has a 
different classloader
-                  // than the catalog thread. We need to set the context 
classloader to the
-                  // catalog's classloader to avoid classloading issues.
-                  Thread.currentThread().setContextClassLoader(cl);
-                  f.storageLocations()
-                      .forEach(
-                          (locationName, location) -> {
-                            try {
-                              Path filesetPath = new Path(location);
-                              Map<String, String> fsConf =
-                                  mergeUpLevelConfigurations(ident, 
f.properties(), filesetPath);
-                              FileSystem fs = 
getFileSystemWithCache(filesetPath, fsConf);
-                              if (fs.exists(filesetPath)) {
-                                if (!fs.delete(filesetPath, true)) {
-                                  LOG.warn(
-                                      "Failed to delete fileset {} location: 
{} with location name: {}",
-                                      f.name(),
-                                      filesetPath,
-                                      locationName);
-                                }
-                              }
-                            } catch (IOException ioe) {
-                              LOG.warn(
-                                  "Failed to delete fileset {} location: {} 
with location name: {}",
-                                  f.name(),
-                                  location,
-                                  locationName,
-                                  ioe);
-                            }
-                          });
-                } finally {
-                  Thread.currentThread().setContextClassLoader(oldCl);
-                }
-              });
-
-      // Delete the schema path if it exists and is empty.
-      if (!schemaPaths.isEmpty()) {
-        AtomicReference<RuntimeException> exception = new AtomicReference<>();
-        schemaPaths.forEach(
-            (locationName, schemaPath) -> {
-              try {
-                Map<String, String> fsConf =
-                    mergeUpLevelConfigurations(ident, 
schemaEntity.properties(), schemaPath);
-                FileSystem fs = getFileSystemWithCache(schemaPath, fsConf);
-                if (fs.exists(schemaPath)) {
-                  FileStatus[] statuses = fs.listStatus(schemaPath);
-                  if (statuses.length == 0) {
-                    if (fs.delete(schemaPath, true)) {
-                      LOG.info(
-                          "Deleted schema {} location {} with location name 
{}",
-                          ident,
-                          schemaPath,
-                          locationName);
-                    } else {
-                      LOG.warn(
-                          "Failed to delete schema {} because it has 
files/folders under location {} with location name {}",
-                          ident,
-                          schemaPath,
-                          locationName);
+      // Only functions may be deleted with the schema; remaining filesets 
still reject the drop.
+      // Keep function and schema metadata in one transaction until directory 
cleanup succeeds.
+      boolean dropped =
+          store
+              .deleteSchemaAndGet(
+                  ident,
+                  cascade,
+                  schemaEntity -> {
+                    if (!disableFSOps) {
+                      try {
+                        deleteEmptySchemaLocations(ident, schemaEntity);
+                      } catch (IOException ioe) {
+                        throw new UncheckedIOException(ioe);
+                      }
                     }
-                  }
-                }
-              } catch (IOException ioe) {
-                LOG.warn(
-                    "Failed to delete schema {} location {} with location name 
{}",
-                    ident,
-                    schemaPath,
-                    locationName,
-                    ioe);
-                exception.set(
-                    new RuntimeException("Failed to delete schema " + ident + 
" location", ioe));
-              }
-            });
-        if (exception.get() != null) {
-          throw exception.get();
-        }
+                  })
+              .isPresent();
+      if (dropped) {
+        LOG.info("Deleted schema {}", ident);
       }
+      return dropped;
 
-      LOG.info("Deleted schema {}", ident);
-      return true;
-
+    } catch (NonEmptyEntityException neee) {
+      throw new NonEmptySchemaException(neee, "Schema %s is not empty", ident);
     } catch (NoSuchEntityException ne) {
       LOG.warn("Schema {} does not exist", ident);
       return false;
+    } catch (UncheckedIOException uioe) {
+      throw ExceptionMessages.wrap(
+          "Failed to delete schema " + ident + " location", uioe.getCause());
     } catch (IOException ioe) {
       throw ExceptionMessages.wrap("Failed to delete schema " + ident + " 
location", ioe);
     }
@@ -1052,6 +939,61 @@ public class FilesetCatalogOperations extends 
ManagedSchemaOperations
     
ClassLoaderResourceCleanerUtils.closeClassLoaderResource(this.getClass().getClassLoader());
   }
 
+  /** Stops at the first failed location, leaving fileset metadata available 
for retry. */
+  private void deleteManagedFilesetStorage(NameIdentifier ident, FilesetEntity 
filesetEntity)
+      throws IOException {
+    Map<String, Path> storageLocations =
+        Maps.transformValues(filesetEntity.storageLocations(), Path::new);
+    for (Map.Entry<String, Path> entry : storageLocations.entrySet()) {
+      String locationName = entry.getKey();
+      Path location = entry.getValue();
+      Map<String, String> fsConf =
+          mergeUpLevelConfigurations(ident, filesetEntity.properties(), 
location);
+      FileSystem fs = getFileSystemWithCache(location, fsConf);
+      if (!fs.exists(location)) {
+        LOG.warn(
+            "Fileset {} location {} with location name {} does not exist",
+            ident,
+            location,
+            locationName);
+        continue;
+      }
+      if (!fs.delete(location, true) && fs.exists(location)) {
+        // A false return also covers a location that somebody else removed 
between the check above
+        // and this call. Only a location that is still there is a reason to 
reject the drop.
+        throw new IOException(
+            String.format(
+                "Failed to delete fileset %s location %s with location name 
%s",
+                ident, location, locationName));
+      }
+    }
+  }
+
+  private void deleteEmptySchemaLocations(NameIdentifier ident, SchemaEntity 
schemaEntity)
+      throws IOException {
+    Map<String, String> properties =
+        
Optional.ofNullable(schemaEntity.properties()).orElse(Collections.emptyMap());
+    Map<String, Path> schemaPaths = getAndCheckSchemaPaths(ident.name(), 
properties);
+    for (Map.Entry<String, Path> entry : schemaPaths.entrySet()) {
+      String locationName = entry.getKey();
+      Path schemaPath = entry.getValue();
+      Map<String, String> fsConf = mergeUpLevelConfigurations(ident, 
properties, schemaPath);
+      FileSystem fs = getFileSystemWithCache(schemaPath, fsConf);
+      // Filesystem changes cannot be rolled back with metadata. A retry may 
find a location gone.
+      if (!fs.exists(schemaPath) || fs.listStatus(schemaPath).length != 0) {
+        continue;
+      }
+      if (!fs.delete(schemaPath, true) && fs.exists(schemaPath)) {
+        throw new IOException(
+            String.format(
+                "Failed to delete schema %s location %s with location name %s",
+                ident, schemaPath, locationName));
+      }
+      LOG.info(
+          "Deleted schema {} location {} with location name {}", ident, 
schemaPath, locationName);
+    }
+  }
+
   private void validateLocationHierarchy(
       Map<String, String> schemaLocations, Map<String, String> 
filesetLocations) {
     if (schemaLocations == null
diff --git 
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
 
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
index da358bcc46..51e53dd98d 100644
--- 
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
+++ 
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
@@ -58,9 +58,11 @@ import java.io.IOException;
 import java.net.ConnectException;
 import java.nio.file.Files;
 import java.nio.file.Paths;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.UUID;
@@ -74,6 +76,7 @@ import org.apache.commons.lang3.StringUtils;
 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.EntityFieldLimits;
 import org.apache.gravitino.EntityStore;
@@ -88,6 +91,7 @@ import org.apache.gravitino.UserPrincipal;
 import org.apache.gravitino.audit.CallerContext;
 import org.apache.gravitino.audit.FilesetAuditConstants;
 import org.apache.gravitino.audit.FilesetDataOperation;
+import org.apache.gravitino.catalog.ManagedFunctionOperations;
 import org.apache.gravitino.catalog.hadoop.fs.FileSystemProvider;
 import org.apache.gravitino.catalog.hadoop.fs.FileSystemUtils;
 import org.apache.gravitino.catalog.hadoop.fs.LocalFileSystemProvider;
@@ -109,6 +113,13 @@ import 
org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
 import org.apache.gravitino.file.FileInfo;
 import org.apache.gravitino.file.Fileset;
 import org.apache.gravitino.file.FilesetChange;
+import org.apache.gravitino.function.FunctionDefinitions;
+import org.apache.gravitino.function.FunctionImpl;
+import org.apache.gravitino.function.FunctionImpls;
+import org.apache.gravitino.function.FunctionParam;
+import org.apache.gravitino.function.FunctionType;
+import org.apache.gravitino.meta.FilesetEntity;
+import org.apache.gravitino.rel.types.Types;
 import org.apache.gravitino.storage.IdGenerator;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.RelationalEntityStore;
@@ -120,6 +131,7 @@ import 
org.apache.gravitino.storage.relational.service.SchemaMetaService;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.PrincipalUtils;
 import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.FileStatus;
 import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.LocalFileSystem;
 import org.apache.hadoop.fs.Path;
@@ -133,6 +145,7 @@ import org.junit.jupiter.api.Timeout;
 import org.junit.jupiter.api.io.TempDir;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.CsvSource;
 import org.junit.jupiter.params.provider.EnumSource;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.junit.jupiter.params.provider.ValueSource;
@@ -792,6 +805,394 @@ public class TestFilesetCatalogOperations {
     }
   }
 
+  @ParameterizedTest
+  @CsvSource({"false, false", "false, true", "true, false", "true, true"})
+  void testDropSchemaPreservesMetadataOnDirectoryCleanupFailure(
+      boolean cascade, boolean throwException) throws IOException {
+    String schemaName = "schema_directory_failure_" + generateTestId();
+    String catalogPath = TEST_ROOT_PATH + "/" + schemaName;
+    NameIdentifier schemaIdent = NameIdentifier.of("m1", "c1", schemaName);
+    NameIdentifier functionIdent = NameIdentifier.of("m1", "c1", schemaName, 
"function");
+    Path schemaPath = new Path(catalogPath, schemaName);
+    long schemaId = generateTestId();
+
+    FileSystem realFileSystem = schemaPath.getFileSystem(new Configuration());
+    FileSystem failingFileSystem = Mockito.mock(FileSystem.class);
+    when(failingFileSystem.exists(schemaPath)).thenReturn(true);
+    when(failingFileSystem.listStatus(schemaPath)).thenReturn(new 
FileStatus[0]);
+    if (throwException) {
+      when(failingFileSystem.delete(schemaPath, true))
+          .thenThrow(new IOException("permission denied"));
+    } else {
+      when(failingFileSystem.delete(schemaPath, true)).thenReturn(false);
+    }
+
+    try (FilesetCatalogOperations ops = Mockito.spy(new 
FilesetCatalogOperations(store))) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      Schema schema =
+          ops.createSchema(
+              schemaIdent,
+              "comment",
+              StringIdentifier.newPropertiesWithId(
+                  StringIdentifier.fromId(schemaId), Map.of("schema-prop", 
"schema-value")));
+      if (cascade) {
+        createFunction(functionIdent);
+      }
+      doReturn(failingFileSystem)
+          .when(ops)
+          .getFileSystemWithCache(Mockito.eq(schemaPath), any(Map.class));
+      Mockito.clearInvocations(ops);
+
+      Assertions.assertThrows(RuntimeException.class, () -> 
ops.dropSchema(schemaIdent, cascade));
+      Assertions.assertTrue(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertEquals(cascade, store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      Assertions.assertEquals(schema.properties(), 
ops.loadSchema(schemaIdent).properties());
+      Assertions.assertTrue(realFileSystem.exists(schemaPath));
+      Mockito.verify(ops)
+          .getFileSystemWithCache(
+              Mockito.eq(schemaPath),
+              Mockito.argThat(fsConf -> 
"schema-value".equals(fsConf.get("schema-prop"))));
+
+      Mockito.doCallRealMethod()
+          .when(ops)
+          .getFileSystemWithCache(Mockito.eq(schemaPath), any(Map.class));
+      Assertions.assertTrue(ops.dropSchema(schemaIdent, cascade));
+      Assertions.assertFalse(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertFalse(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      Assertions.assertFalse(realFileSystem.exists(schemaPath));
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testDropSchemaWhenDirectoryDisappearsDuringDelete(boolean 
throwException)
+      throws IOException {
+    String schemaName = "schema_directory_disappeared_" + generateTestId();
+    String catalogPath = TEST_ROOT_PATH + "/" + schemaName;
+    createSchema(generateTestId(), schemaName, "comment", catalogPath, null);
+    NameIdentifier schemaIdent = NameIdentifier.of("m1", "c1", schemaName);
+    NameIdentifier functionIdent = NameIdentifier.of("m1", "c1", schemaName, 
"function");
+    createFunction(functionIdent);
+    Path schemaPath = new Path(catalogPath, schemaName);
+    FileSystem realFileSystem = schemaPath.getFileSystem(new Configuration());
+    FileSystem failingFileSystem = Mockito.mock(FileSystem.class);
+    when(failingFileSystem.exists(schemaPath))
+        .thenAnswer(invocation -> realFileSystem.exists(schemaPath));
+    when(failingFileSystem.listStatus(schemaPath)).thenReturn(new 
FileStatus[0]);
+    when(failingFileSystem.delete(schemaPath, true))
+        .thenAnswer(
+            invocation -> {
+              Assertions.assertTrue(realFileSystem.delete(schemaPath, true));
+              if (throwException) {
+                throw new IOException("delete response lost");
+              }
+              return false;
+            });
+
+    try (FilesetCatalogOperations ops = Mockito.spy(new 
FilesetCatalogOperations(store))) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      doReturn(failingFileSystem)
+          .when(ops)
+          .getFileSystemWithCache(Mockito.eq(schemaPath), any(Map.class));
+
+      if (throwException) {
+        Assertions.assertThrows(RuntimeException.class, () -> 
ops.dropSchema(schemaIdent, true));
+        Assertions.assertTrue(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+        Assertions.assertTrue(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+        Assertions.assertFalse(realFileSystem.exists(schemaPath));
+        Mockito.doCallRealMethod()
+            .when(ops)
+            .getFileSystemWithCache(Mockito.eq(schemaPath), any(Map.class));
+      }
+      Assertions.assertTrue(ops.dropSchema(schemaIdent, true));
+      Assertions.assertFalse(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertFalse(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      Assertions.assertFalse(realFileSystem.exists(schemaPath));
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testDropSchemaWithFunctions(boolean withFileset) throws IOException {
+    String schemaName = "schema_cascade_functions_" + generateTestId();
+    String catalogPath = TEST_ROOT_PATH + "/" + schemaName;
+    createSchema(generateTestId(), schemaName, "comment", catalogPath, null);
+    NameIdentifier schemaIdent = NameIdentifier.of("m1", "c1", schemaName);
+    NameIdentifier functionIdent = NameIdentifier.of("m1", "c1", schemaName, 
"function");
+    createFunction(functionIdent);
+    if (withFileset) {
+      createFileset("fileset", schemaName, "comment", Fileset.Type.MANAGED, 
catalogPath, null);
+    }
+    NameIdentifier filesetIdent = NameIdentifier.of("m1", "c1", schemaName, 
"fileset");
+    Path schemaPath = new Path(catalogPath, schemaName);
+    FileSystem fs = schemaPath.getFileSystem(new Configuration());
+
+    try (FilesetCatalogOperations ops = new FilesetCatalogOperations(store)) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      Assertions.assertThrows(
+          NonEmptySchemaException.class, () -> ops.dropSchema(schemaIdent, 
false));
+      Assertions.assertTrue(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertTrue(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      Assertions.assertEquals(withFileset, store.exists(filesetIdent, 
Entity.EntityType.FILESET));
+      Assertions.assertTrue(fs.exists(schemaPath));
+
+      Assertions.assertTrue(ops.dropSchema(schemaIdent, true));
+      Assertions.assertFalse(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertFalse(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      Assertions.assertFalse(store.exists(filesetIdent, 
Entity.EntityType.FILESET));
+      Assertions.assertFalse(fs.exists(schemaPath));
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testDropSchemaStopsOnStorageFailureAndCanRetry(boolean throwException) 
throws IOException {
+    String schemaName = "schema_cascade_failure_" + generateTestId();
+    String catalogPath = TEST_ROOT_PATH + "/" + schemaName;
+    createSchema(generateTestId(), schemaName, "comment", catalogPath, null);
+    NameIdentifier schemaIdent = NameIdentifier.of("m1", "c1", schemaName);
+    NameIdentifier functionIdent = NameIdentifier.of("m1", "c1", schemaName, 
"function");
+    createFunction(functionIdent);
+    Namespace filesetNs = Namespace.of("m1", "c1", schemaName);
+    List<FilesetEntity> filesets = new ArrayList<>();
+    for (String name : List.of("first", "failed", "last")) {
+      createFileset(name, schemaName, "comment", Fileset.Type.MANAGED, 
catalogPath, null);
+      filesets.add(
+          store.get(
+              NameIdentifier.of(filesetNs, name), Entity.EntityType.FILESET, 
FilesetEntity.class));
+    }
+
+    EntityStore orderedStore = Mockito.spy(store);
+    doReturn(filesets)
+        .when(orderedStore)
+        .list(filesetNs, FilesetEntity.class, Entity.EntityType.FILESET);
+    Path failedPath = new Path(filesets.get(1).storageLocation());
+    FileSystem realFileSystem = failedPath.getFileSystem(new Configuration());
+    FileSystem failingFileSystem = Mockito.mock(FileSystem.class);
+    when(failingFileSystem.exists(failedPath)).thenReturn(true);
+    if (throwException) {
+      when(failingFileSystem.delete(failedPath, true))
+          .thenThrow(new IOException("permission denied"));
+    } else {
+      when(failingFileSystem.delete(failedPath, true)).thenReturn(false);
+    }
+
+    try (FilesetCatalogOperations ops = Mockito.spy(new 
FilesetCatalogOperations(orderedStore))) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      doReturn(failingFileSystem)
+          .when(ops)
+          .getFileSystemWithCache(Mockito.eq(failedPath), any(Map.class));
+
+      Assertions.assertThrows(RuntimeException.class, () -> 
ops.dropSchema(schemaIdent, true));
+      Assertions.assertTrue(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertTrue(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      for (int i = 0; i < filesets.size(); i++) {
+        FilesetEntity fileset = filesets.get(i);
+        Path path = new Path(fileset.storageLocation());
+        Assertions.assertEquals(
+            i != 0, store.exists(fileset.nameIdentifier(), 
Entity.EntityType.FILESET));
+        Assertions.assertEquals(i != 0, realFileSystem.exists(path));
+      }
+
+      // Retry with the real filesystem after repairing access to the failed 
location.
+      Mockito.doCallRealMethod()
+          .when(ops)
+          .getFileSystemWithCache(Mockito.eq(failedPath), any(Map.class));
+      Assertions.assertTrue(ops.dropSchema(schemaIdent, true));
+      Assertions.assertFalse(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertFalse(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      for (FilesetEntity fileset : filesets) {
+        Assertions.assertFalse(store.exists(fileset.nameIdentifier(), 
Entity.EntityType.FILESET));
+        Path path = new Path(fileset.storageLocation());
+        Assertions.assertFalse(realFileSystem.exists(path));
+      }
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void testDropSchemaRetriesPartiallyDeletedFilesetLocations(boolean 
throwException)
+      throws IOException {
+    String schemaName = "schema_cascade_partial_locations_" + generateTestId();
+    String catalogPath = TEST_ROOT_PATH + "/" + schemaName;
+    createSchema(generateTestId(), schemaName, "comment", catalogPath, null);
+    NameIdentifier schemaIdent = NameIdentifier.of("m1", "c1", schemaName);
+    NameIdentifier filesetIdent = NameIdentifier.of("m1", "c1", schemaName, 
"fileset");
+    Map<String, String> catalogProperties = ImmutableMap.of(LOCATION, 
catalogPath);
+    Fileset fileset =
+        createMultiLocationFileset(
+            filesetIdent.name(),
+            schemaName,
+            "comment",
+            Fileset.Type.MANAGED,
+            catalogProperties,
+            ImmutableMap.of(
+                LOCATION_NAME_UNKNOWN,
+                catalogPath + "/" + schemaName + "/primary",
+                "secondary",
+                catalogPath + "/" + schemaName + "/secondary"),
+            ImmutableMap.of(PROPERTY_DEFAULT_LOCATION_NAME, 
LOCATION_NAME_UNKNOWN));
+    List<Path> locations =
+        
fileset.storageLocations().values().stream().map(Path::new).collect(Collectors.toList());
+    Assertions.assertEquals(2, locations.size());
+    Assertions.assertEquals(2, Set.copyOf(locations).size());
+
+    try (FileSystem realFileSystem = FileSystem.newInstance(new 
Configuration());
+        FilesetCatalogOperations ops = Mockito.spy(new 
FilesetCatalogOperations(store))) {
+      for (Path location : locations) {
+        Assertions.assertTrue(realFileSystem.exists(location));
+      }
+      List<Path> deleteAttempts = new ArrayList<>();
+      FileSystem failingFileSystem = Mockito.mock(FileSystem.class);
+      when(failingFileSystem.exists(any(Path.class)))
+          .thenAnswer(invocation -> 
realFileSystem.exists(invocation.getArgument(0)));
+      when(failingFileSystem.delete(any(Path.class), Mockito.eq(true)))
+          .thenAnswer(
+              invocation -> {
+                Path location = invocation.getArgument(0);
+                deleteAttempts.add(location);
+                // Fail the second actual delete without relying on the 
location map's order.
+                if (deleteAttempts.size() == 2) {
+                  if (throwException) {
+                    throw new IOException("permission denied");
+                  }
+                  return false;
+                }
+                return realFileSystem.delete(location, true);
+              });
+      ops.initialize(catalogProperties, randomCatalogInfo("m1", "c1"), 
FILESET_PROPERTIES_METADATA);
+      
doReturn(failingFileSystem).when(ops).getFileSystemWithCache(any(Path.class), 
any(Map.class));
+
+      Assertions.assertThrows(RuntimeException.class, () -> 
ops.dropSchema(schemaIdent, true));
+      Assertions.assertEquals(2, deleteAttempts.size());
+      Assertions.assertEquals(Set.copyOf(locations), 
Set.copyOf(deleteAttempts));
+      Assertions.assertFalse(realFileSystem.exists(deleteAttempts.get(0)));
+      Assertions.assertTrue(realFileSystem.exists(deleteAttempts.get(1)));
+      Assertions.assertTrue(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      FilesetEntity survivingFileset =
+          store.get(filesetIdent, Entity.EntityType.FILESET, 
FilesetEntity.class);
+      Assertions.assertEquals(fileset.storageLocations(), 
survivingFileset.storageLocations());
+      Assertions.assertEquals(fileset.properties(), 
survivingFileset.properties());
+
+      // Retry without recreating the first location, which was already 
removed before the failure.
+      
Mockito.doCallRealMethod().when(ops).getFileSystemWithCache(any(Path.class), 
any(Map.class));
+      Assertions.assertTrue(ops.dropSchema(schemaIdent, true));
+      Assertions.assertFalse(store.exists(filesetIdent, 
Entity.EntityType.FILESET));
+      Assertions.assertFalse(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      for (Path location : locations) {
+        Assertions.assertFalse(realFileSystem.exists(location));
+      }
+    }
+  }
+
+  @Test
+  void testDropSchemaRejectsFilesetCreatedAfterListing() throws IOException {
+    String schemaName = "schema_cascade_new_fileset_" + generateTestId();
+    String catalogPath = TEST_ROOT_PATH + "/" + schemaName;
+    createSchema(generateTestId(), schemaName, "comment", catalogPath, null);
+    NameIdentifier schemaIdent = NameIdentifier.of("m1", "c1", schemaName);
+    NameIdentifier functionIdent = NameIdentifier.of("m1", "c1", schemaName, 
"function");
+    createFunction(functionIdent);
+    Namespace filesetNs = Namespace.of("m1", "c1", schemaName);
+    Fileset first =
+        createFileset("first", schemaName, "comment", Fileset.Type.MANAGED, 
catalogPath, null);
+    NameIdentifier remainingIdent = NameIdentifier.of(filesetNs, "remaining");
+    Path remainingPath = new Path(new Path(catalogPath, schemaName), 
remainingIdent.name());
+    FileSystem fs = remainingPath.getFileSystem(new Configuration());
+    long remainingId = generateTestId();
+    Map<String, String> remainingProperties =
+        StringIdentifier.newPropertiesWithId(
+            StringIdentifier.fromId(remainingId), Collections.emptyMap());
+
+    EntityStore interleavedStore = Mockito.spy(store);
+    try (FilesetCatalogOperations ops = new 
FilesetCatalogOperations(interleavedStore)) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      Mockito.doAnswer(
+              invocation -> {
+                List<FilesetEntity> listed =
+                    store.list(filesetNs, FilesetEntity.class, 
Entity.EntityType.FILESET);
+                // Simulate another writer creating a fileset after the 
deletion snapshot is read.
+                ops.createFileset(
+                    remainingIdent,
+                    "comment",
+                    Fileset.Type.MANAGED,
+                    remainingPath.toString(),
+                    remainingProperties);
+                return listed;
+              })
+          .when(interleavedStore)
+          .list(filesetNs, FilesetEntity.class, Entity.EntityType.FILESET);
+
+      Assertions.assertThrows(
+          NonEmptySchemaException.class, () -> ops.dropSchema(schemaIdent, 
true));
+      Assertions.assertTrue(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertTrue(store.exists(remainingIdent, 
Entity.EntityType.FILESET));
+      Assertions.assertTrue(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      Assertions.assertTrue(fs.exists(remainingPath));
+      Assertions.assertFalse(
+          store.exists(NameIdentifier.of(filesetNs, first.name()), 
Entity.EntityType.FILESET));
+      Assertions.assertFalse(fs.exists(new Path(first.storageLocation())));
+
+      Mockito.doCallRealMethod()
+          .when(interleavedStore)
+          .list(filesetNs, FilesetEntity.class, Entity.EntityType.FILESET);
+      Assertions.assertTrue(ops.dropSchema(schemaIdent, true));
+      Assertions.assertFalse(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertFalse(store.exists(remainingIdent, 
Entity.EntityType.FILESET));
+      Assertions.assertFalse(store.exists(functionIdent, 
Entity.EntityType.FUNCTION));
+      Assertions.assertFalse(fs.exists(remainingPath));
+    }
+  }
+
+  @ParameterizedTest
+  @CsvSource({"EXTERNAL, false", "MANAGED, true"})
+  void testDropSchemaPreservesExternalOrDisabledStorage(Fileset.Type type, 
boolean disableFSOps)
+      throws IOException {
+    String schemaName = "schema_cascade_metadata_only_" + generateTestId();
+    String catalogPath = TEST_ROOT_PATH + "/" + schemaName;
+    createSchema(generateTestId(), schemaName, "comment", catalogPath, null);
+    NameIdentifier schemaIdent = NameIdentifier.of("m1", "c1", schemaName);
+    NameIdentifier filesetIdent = NameIdentifier.of("m1", "c1", schemaName, 
"fileset");
+    Fileset fileset =
+        createFileset(
+            filesetIdent.name(),
+            schemaName,
+            "comment",
+            type,
+            catalogPath,
+            catalogPath + "/fileset");
+    Path path = new Path(fileset.storageLocation());
+    FileSystem fs = path.getFileSystem(new Configuration());
+    Assertions.assertTrue(fs.exists(path));
+
+    try (FilesetCatalogOperations ops = new FilesetCatalogOperations(store)) {
+      ops.initialize(
+          ImmutableMap.of(
+              LOCATION, catalogPath, DISABLE_FILESYSTEM_OPS, 
String.valueOf(disableFSOps)),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      Assertions.assertTrue(ops.dropSchema(schemaIdent, true));
+      Assertions.assertFalse(store.exists(schemaIdent, 
Entity.EntityType.SCHEMA));
+      Assertions.assertFalse(store.exists(filesetIdent, 
Entity.EntityType.FILESET));
+      Assertions.assertTrue(fs.exists(path));
+    }
+  }
+
   @ParameterizedTest
   @MethodSource("locationArguments")
   public void testCreateLoadAndDeleteFilesetWithLocations(
@@ -3759,6 +4160,22 @@ public class TestFilesetCatalogOperations {
     }
   }
 
+  private void createFunction(NameIdentifier ident) {
+    new ManagedFunctionOperations(store, idGenerator)
+        .registerFunction(
+            ident,
+            "comment",
+            FunctionType.SCALAR,
+            true,
+            FunctionDefinitions.of(
+                FunctionDefinitions.of(
+                    new FunctionParam[0],
+                    Types.IntegerType.get(),
+                    new FunctionImpl[] {
+                      FunctionImpls.ofJava(FunctionImpl.RuntimeType.SPARK, 
"com.example.TestUDF")
+                    })));
+  }
+
   private long generateTestId() {
     return idGenerator.nextId();
   }
diff --git a/core/src/main/java/org/apache/gravitino/EntityStore.java 
b/core/src/main/java/org/apache/gravitino/EntityStore.java
index d2c19c65c6..8c63246f71 100644
--- a/core/src/main/java/org/apache/gravitino/EntityStore.java
+++ b/core/src/main/java/org/apache/gravitino/EntityStore.java
@@ -23,10 +23,13 @@ import java.io.IOException;
 import java.lang.reflect.Array;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
 import java.util.function.Function;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.gravitino.Entity.EntityType;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.utils.Executable;
 
 public interface EntityStore extends Closeable {
@@ -221,6 +224,26 @@ public interface EntityStore extends Closeable {
    */
   boolean delete(NameIdentifier ident, EntityType entityType, boolean cascade) 
throws IOException;
 
+  /**
+   * Deletes a schema and optionally its functions, then runs an action before 
committing.
+   *
+   * <p>Remaining filesets prevent deletion. Function cleanup and the schema 
deletion must roll back
+   * together if the action or the remaining-child check fails.
+   *
+   * @param ident the schema identifier
+   * @param cascadeFunctions whether to delete functions in the same metadata 
transaction
+   * @param postDeleteAction the action to run against the deleted snapshot 
before commit
+   * @return the deleted schema, or empty when it did not exist
+   * @throws IOException if the delete operation fails
+   * @throws UnsupportedOperationException if the store cannot honor this 
transaction boundary
+   */
+  default Optional<SchemaEntity> deleteSchemaAndGet(
+      NameIdentifier ident, boolean cascadeFunctions, Consumer<SchemaEntity> 
postDeleteAction)
+      throws IOException {
+    throw new UnsupportedOperationException(
+        "This store cannot delete a schema with a pre-commit action");
+  }
+
   /**
    * Batch delete entities from the underlying storage by the specified list 
of {@link
    * org.apache.gravitino.NameIdentifier} and {@link EntityType}.
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
index 65182cf57e..96d8856ae7 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/JDBCBackend.java
@@ -28,6 +28,8 @@ import com.google.common.collect.Lists;
 import java.io.IOException;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
+import java.util.function.Consumer;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.commons.lang3.tuple.Pair;
@@ -84,6 +86,7 @@ import 
org.apache.gravitino.storage.relational.service.TopicMetaService;
 import org.apache.gravitino.storage.relational.service.UserMetaService;
 import org.apache.gravitino.storage.relational.service.ViewMetaService;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -435,6 +438,31 @@ public class JDBCBackend implements RelationalBackend {
     }
   }
 
+  @Override
+  public Optional<SchemaEntity> deleteSchemaAndGet(
+      NameIdentifier ident, boolean cascadeFunctions, Consumer<SchemaEntity> 
postDeleteAction)
+      throws IOException {
+    SessionUtils.beginTransaction();
+    boolean committed = false;
+    try {
+      SchemaEntity deletedSchema;
+      try {
+        deletedSchema = 
SchemaMetaService.getInstance().deleteSchemaAndGet(ident, cascadeFunctions);
+      } catch (NoSuchEntityException e) {
+        return Optional.empty();
+      }
+      // Keep schema and function metadata recoverable until directory cleanup 
succeeds.
+      postDeleteAction.accept(deletedSchema);
+      SessionUtils.commitTransaction();
+      committed = true;
+      return Optional.of(deletedSchema);
+    } finally {
+      if (!committed) {
+        SessionUtils.rollbackTransaction();
+      }
+    }
+  }
+
   @Override
   public int hardDeleteLegacyData(Entity.EntityType entityType, long 
legacyTimeline)
       throws IOException {
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalBackend.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalBackend.java
index 61eb9c435b..74e2b1fedc 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalBackend.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalBackend.java
@@ -21,6 +21,8 @@ package org.apache.gravitino.storage.relational;
 import java.io.Closeable;
 import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
 import java.util.function.Function;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.gravitino.Config;
@@ -31,6 +33,7 @@ import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.SupportsRelationOperations;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.meta.SchemaEntity;
 
 /** Interface defining the operations for a Relation Backend. */
 public interface RelationalBackend extends Closeable, 
SupportsRelationOperations {
@@ -137,6 +140,26 @@ public interface RelationalBackend extends Closeable, 
SupportsRelationOperations
   boolean delete(NameIdentifier ident, Entity.EntityType entityType, boolean 
cascade)
       throws IOException;
 
+  /**
+   * Deletes a schema and optionally its functions, then runs an action before 
commit.
+   *
+   * <p>Remaining filesets must prevent deletion. Function cleanup, schema 
deletion, and the action
+   * share one metadata transaction.
+   *
+   * @param ident the schema identifier
+   * @param cascadeFunctions whether to delete functions in the same metadata 
transaction
+   * @param postDeleteAction the action to run against the deleted snapshot 
before commit
+   * @return the deleted schema, or empty when it did not exist
+   * @throws IOException if the store operation fails
+   * @throws UnsupportedOperationException if the backend cannot honor this 
transaction boundary
+   */
+  default Optional<SchemaEntity> deleteSchemaAndGet(
+      NameIdentifier ident, boolean cascadeFunctions, Consumer<SchemaEntity> 
postDeleteAction)
+      throws IOException {
+    throw new UnsupportedOperationException(
+        "This backend cannot delete a schema with a pre-commit action");
+  }
+
   /**
    * Deletes the entities in the specified namespace and entity type.
    *
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
index c710b778b0..40cf1b166e 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/RelationalEntityStore.java
@@ -29,6 +29,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 import java.util.function.Function;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.gravitino.Config;
@@ -51,6 +52,7 @@ import org.apache.gravitino.cache.NoOpsCache;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.meta.GroupEntity;
 import org.apache.gravitino.meta.RoleEntity;
+import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.meta.UserEntity;
 import org.apache.gravitino.storage.relational.service.EntityIdService;
 import org.apache.gravitino.utils.Executable;
@@ -215,6 +217,17 @@ public class RelationalEntityStore
     }
   }
 
+  @Override
+  public Optional<SchemaEntity> deleteSchemaAndGet(
+      NameIdentifier ident, boolean cascadeFunctions, Consumer<SchemaEntity> 
postDeleteAction)
+      throws IOException {
+    try {
+      return backend.deleteSchemaAndGet(ident, cascadeFunctions, 
postDeleteAction);
+    } finally {
+      cache.invalidate(ident, Entity.EntityType.SCHEMA);
+    }
+  }
+
   @Override
   public <R, E extends Exception> R executeInTransaction(Executable<R, E> 
executable) {
     throw new UnsupportedOperationException("Unsupported operation in 
relational entity store.");
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
index 39d820b42d..2afa101d85 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/SchemaMetaService.java
@@ -41,6 +41,7 @@ import org.apache.gravitino.Namespace;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NonEmptyEntityException;
 import org.apache.gravitino.meta.FilesetEntity;
+import org.apache.gravitino.meta.FunctionEntity;
 import org.apache.gravitino.meta.ModelEntity;
 import org.apache.gravitino.meta.NamespacedEntityId;
 import org.apache.gravitino.meta.SchemaEntity;
@@ -264,6 +265,42 @@ public class SchemaMetaService {
     }
   }
 
+  /**
+   * Deletes a schema and optionally its functions within the caller's 
metadata transaction.
+   *
+   * <p>The caller must keep the transaction open until any external cleanup 
succeeds. Remaining
+   * filesets reject the non-cascading schema deletion and roll back function 
cleanup with it.
+   *
+   * @param identifier the schema identifier
+   * @param cascadeFunctions whether to remove functions before the 
non-cascading schema delete
+   * @return the schema snapshot used for external cleanup
+   */
+  public SchemaEntity deleteSchemaAndGet(NameIdentifier identifier, boolean 
cascadeFunctions) {
+    SchemaEntity schema = getSchemaByIdentifier(identifier);
+    try {
+      List<FunctionEntity> functions =
+          FunctionMetaService.getInstance()
+              .listFunctionsByNamespace(
+                  NamespaceUtil.ofFunction(
+                      identifier.namespace().level(0),
+                      identifier.namespace().level(1),
+                      identifier.name()));
+      if (!functions.isEmpty() && !cascadeFunctions) {
+        throw new NonEmptyEntityException(
+            "Entity %s has sub-entities, you should remove sub-entities 
first", identifier);
+      }
+      for (FunctionEntity function : functions) {
+        if 
(!FunctionMetaService.getInstance().deleteFunction(function.nameIdentifier())) {
+          throw new NoSuchEntityException("Function %s does not exist", 
function.nameIdentifier());
+        }
+      }
+    } catch (NoSuchEntityException e) {
+      throw new RuntimeException("Failed to delete functions under schema " + 
identifier, e);
+    }
+    deleteSchema(identifier, false);
+    return schema;
+  }
+
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "deleteSchema")
diff --git 
a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java 
b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
index d64aff46de..05ca5228b3 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCatalogManager.java
@@ -49,6 +49,7 @@ import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicReference;
+import org.apache.commons.lang3.exception.ExceptionUtils;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.CatalogChange;
@@ -1343,6 +1344,60 @@ public class TestCatalogManager {
     Mockito.verify(catalogWrapper, Mockito.never()).doWithSchemaOps(any());
   }
 
+  @Test
+  void testForceDropManagedCatalogPreservesMetadataOnSchemaFailure() throws 
Exception {
+    NameIdentifier ident = NameIdentifier.of("metalake", 
"test_force_drop_failure");
+    Map<String, String> props =
+        ImmutableMap.of(
+            "provider",
+            "test",
+            PROPERTY_KEY1,
+            "value1",
+            PROPERTY_KEY2,
+            "value2",
+            PROPERTY_KEY5_PREFIX + "1",
+            "value3");
+    catalogManager.createCatalog(ident, Catalog.Type.RELATIONAL, provider, 
"comment", props);
+    BaseCatalog<?> catalog = 
catalogManager.loadCatalogAndWrap(ident).catalog();
+    entityStore.put(
+        SchemaEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("schema_cleanup_failure")
+            .withNamespace(Namespace.of("metalake", ident.name()))
+            .withAuditInfo(
+                AuditInfo.builder()
+                    
.withCreator(PrincipalUtils.getCurrentPrincipal().getName())
+                    .withCreateTime(Instant.now())
+                    .build())
+            .build());
+    CatalogManager.CatalogWrapper catalogWrapper =
+        Mockito.mock(CatalogManager.CatalogWrapper.class);
+    Capability capability = Mockito.mock(Capability.class);
+    Mockito.when(catalogWrapper.tryAcquire()).thenReturn(true);
+    Mockito.doReturn(capability).when(catalogWrapper).capabilities();
+    // A managed-storage catalog drops each schema through its schema 
operations.
+    
Mockito.doReturn(CapabilityResult.SUPPORTED).when(capability).managedStorage(any());
+    Mockito.doReturn(catalog).when(catalogWrapper).catalog();
+    RuntimeException failure =
+        new RuntimeException("Failed to delete fileset storage", new 
IOException("denied"));
+    Mockito.doThrow(failure).when(catalogWrapper).doWithSchemaOps(any());
+    catalogManager.getCatalogCache().put(ident, catalogWrapper);
+
+    RuntimeException thrown =
+        Assertions.assertThrows(
+            RuntimeException.class, () -> catalogManager.dropCatalog(ident, 
true));
+    String schemaError =
+        "Failed to drop schema "
+            + NameIdentifier.of("metalake", ident.name(), 
"schema_cleanup_failure");
+    Assertions.assertTrue(thrown.getMessage().contains(schemaError));
+    
Assertions.assertTrue(ExceptionUtils.getThrowableList(thrown).contains(failure));
+    Assertions.assertTrue(entityStore.exists(ident, EntityType.CATALOG));
+    Assertions.assertTrue(
+        entityStore.exists(
+            NameIdentifier.of("metalake", ident.name(), 
"schema_cleanup_failure"),
+            EntityType.SCHEMA));
+  }
+
   @Test
   void testDropCatalogInvalidatesCacheAfterStoreDelete() throws Exception {
     NameIdentifier ident = NameIdentifier.of("metalake", "cache_drop_test");
diff --git 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestOwnerEvent.java
 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestOwnerEvent.java
index 4f2c597b38..fe88844152 100644
--- 
a/core/src/test/java/org/apache/gravitino/listener/api/event/TestOwnerEvent.java
+++ 
b/core/src/test/java/org/apache/gravitino/listener/api/event/TestOwnerEvent.java
@@ -24,8 +24,11 @@ import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 
 import com.google.common.collect.ImmutableList;
+import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Optional;
+import java.util.function.Consumer;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetadataObjects;
@@ -37,6 +40,7 @@ import org.apache.gravitino.authorization.OwnerManager;
 import org.apache.gravitino.listener.DummyEventListener;
 import org.apache.gravitino.listener.EventBus;
 import org.apache.gravitino.listener.api.info.OwnerInfo;
+import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.storage.relational.RelationalBackend;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeAll;
@@ -53,6 +57,11 @@ public class TestOwnerEvent {
 
   interface TestEntityStore extends EntityStore, RelationalBackend, 
SupportsRelationOperations {
     // This interface is used to mock the EntityStore for testing purposes.
+    /** {@inheritDoc} */
+    @Override
+    Optional<SchemaEntity> deleteSchemaAndGet(
+        NameIdentifier ident, boolean cascadeFunctions, Consumer<SchemaEntity> 
postDeleteAction)
+        throws IOException;
   }
 
   @BeforeAll
diff --git 
a/core/src/test/java/org/apache/gravitino/metalake/TestMetalakeManager.java 
b/core/src/test/java/org/apache/gravitino/metalake/TestMetalakeManager.java
index d42786c8ee..0791b20a67 100644
--- a/core/src/test/java/org/apache/gravitino/metalake/TestMetalakeManager.java
+++ b/core/src/test/java/org/apache/gravitino/metalake/TestMetalakeManager.java
@@ -339,6 +339,53 @@ public class TestMetalakeManager {
     }
   }
 
+  @Test
+  void testForceDropMetalakePreservesMetadataOnCatalogFailure() throws 
Exception {
+    InMemoryEntityStore store = new InMemoryEntityStore();
+    store.initialize(config);
+    CatalogManager catalogManager = Mockito.mock(CatalogManager.class);
+    Mockito.when(catalogManager.dropCatalog(Mockito.any(NameIdentifier.class), 
Mockito.eq(true)))
+        .thenThrow(new RuntimeException("catalog drop failed"));
+    // enableMetalake/disableMetalake propagate metalake-in-use to catalogs 
through
+    // GravitinoEnv.catalogManager(), so point it at the mock for the duration 
of this test.
+    Mockito.doNothing()
+        .when(catalogManager)
+        .setMetalakeInUseStatus(Mockito.any(NameIdentifier.class), 
Mockito.anyBoolean());
+    Object previousCatalogManager =
+        FieldUtils.readField(GravitinoEnv.getInstance(), "catalogManager", 
true);
+    FieldUtils.writeField(GravitinoEnv.getInstance(), "catalogManager", 
catalogManager, true);
+
+    try {
+      MetalakeManager manager = new MetalakeManager(store, new 
RandomIdGenerator(), catalogManager);
+
+      NameIdentifier metalakeIdent = 
NameIdentifier.of("force_drop_failure_ml");
+      manager.createMetalake(metalakeIdent, "comment", ImmutableMap.of());
+      store.put(
+          CatalogEntity.builder()
+              .withId(99L)
+              .withName("leftover_catalog")
+              .withNamespace(Namespace.of(metalakeIdent.name()))
+              .withType(Catalog.Type.RELATIONAL)
+              .withProvider("test")
+              .withAuditInfo(
+                  
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build())
+              .build(),
+          true);
+
+      Assertions.assertThrows(
+          RuntimeException.class, () -> manager.dropMetalake(metalakeIdent, 
true));
+      Assertions.assertTrue(store.exists(metalakeIdent, 
Entity.EntityType.METALAKE));
+      Assertions.assertTrue(
+          store.exists(
+              NameIdentifier.of(metalakeIdent.name(), "leftover_catalog"),
+              Entity.EntityType.CATALOG));
+    } finally {
+      FieldUtils.writeField(
+          GravitinoEnv.getInstance(), "catalogManager", 
previousCatalogManager, true);
+      store.close();
+    }
+  }
+
   private void testProperties(Map<String, String> expectedProps, Map<String, 
String> testProps) {
     expectedProps.forEach(
         (k, v) -> {
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSchemaMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSchemaMetaService.java
index a1e43144ec..3b4241a99a 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSchemaMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestSchemaMetaService.java
@@ -36,6 +36,7 @@ import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NonEmptyEntityException;
 import org.apache.gravitino.meta.ColumnEntity;
 import org.apache.gravitino.meta.FilesetEntity;
@@ -49,17 +50,199 @@ import org.apache.gravitino.meta.ViewEntity;
 import org.apache.gravitino.rel.types.Types;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.storage.relational.mapper.EntityChangeLogMapper;
+import org.apache.gravitino.storage.relational.po.cache.EntityChangeRecord;
+import org.apache.gravitino.storage.relational.po.cache.OperateType;
 import org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
+import org.apache.gravitino.storage.relational.utils.SessionUtils;
 import org.apache.gravitino.utils.NameIdentifierUtil;
 import org.apache.gravitino.utils.NamespaceUtil;
 import org.apache.ibatis.session.SqlSession;
 import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.TestTemplate;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
 
 public class TestSchemaMetaService extends TestJDBCBackend {
   private final String metalakeName = "metalake_for_catalog_test";
   private final String catalogName = "catalog_for_catalog_test";
 
+  @TestTemplate
+  void testDeleteSchemaAndGetRollsBackWhenCleanupFails() throws IOException {
+    createAndInsertMakeLake(metalakeName);
+    createAndInsertCatalog(metalakeName, catalogName);
+    SchemaEntity schema =
+        createAndInsertSchema(metalakeName, catalogName, 
"schema_cleanup_failure");
+    NameIdentifier ident = schema.nameIdentifier();
+    FunctionEntity function =
+        createFunctionEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFunction(metalakeName, catalogName, schema.name()),
+            "function",
+            AUDIT_INFO);
+    backend.insert(function, false);
+    long lastChangeId =
+        SessionUtils.getWithoutCommit(
+            EntityChangeLogMapper.class, 
EntityChangeLogMapper::selectMaxChangeId);
+    NoSuchEntityException failure = new NoSuchEntityException("cleanup 
dependency is missing");
+
+    assertThrows(
+        NonEmptyEntityException.class,
+        () ->
+            backend.deleteSchemaAndGet(
+                ident,
+                false,
+                ignored -> Assertions.fail("A non-cascading drop must preserve 
functions")));
+
+    Assertions.assertSame(
+        failure,
+        assertThrows(
+            NoSuchEntityException.class,
+            () ->
+                backend.deleteSchemaAndGet(
+                    ident,
+                    true,
+                    deleted -> {
+                      Assertions.assertEquals(schema.id(), deleted.id());
+                      Assertions.assertEquals(schema.properties(), 
deleted.properties());
+                      throw failure;
+                    })));
+    assertTrue(backend.exists(ident, Entity.EntityType.SCHEMA));
+    Assertions.assertEquals(
+        function.comment(),
+        backend
+            .<FunctionEntity>get(function.nameIdentifier(), 
Entity.EntityType.FUNCTION)
+            .comment());
+    assertTrue(
+        SessionUtils.getWithoutCommit(
+                EntityChangeLogMapper.class,
+                mapper -> mapper.selectEntityChanges(lastChangeId, 100))
+            .isEmpty());
+    Assertions.assertEquals(
+        schema.properties(),
+        backend.<SchemaEntity>get(ident, 
Entity.EntityType.SCHEMA).properties());
+
+    SchemaEntity deleted =
+        backend
+            .deleteSchemaAndGet(
+                ident, true, snapshot -> Assertions.assertEquals(schema.id(), 
snapshot.id()))
+            .orElseThrow();
+    Assertions.assertEquals(schema.id(), deleted.id());
+    assertFalse(backend.exists(ident, Entity.EntityType.SCHEMA));
+    assertFalse(backend.exists(function.nameIdentifier(), 
Entity.EntityType.FUNCTION));
+    List<EntityChangeRecord> changes =
+        SessionUtils.getWithoutCommit(
+            EntityChangeLogMapper.class, mapper -> 
mapper.selectEntityChanges(lastChangeId, 100));
+    Assertions.assertEquals(2, changes.size());
+    Assertions.assertEquals(
+        Set.of(Entity.EntityType.SCHEMA.name(), 
Entity.EntityType.FUNCTION.name()),
+        
changes.stream().map(EntityChangeRecord::getEntityType).collect(Collectors.toSet()));
+    assertTrue(changes.stream().allMatch(change -> change.getOperateType() == 
OperateType.DROP));
+    assertFalse(
+        backend
+            .deleteSchemaAndGet(
+                ident,
+                true,
+                ignored -> Assertions.fail("Cleanup must not run for a missing 
schema"))
+            .isPresent());
+  }
+
+  @TestTemplate
+  void testDeleteSchemaAndGetRejectsRemainingFilesetsBeforeCleanup() throws 
IOException {
+    createAndInsertMakeLake(metalakeName);
+    createAndInsertCatalog(metalakeName, catalogName);
+    SchemaEntity schema = createAndInsertSchema(metalakeName, catalogName, 
"schema_with_child");
+    FilesetEntity fileset =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            Namespace.of(metalakeName, catalogName, schema.name()),
+            "fileset",
+            AUDIT_INFO);
+    backend.insert(fileset, false);
+    FunctionEntity function =
+        createFunctionEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFunction(metalakeName, catalogName, schema.name()),
+            "function",
+            AUDIT_INFO);
+    backend.insert(function, false);
+
+    assertThrows(
+        NonEmptyEntityException.class,
+        () ->
+            backend.deleteSchemaAndGet(
+                schema.nameIdentifier(),
+                true,
+                ignored -> Assertions.fail("Cleanup must not run for a 
non-empty schema")));
+    assertTrue(backend.exists(schema.nameIdentifier(), 
Entity.EntityType.SCHEMA));
+    assertTrue(backend.exists(fileset.nameIdentifier(), 
Entity.EntityType.FILESET));
+    assertTrue(backend.exists(function.nameIdentifier(), 
Entity.EntityType.FUNCTION));
+  }
+
+  @TestTemplate
+  void testDeleteSchemaAndGetPropagatesFunctionCleanupFailure() throws 
IOException {
+    createAndInsertMakeLake(metalakeName);
+    createAndInsertCatalog(metalakeName, catalogName);
+    SchemaEntity schema =
+        createAndInsertSchema(metalakeName, catalogName, 
"function_cleanup_failure");
+    FunctionEntity function =
+        createFunctionEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFunction(metalakeName, catalogName, schema.name()),
+            "function",
+            AUDIT_INFO);
+    backend.insert(function, false);
+    FunctionMetaService functionService = FunctionMetaService.getInstance();
+
+    for (RuntimeException failure :
+        List.of(
+            new NoSuchEntityException("function disappeared"),
+            new IllegalStateException("function cleanup failed"))) {
+      FunctionMetaService failingService = Mockito.spy(functionService);
+      
Mockito.doThrow(failure).when(failingService).deleteFunction(function.nameIdentifier());
+      try (MockedStatic<FunctionMetaService> mocked =
+          Mockito.mockStatic(FunctionMetaService.class)) {
+        
mocked.when(FunctionMetaService::getInstance).thenReturn(failingService);
+        RuntimeException actual =
+            assertThrows(
+                RuntimeException.class,
+                () ->
+                    backend.deleteSchemaAndGet(
+                        schema.nameIdentifier(),
+                        true,
+                        ignored ->
+                            Assertions.fail("Cleanup must not run after a 
function failure")));
+        if (failure instanceof NoSuchEntityException) {
+          Assertions.assertEquals(
+              "Failed to delete functions under schema " + 
schema.nameIdentifier(),
+              actual.getMessage());
+          Assertions.assertSame(failure, actual.getCause());
+        } else {
+          Assertions.assertSame(failure, actual);
+        }
+      }
+      assertTrue(backend.exists(schema.nameIdentifier(), 
Entity.EntityType.SCHEMA));
+      assertTrue(backend.exists(function.nameIdentifier(), 
Entity.EntityType.FUNCTION));
+    }
+
+    FunctionMetaService failingService = Mockito.spy(functionService);
+    
Mockito.doReturn(false).when(failingService).deleteFunction(function.nameIdentifier());
+    try (MockedStatic<FunctionMetaService> mocked = 
Mockito.mockStatic(FunctionMetaService.class)) {
+      mocked.when(FunctionMetaService::getInstance).thenReturn(failingService);
+      RuntimeException failure =
+          assertThrows(
+              RuntimeException.class,
+              () ->
+                  backend.deleteSchemaAndGet(
+                      schema.nameIdentifier(),
+                      true,
+                      ignored -> Assertions.fail("Cleanup must not run when a 
function remains")));
+      Assertions.assertInstanceOf(NoSuchEntityException.class, 
failure.getCause());
+    }
+    assertTrue(backend.exists(schema.nameIdentifier(), 
Entity.EntityType.SCHEMA));
+    assertTrue(backend.exists(function.nameIdentifier(), 
Entity.EntityType.FUNCTION));
+  }
+
   @TestTemplate
   public void testInsertAlreadyExistsException() throws IOException {
     createAndInsertMakeLake(metalakeName);

Reply via email to