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

mchades pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new fd6638953b [#12550] improvement(fileset): Guard managed fileset 
creation on non-empty locations (#13506)
fd6638953b is described below

commit fd6638953b9588f5decc8670ac3235b1a395ac18
Author: Yasu <[email protected]>
AuthorDate: Tue Oct 27 21:53:41 2026 +0900

    [#12550] improvement(fileset): Guard managed fileset creation on non-empty 
locations (#13506)
    
    ### What changes were proposed in this pull request?
    
    Reject creation of a `MANAGED` fileset on an existing non-empty location
    unless `allow-existing-location-as-managed=true` is supplied. The
    property defaults to `false`, is immutable, and has Java and Python
    constants.
    
    Preflight all resolved locations before creating directories, preserving
    nested locations within one request. Check directory contents through
    `listStatusIterator().hasNext()` and document the explicit adoption
    behavior.
    
    ### Why are the changes needed?
    
    Silently adopting existing data as managed makes that data subject to
    deletion when the fileset is dropped. This adds an explicit choice at
    creation time.
    
    Addresses the creation-time protection in #12550. Type conversion is
    separate work in #14124.
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. Existing non-empty locations require `EXTERNAL` or an explicit
    managed-adoption override. Existing empty directories, external
    filesets, and catalogs with filesystem operations disabled retain their
    existing behavior.
    
    Drop and cascade behavior are unchanged. Validation is a creation-time
    preflight; it does not reserve paths against concurrent filesystem
    writers.
    
    ### How was this patch tested?
    
    - `./gradlew :api:build :catalogs:catalog-fileset:build -PskipITs
    --max-workers=2 --console=plain`
    - Added coverage for inherited and placeholder locations,
    case-insensitive true versus false/invalid overrides, immutable-property
    validation, and iterator-based emptiness checks. Existing
    nested-location and multi-location preflight regression tests remain
    covered.
    - Java module builds passed: 300 API tests and 277 fileset tests, with
    no failures or skips.
    - Python fileset API unit test, constant import check, Black formatting
    check, and Pylint passed.
    
    Object-store connector behavior was inspected in the pinned dependency
    sources; live cloud-storage tests were not run.
    
    ---------
    
    Signed-off-by: Yasutaka Hisano <[email protected]>
    Co-authored-by: mchades <[email protected]>
---
 .../java/org/apache/gravitino/file/Fileset.java    |   7 +
 .../catalog/fileset/FilesetCatalogOperations.java  |  48 ++-
 .../catalog/fileset/FilesetPropertiesMetadata.java |  11 +
 .../fileset/TestFilesetCatalogOperations.java      | 363 +++++++++++++++++++++
 .../fileset/TestFilesetPropertiesMetadata.java     |  65 ++++
 .../client-python/gravitino/api/file/fileset.py    |   4 +
 docs/fileset-catalog.md                            |   1 +
 docs/filesets.md                                   |   9 +-
 docs/manage-fileset-metadata-using-gravitino.md    |   4 +
 9 files changed, 504 insertions(+), 8 deletions(-)

diff --git a/api/src/main/java/org/apache/gravitino/file/Fileset.java 
b/api/src/main/java/org/apache/gravitino/file/Fileset.java
index 644c516e69..36577de3ed 100644
--- a/api/src/main/java/org/apache/gravitino/file/Fileset.java
+++ b/api/src/main/java/org/apache/gravitino/file/Fileset.java
@@ -71,6 +71,13 @@ public interface Fileset extends Auditable {
   /** The property name for the default location name of the fileset. */
   String PROPERTY_DEFAULT_LOCATION_NAME = "default-location-name";
 
+  /**
+   * The property name that allows creating a managed fileset on a location 
that already exists and
+   * is not empty. Defaults to false, in which case such a location must be 
registered as an
+   * external fileset instead.
+   */
+  String PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED = 
"allow-existing-location-as-managed";
+
   /** The reserved location name to indicate the location name is unknown. */
   String LOCATION_NAME_UNKNOWN = "unknown";
 
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 2f8ef02dfd..af24428c57 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
@@ -20,6 +20,7 @@ package org.apache.gravitino.catalog.fileset;
 
 import static 
org.apache.gravitino.catalog.hadoop.fs.HDFSFileSystemProvider.SCHEME_HDFS;
 import static org.apache.gravitino.file.Fileset.LOCATION_NAME_UNKNOWN;
+import static 
org.apache.gravitino.file.Fileset.PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED;
 import static org.apache.gravitino.file.Fileset.PROPERTY_CATALOG_PLACEHOLDER;
 import static org.apache.gravitino.file.Fileset.PROPERTY_DEFAULT_LOCATION_NAME;
 import static org.apache.gravitino.file.Fileset.PROPERTY_FILESET_PLACEHOLDER;
@@ -542,10 +543,18 @@ public class FilesetCatalogOperations extends 
ManagedSchemaOperations
           });
     } else {
       try {
-        // formalize the path to avoid path without scheme, uri, authority, 
etc.
+        boolean allowExistingLocationAsManaged =
+            (boolean)
+                propertiesMetadata
+                    .filesetPropertiesMetadata()
+                    .getOrDefault(properties, 
PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED);
+        Map<String, Map<String, String>> fileSystemConfigurations = 
Maps.newHashMap();
+        // Formalize and validate every location before creating any 
directories. Otherwise,
+        // creating a child location can make another location in the request 
non-empty.
         for (Map.Entry<String, Path> entry : filesetPaths.entrySet()) {
           Map<String, String> fsConf =
               mergeUpLevelConfigurations(ident, properties, entry.getValue());
+          fileSystemConfigurations.put(entry.getKey(), fsConf);
           FileSystem tmpFs = getFileSystemWithCache(entry.getValue(), fsConf);
           Path formalizePath =
               entry.getValue().makeQualified(tmpFs.getUri(), 
tmpFs.getWorkingDirectory());
@@ -553,14 +562,39 @@ public class FilesetCatalogOperations extends 
ManagedSchemaOperations
           filesetPathsBuilder.put(entry.getKey(), formalizePath);
           FileSystem fs = getFileSystemWithCache(formalizePath, fsConf);
 
-          if (fs.exists(formalizePath) && 
fs.getFileStatus(formalizePath).isFile()) {
-            throw new IllegalArgumentException(
-                "Fileset location cannot be a file: "
-                    + formalizePath
-                    + ", location name: "
-                    + entry.getKey());
+          if (fs.exists(formalizePath)) {
+            if (fs.getFileStatus(formalizePath).isFile()) {
+              throw new IllegalArgumentException(
+                  "Fileset location cannot be a file: "
+                      + formalizePath
+                      + ", location name: "
+                      + entry.getKey());
+            }
+            // A managed fileset takes its data with it on drop, so silently 
adopting a location
+            // that already holds files would put data Gravitino never created 
at risk.
+            if (type == Fileset.Type.MANAGED
+                && !allowExistingLocationAsManaged
+                && fs.listStatusIterator(formalizePath).hasNext()) {
+              throw new IllegalArgumentException(
+                  "Location "
+                      + formalizePath
+                      + " for managed fileset "
+                      + ident
+                      + " already exists and is not empty, location name: "
+                      + entry.getKey()
+                      + ". Register it as an "
+                      + Fileset.Type.EXTERNAL
+                      + " fileset with this location set explicitly, or set "
+                      + PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED
+                      + "=true to manage it anyway");
+            }
           }
+        }
 
+        for (Map.Entry<String, Path> entry : 
filesetPathsBuilder.build().entrySet()) {
+          Path formalizePath = entry.getValue();
+          FileSystem fs =
+              getFileSystemWithCache(formalizePath, 
fileSystemConfigurations.get(entry.getKey()));
           if (!fs.exists(formalizePath)) {
             if (!fs.mkdirs(formalizePath)) {
               throw new RuntimeException(
diff --git 
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetPropertiesMetadata.java
 
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetPropertiesMetadata.java
index 8f01268adf..69c37ae107 100644
--- 
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetPropertiesMetadata.java
+++ 
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetPropertiesMetadata.java
@@ -20,6 +20,7 @@ package org.apache.gravitino.catalog.fileset;
 
 import static 
org.apache.gravitino.catalog.fileset.FilesetCatalogPropertiesMetadata.AUTHENTICATION_PROPERTY_ENTRIES;
 import static 
org.apache.gravitino.catalog.fileset.FilesetCatalogPropertiesMetadata.KERBEROS_PROPERTY_ENTRIES;
+import static 
org.apache.gravitino.file.Fileset.PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED;
 import static org.apache.gravitino.file.Fileset.PROPERTY_CATALOG_PLACEHOLDER;
 import static org.apache.gravitino.file.Fileset.PROPERTY_DEFAULT_LOCATION_NAME;
 import static org.apache.gravitino.file.Fileset.PROPERTY_FILESET_PLACEHOLDER;
@@ -73,6 +74,16 @@ public class FilesetPropertiesMetadata extends 
BasePropertiesMetadata {
                 true /* immutable */,
                 null,
                 false /* hidden */))
+        .put(
+            PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED,
+            PropertyEntry.booleanPropertyEntry(
+                PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED,
+                "Whether a managed fileset may be created on a non-empty 
location that already exists",
+                false /* required */,
+                true /* immutable */,
+                false /* default value */,
+                false /* hidden */,
+                false /* reserved */))
         .putAll(KERBEROS_PROPERTY_ENTRIES)
         .putAll(AUTHENTICATION_PROPERTY_ENTRIES)
         .putAll(CloudPropertiesMetadata.STORAGE_PROPERTY_ENTRIES);
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 644cf7c9d2..8bfa44dfc6 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
@@ -49,6 +49,7 @@ import static 
org.apache.gravitino.catalog.fileset.FilesetCatalogPropertiesMetad
 import static 
org.apache.gravitino.catalog.fileset.FilesetCatalogPropertiesMetadata.LOCATION;
 import static 
org.apache.gravitino.catalog.hadoop.fs.FileSystemProvider.GRAVITINO_BYPASS;
 import static org.apache.gravitino.file.Fileset.LOCATION_NAME_UNKNOWN;
+import static 
org.apache.gravitino.file.Fileset.PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED;
 import static org.apache.gravitino.file.Fileset.PROPERTY_DEFAULT_LOCATION_NAME;
 import static 
org.apache.gravitino.file.Fileset.PROPERTY_MULTIPLE_LOCATIONS_PREFIX;
 import static org.mockito.ArgumentMatchers.any;
@@ -162,6 +163,7 @@ import org.apache.hadoop.fs.FileStatus;
 import org.apache.hadoop.fs.FileSystem;
 import org.apache.hadoop.fs.LocalFileSystem;
 import org.apache.hadoop.fs.Path;
+import org.apache.hadoop.fs.RemoteIterator;
 import org.awaitility.Awaitility;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.Assertions;
@@ -1508,6 +1510,367 @@ public class TestFilesetCatalogOperations {
     }
   }
 
+  /**
+   * Checks that managed filesets reject existing data without registering 
metadata.
+   *
+   * @param root temporary directory for this test
+   */
+  @Test
+  public void testCreateManagedFilesetRejectsNonEmptyExistingLocation(@TempDir 
File root)
+      throws IOException {
+    String schemaName = "schema_managed_non_empty";
+    String filesetName = "fileset_managed_non_empty";
+    String catalogPath = TEST_ROOT_PATH + "/catalog_managed_non_empty";
+    createSchema(schemaName, "comment", catalogPath, null);
+
+    File existingLocation = new File(root, "existing_reports");
+    FileUtils.forceMkdir(existingLocation);
+    FileUtils.touch(new File(existingLocation, "report.csv"));
+    NameIdentifier filesetIdent = NameIdentifier.of("m1", "c1", schemaName, 
filesetName);
+
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                createFileset(
+                    filesetName,
+                    schemaName,
+                    "comment",
+                    Fileset.Type.MANAGED,
+                    catalogPath,
+                    existingLocation.toURI().toString()));
+    Assertions.assertTrue(exception.getMessage().contains("already exists and 
is not empty"));
+    
Assertions.assertTrue(exception.getMessage().contains(Fileset.Type.EXTERNAL.name()));
+
+    // Nothing was registered, and the data that would have been at risk is 
untouched.
+    try (FilesetCatalogOperations ops = new FilesetCatalogOperations(store, 
secretManager)) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      Assertions.assertThrows(NoSuchFilesetException.class, () -> 
ops.loadFileset(filesetIdent));
+    }
+    Assertions.assertTrue(new File(existingLocation, "report.csv").exists());
+  }
+
+  /**
+   * Checks that an empty existing directory can still be adopted by a managed 
fileset.
+   *
+   * @param root temporary directory for this test
+   */
+  @Test
+  public void testCreateManagedFilesetAdoptsEmptyExistingLocation(@TempDir 
File root)
+      throws IOException {
+    String schemaName = "schema_managed_empty";
+    String filesetName = "fileset_managed_empty";
+    String catalogPath = TEST_ROOT_PATH + "/catalog_managed_empty";
+    createSchema(schemaName, "comment", catalogPath, null);
+
+    File existingLocation = new File(root, "existing_empty");
+    FileUtils.forceMkdir(existingLocation);
+
+    Fileset fileset =
+        createFileset(
+            filesetName,
+            schemaName,
+            "comment",
+            Fileset.Type.MANAGED,
+            catalogPath,
+            existingLocation.toURI().toString());
+    Assertions.assertEquals(Fileset.Type.MANAGED, fileset.type());
+  }
+
+  /**
+   * Checks that explicitly adopted data is deleted when the managed fileset 
is dropped.
+   *
+   * @param value the managed-location adoption property value
+   * @param root temporary directory for this test invocation
+   */
+  @ParameterizedTest
+  @ValueSource(strings = {"true", "TRUE", "TrUe"})
+  public void testCreateManagedFilesetOnNonEmptyLocationWithOverride(
+      String value, @TempDir File root) throws IOException {
+    String schemaName = "schema_managed_override_" + value;
+    String filesetName = "fileset_managed_override";
+    String catalogPath = TEST_ROOT_PATH + "/catalog_managed_override";
+    createSchema(schemaName, "comment", catalogPath, null);
+
+    File existingLocation = new File(root, "existing_override");
+    File existingData = new File(existingLocation, "report.csv");
+    FileUtils.forceMkdir(existingLocation);
+    FileUtils.touch(existingData);
+
+    Fileset fileset =
+        createFileset(
+            filesetName,
+            schemaName,
+            "comment",
+            Fileset.Type.MANAGED,
+            catalogPath,
+            existingLocation.toURI().toString(),
+            ImmutableMap.of(PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED, 
value));
+    Assertions.assertEquals(Fileset.Type.MANAGED, fileset.type());
+    Assertions.assertTrue(existingData.isFile());
+
+    NameIdentifier filesetIdent = NameIdentifier.of("m1", "c1", schemaName, 
filesetName);
+    try (FilesetCatalogOperations ops = new FilesetCatalogOperations(store, 
secretManager)) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      Assertions.assertTrue(ops.dropFileset(filesetIdent));
+    }
+    Assertions.assertFalse(existingData.exists());
+  }
+
+  /** Checks that values other than true do not allow adoption of existing 
data. */
+  @ParameterizedTest
+  @ValueSource(strings = {"false", "FALSE", "yes", "1", "", " true "})
+  public void testCreateManagedFilesetRejectsNonTrueOverride(String value) 
throws IOException {
+    String schemaName = "schema_managed_invalid_override_" + UUID.randomUUID();
+    createSchema(schemaName, "comment", null, null);
+    File location = new File(UNFORMALIZED_TEST_ROOT_PATH, schemaName);
+    File existingData = new File(location, "report.csv");
+    FileUtils.touch(existingData);
+    NameIdentifier ident = NameIdentifier.of("m1", "c1", schemaName, 
"reports");
+
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                createFileset(
+                    ident.name(),
+                    schemaName,
+                    "comment",
+                    Fileset.Type.MANAGED,
+                    null,
+                    location.toURI().toString(),
+                    
ImmutableMap.of(PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED, value)));
+    Assertions.assertTrue(exception.getMessage().contains("already exists and 
is not empty"));
+    Assertions.assertFalse(store.exists(ident, Entity.EntityType.FILESET));
+    Assertions.assertTrue(existingData.isFile());
+  }
+
+  /** Checks the guard after resolving inherited locations and schema/fileset 
placeholders. */
+  @ParameterizedTest
+  @ValueSource(strings = {"catalog", "schema", "placeholder"})
+  public void testCreateManagedFilesetRejectsNonEmptyDerivedLocation(String 
source)
+      throws IOException {
+    String schemaName = "schema_managed_derived_" + source;
+    String filesetName = "reports";
+    File root = new File(UNFORMALIZED_TEST_ROOT_PATH, "derived_" + source);
+    String catalogLocation = "catalog".equals(source) ? 
root.toURI().toString() : null;
+    String schemaLocation = null;
+    if ("schema".equals(source)) {
+      schemaLocation = new File(root, schemaName).toURI().toString();
+    } else if ("placeholder".equals(source)) {
+      schemaLocation = new Path(root.toURI()).toString() + 
"/{{schema}}/{{fileset}}";
+    }
+    createSchema(schemaName, "comment", catalogLocation, schemaLocation);
+    File location = new File(new File(root, schemaName), filesetName);
+    File existingData = new File(location, "report.csv");
+    FileUtils.touch(existingData);
+    NameIdentifier ident = NameIdentifier.of("m1", "c1", schemaName, 
filesetName);
+
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                createFileset(
+                    filesetName,
+                    schemaName,
+                    "comment",
+                    Fileset.Type.MANAGED,
+                    catalogLocation,
+                    null));
+    Assertions.assertTrue(exception.getMessage().contains("already exists and 
is not empty"));
+    Assertions.assertFalse(store.exists(ident, Entity.EntityType.FILESET));
+    Assertions.assertTrue(existingData.isFile());
+
+    Fileset adopted =
+        createFileset(
+            filesetName,
+            schemaName,
+            "comment",
+            Fileset.Type.MANAGED,
+            catalogLocation,
+            null,
+            ImmutableMap.of(PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED, 
"true"));
+    Assertions.assertEquals(
+        location.toPath(), Paths.get(new 
Path(adopted.storageLocation()).toUri()));
+    Assertions.assertTrue(existingData.isFile());
+  }
+
+  /** Checks that the guard only asks the listing iterator whether a child 
exists. */
+  @SuppressWarnings("unchecked")
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testCreateManagedFilesetUsesLocationIterator(boolean nonEmpty) 
throws IOException {
+    String schemaName = "schema_managed_iterator_" + nonEmpty;
+    createSchema(schemaName, "comment", null, null);
+    NameIdentifier ident = NameIdentifier.of("m1", "c1", schemaName, 
"reports");
+    Path location = new Path(TEST_ROOT_PATH + "/iterator_" + nonEmpty);
+    FileSystem fs = Mockito.mock(FileSystem.class);
+    RemoteIterator<FileStatus> iterator = Mockito.mock(RemoteIterator.class);
+    when(fs.getUri()).thenReturn(new Path("file:/").toUri());
+    when(fs.getWorkingDirectory()).thenReturn(new Path("/"));
+    when(fs.exists(location)).thenReturn(true);
+    when(fs.getFileStatus(location)).thenReturn(new FileStatus(0, true, 1, 0, 
0, location));
+    when(fs.listStatusIterator(location)).thenReturn(iterator);
+    when(iterator.hasNext()).thenReturn(nonEmpty);
+
+    try (FilesetCatalogOperations ops =
+        Mockito.spy(new FilesetCatalogOperations(store, secretManager))) {
+      ops.initialize(ImmutableMap.of(), randomCatalogInfo("m1", "c1"), 
FILESET_PROPERTIES_METADATA);
+      doReturn(fs).when(ops).getFileSystemWithCache(any(Path.class), 
any(Map.class));
+      Map<String, String> properties =
+          StringIdentifier.newPropertiesWithId(
+              StringIdentifier.fromId(idGenerator.nextId()), 
ImmutableMap.of());
+      if (nonEmpty) {
+        IllegalArgumentException exception =
+            Assertions.assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                    ops.createFileset(
+                        ident, "comment", Fileset.Type.MANAGED, 
location.toString(), properties));
+        Assertions.assertTrue(exception.getMessage().contains("already exists 
and is not empty"));
+        Assertions.assertFalse(store.exists(ident, Entity.EntityType.FILESET));
+      } else {
+        Fileset fileset =
+            ops.createFileset(
+                ident, "comment", Fileset.Type.MANAGED, location.toString(), 
properties);
+        Assertions.assertEquals(location, new Path(fileset.storageLocation()));
+      }
+      Mockito.verify(fs, Mockito.never()).listStatus(any(Path.class));
+      Mockito.verify(iterator).hasNext();
+      Mockito.verify(iterator, Mockito.never()).next();
+      Mockito.verify(fs, Mockito.never()).mkdirs(any(Path.class));
+    }
+  }
+
+  /** Checks that creating one location does not invalidate another location 
in the same request. */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testCreateManagedFilesetWithNestedNewLocations(boolean 
childFirst)
+      throws IOException {
+    String schemaName = "schema_nested_locations_" + childFirst;
+    createSchema(schemaName, "comment", null, null);
+    File parent = new File(UNFORMALIZED_TEST_ROOT_PATH, "nested_locations_" + 
childFirst);
+    File child = new File(parent, "child");
+    Map<String, String> locations =
+        ImmutableMap.of(
+            "a", (childFirst ? child : parent).toURI().toString(),
+            "b", (childFirst ? parent : child).toURI().toString());
+
+    Fileset fileset =
+        createMultiLocationFileset(
+            "nested",
+            schemaName,
+            "comment",
+            Fileset.Type.MANAGED,
+            ImmutableMap.of(),
+            locations,
+            ImmutableMap.of(PROPERTY_DEFAULT_LOCATION_NAME, "a"));
+
+    Assertions.assertTrue(parent.isDirectory());
+    Assertions.assertTrue(child.isDirectory());
+    locations.forEach(
+        (name, location) ->
+            Assertions.assertEquals(
+                new Path(location).toString(), 
fileset.storageLocations().get(name)));
+    Assertions.assertTrue(
+        store.exists(
+            NameIdentifier.of("m1", "c1", schemaName, "nested"), 
Entity.EntityType.FILESET));
+  }
+
+  /** Checks that a rejected later location leaves both the filesystem and 
metadata unchanged. */
+  @ParameterizedTest
+  @ValueSource(booleans = {true, false})
+  public void testCreateManagedFilesetPreflightsAllLocations(boolean 
existingFile)
+      throws IOException {
+    String schemaName = "schema_preflight_locations_" + existingFile;
+    createSchema(schemaName, "comment", null, null);
+    File newLocation = new File(UNFORMALIZED_TEST_ROOT_PATH, "new_location_" + 
existingFile);
+    File existingLocation =
+        new File(UNFORMALIZED_TEST_ROOT_PATH, "existing_location_" + 
existingFile);
+    File existingData = existingFile ? existingLocation : new 
File(existingLocation, "report.csv");
+    FileUtils.touch(existingData);
+    NameIdentifier ident = NameIdentifier.of("m1", "c1", schemaName, 
"preflight");
+
+    IllegalArgumentException exception =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                createMultiLocationFileset(
+                    ident.name(),
+                    schemaName,
+                    "comment",
+                    Fileset.Type.MANAGED,
+                    ImmutableMap.of(),
+                    ImmutableMap.of(
+                        "a", newLocation.toURI().toString(),
+                        "b", existingLocation.toURI().toString()),
+                    ImmutableMap.of(PROPERTY_DEFAULT_LOCATION_NAME, "a")));
+
+    Assertions.assertTrue(
+        exception
+            .getMessage()
+            .contains(existingFile ? "cannot be a file" : "already exists and 
is not empty"));
+    Assertions.assertFalse(newLocation.exists(), "Preflight must not create 
earlier locations");
+    Assertions.assertTrue(existingData.isFile());
+    Assertions.assertFalse(store.exists(ident, Entity.EntityType.FILESET));
+  }
+
+  /** Checks that external filesets can still register existing data and 
retain it on drop. */
+  @Test
+  public void testCreateExternalFilesetOnNonEmptyLocation() throws IOException 
{
+    String schemaName = "schema_external_non_empty";
+    createSchema(schemaName, "comment", null, null);
+    File location = new File(UNFORMALIZED_TEST_ROOT_PATH, "external_reports");
+    File existingData = new File(location, "report.csv");
+    FileUtils.touch(existingData);
+    NameIdentifier ident = NameIdentifier.of("m1", "c1", schemaName, 
"external");
+
+    Fileset fileset =
+        createFileset(
+            ident.name(),
+            schemaName,
+            "comment",
+            Fileset.Type.EXTERNAL,
+            null,
+            location.toURI().toString());
+
+    Assertions.assertEquals(Fileset.Type.EXTERNAL, fileset.type());
+    try (FilesetCatalogOperations ops = new FilesetCatalogOperations(store, 
secretManager)) {
+      ops.initialize(ImmutableMap.of(), randomCatalogInfo("m1", "c1"), 
FILESET_PROPERTIES_METADATA);
+      Assertions.assertTrue(ops.dropFileset(ident));
+    }
+    Assertions.assertTrue(existingData.isFile());
+  }
+
+  /** Checks that metadata-only catalogs do not inspect or change existing 
storage. */
+  @Test
+  public void testCreateManagedFilesetOnNonEmptyLocationWhenFSOpsDisabled() 
throws IOException {
+    String schemaName = "schema_managed_non_empty_disabled";
+    createSchema(schemaName, "comment", null, null, true);
+    File location = new File(UNFORMALIZED_TEST_ROOT_PATH, 
"managed_reports_disabled");
+    File existingData = new File(location, "report.csv");
+    FileUtils.touch(existingData);
+
+    Fileset fileset =
+        createFileset(
+            "managed",
+            schemaName,
+            "comment",
+            Fileset.Type.MANAGED,
+            null,
+            location.toURI().toString(),
+            true);
+
+    Assertions.assertEquals(Fileset.Type.MANAGED, fileset.type());
+    Assertions.assertTrue(existingData.isFile());
+  }
+
   /** Checks independent catalog instances cannot both create the same 
fileset. */
   @Test
   public void testConcurrentCreateAcrossIndependentNodesPreservesWinner() 
throws Exception {
diff --git 
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetPropertiesMetadata.java
 
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetPropertiesMetadata.java
new file mode 100644
index 0000000000..95e76816b5
--- /dev/null
+++ 
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetPropertiesMetadata.java
@@ -0,0 +1,65 @@
+/*
+ * 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.fileset;
+
+import static 
org.apache.gravitino.file.Fileset.PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED;
+
+import com.google.common.collect.ImmutableMap;
+import org.apache.gravitino.catalog.PropertiesMetadataHelpers;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/** Tests the fileset catalog's property validation contract. */
+public class TestFilesetPropertiesMetadata {
+
+  /** Checks that the alter validator rejects both setting and removing the 
adoption property. */
+  @Test
+  public void testManagedLocationAdoptionPropertyCannotBeAltered() {
+    FilesetPropertiesMetadata metadata = new FilesetPropertiesMetadata();
+    IllegalArgumentException setFailure =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                PropertiesMetadataHelpers.validatePropertyForAlter(
+                    metadata,
+                    
ImmutableMap.of(PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED, "true"),
+                    ImmutableMap.of()));
+    Assertions.assertEquals(
+        "Property "
+            + PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED
+            + " is immutable or reserved, cannot be set",
+        setFailure.getMessage());
+
+    IllegalArgumentException removeFailure =
+        Assertions.assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                PropertiesMetadataHelpers.validatePropertyForAlter(
+                    metadata,
+                    ImmutableMap.of(),
+                    ImmutableMap.of(
+                        PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED,
+                        PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED)));
+    Assertions.assertEquals(
+        "Property "
+            + PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED
+            + " is immutable or reserved, cannot be deleted",
+        removeFailure.getMessage());
+  }
+}
diff --git a/clients/client-python/gravitino/api/file/fileset.py 
b/clients/client-python/gravitino/api/file/fileset.py
index cda622ceaf..5cb75a873c 100644
--- a/clients/client-python/gravitino/api/file/fileset.py
+++ b/clients/client-python/gravitino/api/file/fileset.py
@@ -61,6 +61,10 @@ class Fileset(Auditable):
     """The reserved property name for the fileset name placeholder, when 
creating a fileset, all
     placeholders as {{fileset}} will be replaced by the fileset name"""
 
+    PROPERTY_ALLOW_EXISTING_LOCATION_AS_MANAGED = 
"allow-existing-location-as-managed"
+    """Whether a managed fileset may adopt an existing non-empty location. 
Defaults to false
+    and cannot be changed after creation."""
+
     class Type(Enum):
         """An enum representing the type of the fileset object."""
 
diff --git a/docs/fileset-catalog.md b/docs/fileset-catalog.md
index caf1fc8847..e42b474285 100644
--- a/docs/fileset-catalog.md
+++ b/docs/fileset-catalog.md
@@ -206,6 +206,7 @@ Besides, the Fileset catalog fileset has the following 
properties:
 | `credential-providers`                | The credential provider types, 
separated by comma.                                                             
         | (none)                                                               
                                          | No                                  
       | No        |
 | `placeholder-`                        | Properties that start with 
`placeholder-` are used to replace placeholders in the location.                
             | (none)                                                           
                                              | No                              
           | Yes       |
 | `default-location-name`               | The name of the default location of 
the fileset, mainly used for GVFS operations without specifying a location 
name.    | When the fileset has only one location, its location name will be 
automatically selected as the default value. | Yes, if the fileset has multiple 
locations | Yes       |
+| `allow-existing-location-as-managed`  | Whether a managed fileset may be 
created on a location that already exists and is not empty. Such a location is 
rejected by default when filesystem operations are enabled; register it as an 
`EXTERNAL` fileset instead. | false                                             
                          | No                                         | Yes    
   |
 | `config.resources`                    | The configuration resources, 
separated by comma. For example, `hdfs-site.xml,core-site.xml`.                 
           | (none)                                                             
                                            | No                                
         | NO        |
 
 Some properties are reserved and cannot be set by users:
diff --git a/docs/filesets.md b/docs/filesets.md
index bc48661371..f286eca867 100644
--- a/docs/filesets.md
+++ b/docs/filesets.md
@@ -47,6 +47,8 @@ than federating an external one, so no provider is needed 
when creating it.
 **2. Create the fileset.** Give it a name, a type, and a storage location. 
Choose `MANAGED` to let
 Gravitino manage the data lifecycle, or `EXTERNAL` to retain the data when the 
fileset is dropped.
 Pointing at an existing path does not automatically make a fileset external.
+An existing empty directory can be adopted as `MANAGED`; when filesystem 
operations are enabled,
+adopting an existing non-empty directory requires 
`allow-existing-location-as-managed=true`.
 
 **3. Read it by name.** Engines reach the files through GVFS or the client 
rather than the raw path.
 See [How to use GVFS](./how-to-use-gvfs.md).
@@ -56,12 +58,17 @@ See [How to use GVFS](./how-to-use-gvfs.md).
 ### Managed and External
 
 A managed fileset belongs to Gravitino. Creating it creates the directory if 
needed, and deleting
-it deletes the data.
+it deletes the data. An existing empty directory can also be used. With 
filesystem operations enabled,
+creating a managed fileset on an existing non-empty location is rejected by 
default. Register such a
+location as an external fileset, or set `allow-existing-location-as-managed` 
to `true` to explicitly
+allow Gravitino to manage the existing data and delete it when the fileset is 
dropped.
 
 An external fileset leaves data lifecycle management to the user. Deleting it 
removes only the
 Gravitino record and preserves the files. With filesystem operations enabled, 
creating either
 type creates the storage directory if it does not exist.
 
+Choose the type based on who owns the data and whether dropping the fileset 
should delete it.
+
 An existing fileset can be converted between the two types using
 
[`FilesetChange.updateType`](./manage-fileset-metadata-using-gravitino.md#change-the-fileset-type).
 Conversion changes metadata only and preserves its identity, locations, and 
files. Converting to
diff --git a/docs/manage-fileset-metadata-using-gravitino.md 
b/docs/manage-fileset-metadata-using-gravitino.md
index 10ab499625..57f5febc41 100755
--- a/docs/manage-fileset-metadata-using-gravitino.md
+++ b/docs/manage-fileset-metadata-using-gravitino.md
@@ -21,6 +21,10 @@ managed and external, how storage locations and placeholders 
work, and how GVFS
 
 A fileset needs a name, a type, and a storage location. A `MANAGED` fileset is 
created and deleted
 with its data; an `EXTERNAL` one points at a location that already exists.
+With filesystem operations enabled, creating a `MANAGED` fileset on an 
existing non-empty location
+is rejected by default. Set `allow-existing-location-as-managed=true` at 
creation to explicitly adopt
+that data, including deleting it when the fileset is dropped. An existing 
empty directory needs no
+override.
 
 <Tabs groupId='language' queryString>
 <TabItem value="shell" label="REST">

Reply via email to