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

jerryshao 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 15731007ef [#12650] improvement(core): add OCC for fileset writes 
(#12656)
15731007ef is described below

commit 15731007efed9da8c840230d5539f22d76d77db1
Author: Qi Yu <[email protected]>
AuthorDate: Fri Aug 28 20:18:26 2026 +0800

    [#12650] improvement(core): add OCC for fileset writes (#12656)
    
    ### What changes were proposed in this pull request?
    
    - Use `current_version` as the OCC token for fileset alter and direct
    delete.
    - Advance the version and write a complete fileset snapshot for every
    successful alter.
    - Execute the root fileset CAS before version and dependent-row writes
    in one transaction.
    - Distinguish concurrent version changes from deleted, renamed, or moved
    filesets.
    - Keep versions monotonic during overwrite and use the persisted fileset
    identity for version rows.
    - Add comments explaining transaction ordering, CAS checks, and conflict
    classification.
    - Add service and SQL provider tests for the OCC behavior.
    
    This is a subtask of #12166 and follows the table OCC implementation in
    #12551.
    
    ### Why are the changes needed?
    
    Concurrent fileset writes could overwrite the winning version metadata,
    while a stale delete could remove data belonging to a newer fileset
    version. Overwrite could also reset the OCC version.
    
    Fix: #12650
    
    ### Does this PR introduce _any_ user-facing change?
    
    Stale fileset writes now fail with the existing optimistic-lock or
    not-found response instead of overwriting newer metadata. No API or
    property is added.
    
    ### How was this patch tested?
    
    - Ran the Fileset service, converter, and SQL provider tests on H2.
    - Ran `:core:check -PskipITs -PskipDockerTests=true`.
    - Ran Spotless and `git diff --check`.
    - MySQL and PostgreSQL execution was attempted, but the local database
    containers failed during connection setup before the test cases ran.
---
 .../catalog/fileset/FilesetCatalogOperations.java  | 103 ++--
 .../fileset/TestFilesetCatalogOperations.java      | 111 ++++
 .../java/org/apache/gravitino/EntityStore.java     |  79 +++
 .../gravitino/storage/relational/JDBCBackend.java  |  45 ++
 .../storage/relational/RelationalBackend.java      |  58 ++
 .../storage/relational/RelationalEntityStore.java  |  24 +-
 .../relational/mapper/FilesetMetaMapper.java       |  23 +-
 .../mapper/FilesetMetaSQLProviderFactory.java      |  24 +-
 .../relational/mapper/FilesetVersionMapper.java    |   9 +
 .../mapper/FilesetVersionSQLProviderFactory.java   |  10 +
 .../provider/base/FilesetMetaBaseSQLProvider.java  |  74 ++-
 .../base/FilesetVersionBaseSQLProvider.java        |  13 +
 .../postgresql/FilesetMetaPostgreSQLProvider.java  |  19 +-
 .../relational/service/FilesetMetaService.java     | 297 ++++++----
 .../storage/relational/utils/POConverters.java     |  94 ++-
 .../base/TestFilesetMetaBaseSQLProvider.java       |  77 +++
 .../TestFilesetMetaPostgreSQLProvider.java         |  51 ++
 .../relational/service/TestFilesetMetaService.java | 633 +++++++++++++++++++--
 .../storage/relational/utils/TestPOConverters.java |  28 +-
 19 files changed, 1501 insertions(+), 271 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 24670074f6..930429a2d0 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
@@ -39,6 +39,7 @@ import com.google.common.collect.Maps;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.UncheckedIOException;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -729,59 +730,75 @@ 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);
+      // The relational store runs this cleanup after the metadata CAS wins 
but before committing
+      // its transaction. The callback therefore sees the exact deleted 
snapshot, and an I/O
+      // failure can still restore the metadata so the caller may fix 
permissions and retry.
+      //
+      // The price is that the recursive storage delete runs inside that 
transaction, holding the
+      // fileset rows and a pooled connection for as long as the filesystem 
takes. Dropping a
+      // fileset with a very large tree is therefore a slow write for that 
fileset, and enough
+      // concurrent drops can hold up the connection pool.
+      Optional<FilesetEntity> deletedFileset =
+          store.deleteAndGet(
+              ident,
+              Entity.EntityType.FILESET,
+              FilesetEntity.class,
+              filesetEntity -> {
+                if (!disableFSOps && filesetEntity.filesetType() == 
Fileset.Type.MANAGED) {
+                  try {
+                    deleteManagedFilesetStorage(ident, filesetEntity);
+                  } catch (IOException ioe) {
+                    throw new UncheckedIOException(ioe);
                   }
-                } 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();
-        }
-      }
-
-      return store.delete(ident, Entity.EntityType.FILESET);
+              });
+      return deletedFileset.isPresent();
     } catch (NoSuchEntityException ne) {
       LOG.warn("Fileset {} does not exist", ident);
       return false;
+    } catch (UncheckedIOException uioe) {
+      throw new RuntimeException("Failed to delete fileset " + ident, 
uioe.getCause());
     } catch (IOException ioe) {
       throw new RuntimeException("Failed to delete fileset " + ident, ioe);
     }
   }
 
+  /**
+   * Removes the storage of a managed fileset while its metadata delete can 
still be rolled back.
+   *
+   * <p>The first location that cannot be removed stops the loop, so the drop 
is rejected before it
+   * takes away more data than it already has. The locations removed up to 
that point are gone for
+   * good, but attempting the remaining ones would only widen that gap.
+   */
+  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));
+      }
+    }
+  }
+
   @Override
   public String getFileLocation(NameIdentifier ident, String subPath, String 
locationName)
       throws NoSuchFilesetException, NoSuchLocationNameException {
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 d4b70d3a8d..1db30f01e2 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
@@ -76,6 +76,7 @@ import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.Config;
 import org.apache.gravitino.Configs;
+import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.EntityStoreFactory;
 import org.apache.gravitino.GravitinoEnv;
@@ -105,6 +106,7 @@ import 
org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.exceptions.NoSuchFilesetException;
 import org.apache.gravitino.exceptions.NoSuchSchemaException;
 import org.apache.gravitino.exceptions.NonEmptySchemaException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.exceptions.SchemaAlreadyExistsException;
 import org.apache.gravitino.file.FileInfo;
 import org.apache.gravitino.file.Fileset;
@@ -3386,6 +3388,115 @@ public class TestFilesetCatalogOperations {
             TEST_ROOT_PATH + "/fileset39"));
   }
 
+  @Test
+  public void testDropFilesetKeepsFilesWhenMetadataDropIsRejected() throws 
IOException {
+    String schemaName = "schema_drop_rejected";
+    String filesetName = "fileset_drop_rejected";
+    String catalogPath = TEST_ROOT_PATH + "/catalog_drop_rejected";
+    createSchema(schemaName, "comment", catalogPath, null);
+    Fileset fileset =
+        createFileset(filesetName, schemaName, "comment", 
Fileset.Type.MANAGED, catalogPath, null);
+
+    Path filesetPath = new Path(fileset.storageLocation());
+    FileSystem fs = filesetPath.getFileSystem(new Configuration());
+    Assertions.assertTrue(fs.exists(filesetPath));
+
+    NameIdentifier filesetIdent = NameIdentifier.of("m1", "c1", schemaName, 
filesetName);
+    EntityStore rejectingStore = Mockito.spy(store);
+    Mockito.doThrow(new OptimisticLockException("fileset was modified 
concurrently"))
+        .when(rejectingStore)
+        .deleteAndGet(
+            Mockito.eq(filesetIdent),
+            Mockito.eq(Entity.EntityType.FILESET),
+            Mockito.eq(FilesetEntity.class),
+            Mockito.any());
+
+    try (FilesetCatalogOperations ops =
+        new FilesetCatalogOperations(rejectingStore, secretManager)) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      Assertions.assertThrows(OptimisticLockException.class, () -> 
ops.dropFileset(filesetIdent));
+    }
+
+    // The drop was refused, so the fileset row still advertises this 
location. Deleting the files
+    // anyway would leave that row pointing at data that is gone.
+    Assertions.assertTrue(fs.exists(filesetPath));
+    Assertions.assertEquals(
+        filesetName,
+        store.get(filesetIdent, Entity.EntityType.FILESET, 
FilesetEntity.class).name());
+
+    fs.delete(filesetPath, true);
+  }
+
+  @Test
+  public void testDropFilesetRollsBackMetadataWhenStorageDeletionFails() 
throws IOException {
+    String schemaName = "schema_drop_storage_failure";
+    String filesetName = "fileset_drop_storage_failure";
+    String catalogPath = TEST_ROOT_PATH + "/catalog_drop_storage_failure";
+    createSchema(schemaName, "comment", catalogPath, null);
+    Fileset fileset =
+        createFileset(filesetName, schemaName, "comment", 
Fileset.Type.MANAGED, catalogPath, null);
+    NameIdentifier filesetIdent = NameIdentifier.of("m1", "c1", schemaName, 
filesetName);
+
+    FileSystem failingFileSystem = Mockito.mock(FileSystem.class);
+    when(failingFileSystem.exists(any(Path.class))).thenReturn(true);
+    when(failingFileSystem.delete(any(Path.class), Mockito.eq(true)))
+        .thenThrow(new IOException("permission denied"));
+
+    try (FilesetCatalogOperations ops =
+        Mockito.spy(new FilesetCatalogOperations(store, secretManager))) {
+      ops.initialize(
+          ImmutableMap.of(LOCATION, catalogPath),
+          randomCatalogInfo("m1", "c1"),
+          FILESET_PROPERTIES_METADATA);
+      
doReturn(failingFileSystem).when(ops).getFileSystemWithCache(any(Path.class), 
any(Map.class));
+
+      RuntimeException failure =
+          Assertions.assertThrows(RuntimeException.class, () -> 
ops.dropFileset(filesetIdent));
+      Assertions.assertTrue(failure.getMessage().contains("Failed to delete 
fileset"));
+    }
+
+    // The failed physical cleanup aborts the outer transaction, so the 
fileset remains visible and
+    // a caller can repair its filesystem permissions and retry the drop.
+    FilesetEntity survivingFileset =
+        store.get(filesetIdent, Entity.EntityType.FILESET, 
FilesetEntity.class);
+    Assertions.assertEquals(fileset.storageLocation(), 
survivingFileset.storageLocation());
+
+    store.delete(filesetIdent, Entity.EntityType.FILESET);
+    new Path(fileset.storageLocation())
+        .getFileSystem(new Configuration())
+        .delete(new Path(fileset.storageLocation()), true);
+  }
+
+  @Test
+  public void testDropFilesetSucceedsWhenTheLocationDisappearsFirst() throws 
IOException {
+    String schemaName = "schema_drop_vanished";
+    String filesetName = "fileset_drop_vanished";
+    String catalogPath = TEST_ROOT_PATH + "/catalog_drop_vanished";
+    createSchema(schemaName, "comment", catalogPath, null);
+    Fileset fileset =
+        createFileset(filesetName, schemaName, "comment", 
Fileset.Type.MANAGED, catalogPath, null);
+
+    Path filesetPath = new Path(fileset.storageLocation());
+    FileSystem fs = filesetPath.getFileSystem(new Configuration());
+    Assertions.assertTrue(fs.exists(filesetPath));
+    // Somebody else removed the location already. A drop that finds nothing 
left to delete has
+    // nothing to complain about.
+    Assertions.assertTrue(fs.delete(filesetPath, true));
+
+    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(ops.dropFileset(filesetIdent), "fileset should be 
non-existent");
+    }
+  }
+
   private Schema createSchema(String name, String comment, String catalogPath, 
String schemaPath)
       throws IOException {
     return createSchema(name, comment, catalogPath, schemaPath, false);
diff --git a/core/src/main/java/org/apache/gravitino/EntityStore.java 
b/core/src/main/java/org/apache/gravitino/EntityStore.java
index 1f844a2e27..c63458c720 100644
--- a/core/src/main/java/org/apache/gravitino/EntityStore.java
+++ b/core/src/main/java/org/apache/gravitino/EntityStore.java
@@ -23,6 +23,8 @@ 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;
@@ -221,6 +223,83 @@ public interface EntityStore extends Closeable {
    */
   boolean delete(NameIdentifier ident, EntityType entityType, boolean cascade) 
throws IOException;
 
+  /**
+   * The only post-delete action an implementation that cannot run it before 
commit accepts.
+   *
+   * <p>Compared by reference, so a caller that supplies its own action 
reaches an implementation
+   * that honors the contract or gets told that this one cannot.
+   */
+  Consumer<? extends Entity> NO_POST_DELETE_ACTION = ignored -> {};
+
+  /**
+   * Returns the shared no-op post-delete action.
+   *
+   * @param <E> the entity type
+   * @return an action that does nothing
+   */
+  @SuppressWarnings("unchecked")
+  static <E extends Entity & HasIdentifier> Consumer<E> noPostDeleteAction() {
+    return (Consumer<E>) NO_POST_DELETE_ACTION;
+  }
+
+  /**
+   * Deletes an entity and returns the snapshot chosen by the delete operation.
+   *
+   * <p>The default implementation is intended for stores that serialize 
operations through {@link
+   * #executeInTransaction(Executable)}. Stores that can read and delete with 
one native
+   * compare-and-set should override this method so the returned snapshot is 
exactly the one that
+   * was deleted.
+   *
+   * @param ident the name identifier of the entity
+   * @param entityType the type of the entity
+   * @param clazz the concrete entity class
+   * @param <E> the entity type
+   * @return the deleted entity, or empty when it did not exist
+   * @throws IOException if the delete operation fails
+   */
+  default <E extends Entity & HasIdentifier> Optional<E> deleteAndGet(
+      NameIdentifier ident, EntityType entityType, Class<E> clazz) throws 
IOException {
+    return deleteAndGet(ident, entityType, clazz, noPostDeleteAction());
+  }
+
+  /**
+   * Deletes an entity, runs an action against the deleted snapshot, and 
returns that snapshot.
+   *
+   * <p>A transactional store should run the action after its delete has won 
but before committing.
+   * This lets callers couple non-database cleanup to the metadata 
transaction: an action failure
+   * can still roll the metadata delete back.
+   *
+   * @param ident the name identifier of the entity
+   * @param entityType the type of the entity
+   * @param clazz the concrete entity class
+   * @param postDeleteAction the action to run after deletion but before 
commit when supported
+   * @param <E> the entity type
+   * @return the deleted entity, or empty when it did not exist
+   * @throws IOException if the delete operation fails
+   */
+  default <E extends Entity & HasIdentifier> Optional<E> deleteAndGet(
+      NameIdentifier ident, EntityType entityType, Class<E> clazz, Consumer<E> 
postDeleteAction)
+      throws IOException {
+    if (postDeleteAction != NO_POST_DELETE_ACTION) {
+      // This implementation can only run the action once the delete is 
committed, which is the
+      // opposite of what the contract promises. Refusing is better than 
silently leaving the
+      // caller with a committed delete and a failed cleanup.
+      throw new UnsupportedOperationException(
+          "This store cannot run a post-delete action while the delete can 
still be rolled back");
+    }
+
+    try {
+      E entity = get(ident, entityType, clazz);
+      if (!delete(ident, entityType)) {
+        return Optional.empty();
+      }
+      postDeleteAction.accept(entity);
+      return Optional.of(entity);
+    } catch (NoSuchEntityException e) {
+      return Optional.empty();
+    }
+  }
+
   /**
    * 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 11065934aa..2555396448 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
@@ -29,6 +29,8 @@ import java.io.IOException;
 import java.util.Arrays;
 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;
@@ -462,6 +464,49 @@ public class JDBCBackend implements RelationalBackend, 
SupportsOrphanedRelationC
     }
   }
 
+  @Override
+  public <E extends Entity & HasIdentifier> Optional<E> deleteAndGet(
+      NameIdentifier ident,
+      Entity.EntityType entityType,
+      Class<E> clazz,
+      Consumer<E> postDeleteAction)
+      throws IOException {
+    if (entityType != Entity.EntityType.FILESET) {
+      return RelationalBackend.super.deleteAndGet(ident, entityType, clazz, 
postDeleteAction);
+    }
+
+    boolean transactionOwner = !SessionUtils.isInTransaction();
+    if (transactionOwner) {
+      SessionUtils.beginTransaction();
+    }
+    boolean committed = false;
+    try {
+      FilesetEntity deletedFileset;
+      try {
+        deletedFileset = 
FilesetMetaService.getInstance().deleteFilesetAndGet(ident);
+      } catch (NoSuchEntityException e) {
+        // Only the delete itself may report the fileset as missing. A 
NoSuchEntityException from
+        // any later step means the delete did happen and something else 
failed, which must not be
+        // reported to the caller as "there was nothing to delete".
+        return Optional.empty();
+      }
+      insertEntityChange(ident, entityType, OperateType.DROP);
+      E deletedEntity = clazz.cast(deletedFileset);
+      // Run external cleanup while the metadata delete can still be rolled 
back. The callback uses
+      // the same snapshot whose OCC token won above, so it cannot act on 
stale locations.
+      postDeleteAction.accept(deletedEntity);
+      if (transactionOwner) {
+        SessionUtils.commitTransaction();
+      }
+      committed = true;
+      return Optional.of(deletedEntity);
+    } finally {
+      if (transactionOwner && !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 50e48af1c2..ea5a8aa46c 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,11 +21,14 @@ 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;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.EntityAlreadyExistsException;
+import org.apache.gravitino.EntityStore;
 import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
@@ -195,6 +198,61 @@ public interface RelationalBackend extends Closeable, 
SupportsRelationOperations
   boolean delete(NameIdentifier ident, Entity.EntityType entityType, boolean 
cascade)
       throws IOException;
 
+  /**
+   * Deletes an entity and returns the snapshot used by that delete.
+   *
+   * <p>Backends with a native compare-and-set delete should override this 
method so the returned
+   * entity and the deleted row are based on the same read.
+   *
+   * @param ident the identifier of the entity
+   * @param entityType the entity type
+   * @param clazz the concrete entity class
+   * @param <E> the concrete entity type
+   * @return the deleted entity, or empty when it did not exist
+   * @throws IOException if the store operation fails
+   */
+  default <E extends Entity & HasIdentifier> Optional<E> deleteAndGet(
+      NameIdentifier ident, Entity.EntityType entityType, Class<E> clazz) 
throws IOException {
+    return deleteAndGet(ident, entityType, clazz, 
EntityStore.noPostDeleteAction());
+  }
+
+  /**
+   * Deletes an entity and runs an action against the deleted snapshot before 
commit when supported.
+   *
+   * @param ident the identifier of the entity
+   * @param entityType the entity type
+   * @param clazz the concrete entity class
+   * @param postDeleteAction the action to run after deletion but before 
commit when supported
+   * @param <E> the concrete entity type
+   * @return the deleted entity, or empty when it did not exist
+   * @throws IOException if the store operation fails
+   */
+  default <E extends Entity & HasIdentifier> Optional<E> deleteAndGet(
+      NameIdentifier ident,
+      Entity.EntityType entityType,
+      Class<E> clazz,
+      Consumer<E> postDeleteAction)
+      throws IOException {
+    if (postDeleteAction != EntityStore.NO_POST_DELETE_ACTION) {
+      // This implementation can only run the action once the delete is 
committed, which is the
+      // opposite of what the contract promises. Refusing is better than 
silently leaving the
+      // caller with a committed delete and a failed cleanup.
+      throw new UnsupportedOperationException(
+          "This store cannot run a post-delete action while the delete can 
still be rolled back");
+    }
+
+    try {
+      E entity = get(ident, entityType);
+      if (!delete(ident, entityType, false)) {
+        return Optional.empty();
+      }
+      postDeleteAction.accept(entity);
+      return Optional.of(entity);
+    } catch (NoSuchEntityException e) {
+      return Optional.empty();
+    }
+  }
+
   /**
    * 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 bf6c50ee09..0d643dfba8 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.Arrays;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
 import java.util.function.Function;
 import javax.annotation.Nullable;
 import org.apache.commons.lang3.tuple.Pair;
@@ -178,7 +179,14 @@ public class RelationalEntityStore
   public <E extends Entity & HasIdentifier> void put(E e, boolean overwritten)
       throws IOException, EntityAlreadyExistsException {
     backend.insert(e, overwritten);
-    cache.put(e);
+    if (overwritten) {
+      // An overwrite is resolved by the database, which may keep the identity 
and version of the
+      // row it already had. Caching the copy handed in here would publish 
values the stored row
+      // does not carry, so the next read is served from the backend instead.
+      cache.invalidate(e.nameIdentifier(), e.type());
+    } else {
+      cache.put(e);
+    }
   }
 
   @Override
@@ -320,6 +328,20 @@ public class RelationalEntityStore
     }
   }
 
+  @Override
+  public <E extends Entity & HasIdentifier> Optional<E> deleteAndGet(
+      NameIdentifier ident,
+      Entity.EntityType entityType,
+      Class<E> clazz,
+      Consumer<E> postDeleteAction)
+      throws IOException {
+    try {
+      return backend.deleteAndGet(ident, entityType, clazz, postDeleteAction);
+    } finally {
+      cache.invalidate(ident, entityType);
+    }
+  }
+
   @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/mapper/FilesetMetaMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetMetaMapper.java
index fcbbc66c0b..fbd2ca9c26 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetMetaMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetMetaMapper.java
@@ -187,6 +187,19 @@ public interface FilesetMetaMapper {
   @SelectProvider(type = FilesetMetaSQLProviderFactory.class, method = 
"selectFilesetMetaById")
   FilesetPO selectFilesetMetaById(@Param("filesetId") Long filesetId);
 
+  /**
+   * Selects an active fileset metadata row by schema and name in the current 
transaction.
+   *
+   * @param schemaId the schema ID
+   * @param filesetName the fileset name
+   * @return the active fileset metadata, or {@code null} when it does not 
exist
+   */
+  @SelectProvider(
+      type = FilesetMetaSQLProviderFactory.class,
+      method = "selectFilesetMetaBySchemaIdAndNameForUpdate")
+  FilesetPO selectFilesetMetaBySchemaIdAndNameForUpdate(
+      @Param("schemaId") Long schemaId, @Param("filesetName") String 
filesetName);
+
   @Results({
     @Result(property = "filesetId", column = "fileset_id", id = true),
     @Result(property = "filesetName", column = "fileset_name"),
@@ -243,10 +256,18 @@ public interface FilesetMetaMapper {
       method = "softDeleteFilesetMetasBySchemaIds")
   Integer softDeleteFilesetMetasBySchemaIds(@Param("schemaIds") List<Long> 
schemaIds);
 
+  /**
+   * Soft-deletes a fileset only if its version has not changed since the 
caller read it.
+   *
+   * @param filesetId the fileset ID
+   * @param currentVersion the version observed by the caller
+   * @return the number of deleted rows; zero means the fileset changed or 
disappeared
+   */
   @UpdateProvider(
       type = FilesetMetaSQLProviderFactory.class,
       method = "softDeleteFilesetMetasByFilesetId")
-  Integer softDeleteFilesetMetasByFilesetId(@Param("filesetId") Long 
filesetId);
+  Integer softDeleteFilesetMetasByFilesetId(
+      @Param("filesetId") Long filesetId, @Param("currentVersion") Long 
currentVersion);
 
   @DeleteProvider(
       type = FilesetMetaSQLProviderFactory.class,
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetMetaSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetMetaSQLProviderFactory.java
index 07aaebb1cb..b09dd069e8 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetMetaSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetMetaSQLProviderFactory.java
@@ -81,6 +81,18 @@ public class FilesetMetaSQLProviderFactory {
     return getProvider().selectFilesetMetaById(filesetId);
   }
 
+  /**
+   * Returns SQL that selects an active fileset metadata row by schema and 
name.
+   *
+   * @param schemaId the schema ID
+   * @param filesetName the fileset name
+   * @return the metadata-only select SQL
+   */
+  public static String selectFilesetMetaBySchemaIdAndNameForUpdate(
+      @Param("schemaId") Long schemaId, @Param("filesetName") String 
filesetName) {
+    return getProvider().selectFilesetMetaBySchemaIdAndNameForUpdate(schemaId, 
filesetName);
+  }
+
   public static String selectFilesetByFullQualifiedName(
       @Param("metalakeName") String metalakeName,
       @Param("catalogName") String catalogName,
@@ -117,8 +129,16 @@ public class FilesetMetaSQLProviderFactory {
     return getProvider().softDeleteFilesetMetasBySchemaIds(schemaIds);
   }
 
-  public String softDeleteFilesetMetasByFilesetId(@Param("filesetId") Long 
filesetId) {
-    return getProvider().softDeleteFilesetMetasByFilesetId(filesetId);
+  /**
+   * Returns SQL that soft-deletes a fileset by ID and expected version.
+   *
+   * @param filesetId the fileset ID
+   * @param currentVersion the version observed by the caller
+   * @return the version-checked delete SQL
+   */
+  public static String softDeleteFilesetMetasByFilesetId(
+      @Param("filesetId") Long filesetId, @Param("currentVersion") Long 
currentVersion) {
+    return getProvider().softDeleteFilesetMetasByFilesetId(filesetId, 
currentVersion);
   }
 
   public String deleteFilesetMetasByLegacyTimeline(
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetVersionMapper.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetVersionMapper.java
index 931110faf0..2263b9f140 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetVersionMapper.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetVersionMapper.java
@@ -74,6 +74,15 @@ public interface FilesetVersionMapper {
   Integer deleteFilesetVersionsByLegacyTimeline(
       @Param("legacyTimeline") Long legacyTimeline, @Param("limit") int limit);
 
+  /**
+   * Returns the highest live version recorded for a fileset, or {@code null} 
when it has none.
+   *
+   * @param filesetId the fileset whose versions are inspected
+   * @return the highest version still present in the version table
+   */
+  @SelectProvider(type = FilesetVersionSQLProviderFactory.class, method = 
"selectMaxFilesetVersion")
+  Long selectMaxFilesetVersion(@Param("filesetId") Long filesetId);
+
   @SelectProvider(
       type = FilesetVersionSQLProviderFactory.class,
       method = "selectFilesetVersionsByRetentionCount")
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetVersionSQLProviderFactory.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetVersionSQLProviderFactory.java
index 0d29d6f3f8..eebb7e6802 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetVersionSQLProviderFactory.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/FilesetVersionSQLProviderFactory.java
@@ -83,6 +83,16 @@ public class FilesetVersionSQLProviderFactory {
     return getProvider().deleteFilesetVersionsByLegacyTimeline(legacyTimeline, 
limit);
   }
 
+  /**
+   * Returns SQL that finds the highest active snapshot version for a fileset.
+   *
+   * @param filesetId the fileset ID
+   * @return the maximum-version query for the configured database
+   */
+  public static String selectMaxFilesetVersion(@Param("filesetId") Long 
filesetId) {
+    return getProvider().selectMaxFilesetVersion(filesetId);
+  }
+
   public static String selectFilesetVersionsByRetentionCount(
       @Param("versionRetentionCount") Long versionRetentionCount) {
     return 
getProvider().selectFilesetVersionsByRetentionCount(versionRetentionCount);
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FilesetMetaBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FilesetMetaBaseSQLProvider.java
index 8a1ad653c8..6a420fce1d 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FilesetMetaBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FilesetMetaBaseSQLProvider.java
@@ -223,6 +223,29 @@ public class FilesetMetaBaseSQLProvider {
         + " AND fm.deleted_at = 0 AND vi.deleted_at = 0";
   }
 
+  /**
+   * Returns the active fileset metadata row selected by its natural key.
+   *
+   * <p>An overwrite may match the natural key instead of the incoming ID. 
Reading the stored row
+   * after the upsert tells dependent version rows which ID and 
database-generated version to use.
+   *
+   * @param schemaId the schema ID
+   * @param filesetName the fileset name
+   * @return the metadata-only select SQL
+   */
+  public String selectFilesetMetaBySchemaIdAndNameForUpdate(
+      @Param("schemaId") Long schemaId, @Param("filesetName") String 
filesetName) {
+    return "SELECT fileset_id as filesetId, fileset_name as filesetName,"
+        + " metalake_id as metalakeId, catalog_id as catalogId, schema_id as 
schemaId,"
+        + " type as type, audit_info as auditInfo,"
+        + " current_version as currentVersion, last_version as lastVersion,"
+        + " deleted_at as deletedAt"
+        + " FROM "
+        + META_TABLE_NAME
+        + " WHERE schema_id = #{schemaId} AND fileset_name = #{filesetName}"
+        + " AND deleted_at = 0 FOR UPDATE";
+  }
+
   public String insertFilesetMeta(@Param("filesetMeta") FilesetPO filesetPO) {
     return "INSERT INTO "
         + META_TABLE_NAME
@@ -268,11 +291,31 @@ public class FilesetMetaBaseSQLProvider {
         + " schema_id = #{filesetMeta.schemaId},"
         + " type = #{filesetMeta.type},"
         + " audit_info = #{filesetMeta.auditInfo},"
-        + " current_version = #{filesetMeta.currentVersion},"
-        + " last_version = #{filesetMeta.lastVersion},"
+        // An overwrite is also a write observed by OCC. Advance from the 
stored value instead of
+        // resetting the row to the initial version carried by the incoming 
create request.
+        //
+        // Keep current_version last: MySQL evaluates these assignments left 
to right against the
+        // columns already assigned, while H2 and PostgreSQL evaluate every 
right-hand side against
+        // the row as it was before the update. Both agree only while 
current_version is read
+        // before it is assigned.
+        + " last_version = current_version + 1,"
+        + " current_version = current_version + 1,"
         + " deleted_at = #{filesetMeta.deletedAt}";
   }
 
+  /**
+   * Returns SQL that updates a fileset only while its OCC version is 
unchanged and its next
+   * snapshot version is free.
+   *
+   * <p>The version is the concurrency token, so payload, name, and audit 
columns are deliberately
+   * excluded from the predicate. This also detects change-then-change-back 
races that a full-row
+   * comparison would miss. The snapshot check detects rows affected by the 
legacy overwrite bug
+   * without requiring a separate {@code MAX(version)} query on every normal 
alter.
+   *
+   * @param newFilesetPO the new fileset values
+   * @param oldFilesetPO the fileset values and version observed by the caller
+   * @return the version-checked update SQL
+   */
   public String updateFilesetMeta(
       @Param("newFilesetMeta") FilesetPO newFilesetPO,
       @Param("oldFilesetMeta") FilesetPO oldFilesetPO) {
@@ -288,15 +331,13 @@ public class FilesetMetaBaseSQLProvider {
         + " last_version = #{newFilesetMeta.lastVersion},"
         + " deleted_at = #{newFilesetMeta.deletedAt}"
         + " WHERE fileset_id = #{oldFilesetMeta.filesetId}"
-        + " AND fileset_name = #{oldFilesetMeta.filesetName}"
-        + " AND metalake_id = #{oldFilesetMeta.metalakeId}"
-        + " AND catalog_id = #{oldFilesetMeta.catalogId}"
-        + " AND schema_id = #{oldFilesetMeta.schemaId}"
-        + " AND type = #{oldFilesetMeta.type}"
-        + " AND audit_info = #{oldFilesetMeta.auditInfo}"
         + " AND current_version = #{oldFilesetMeta.currentVersion}"
-        + " AND last_version = #{oldFilesetMeta.lastVersion}"
-        + " AND deleted_at = 0";
+        + " AND deleted_at = 0"
+        + " AND NOT EXISTS (SELECT 1 FROM "
+        + VERSION_TABLE_NAME
+        + " fv WHERE fv.fileset_id = #{oldFilesetMeta.filesetId}"
+        + " AND fv.version >= #{newFilesetMeta.currentVersion}"
+        + " AND fv.deleted_at = 0)";
   }
 
   public String softDeleteFilesetMetasByMetalakeId(@Param("metalakeId") Long 
metalakeId) {
@@ -329,12 +370,21 @@ public class FilesetMetaBaseSQLProvider {
         + "</script>";
   }
 
-  public String softDeleteFilesetMetasByFilesetId(@Param("filesetId") Long 
filesetId) {
+  /**
+   * Returns SQL that deletes only the fileset version observed by the caller.
+   *
+   * @param filesetId the fileset ID
+   * @param currentVersion the version observed by the caller
+   * @return the version-checked delete SQL
+   */
+  public String softDeleteFilesetMetasByFilesetId(
+      @Param("filesetId") Long filesetId, @Param("currentVersion") Long 
currentVersion) {
     return "UPDATE "
         + META_TABLE_NAME
         + " SET deleted_at = (UNIX_TIMESTAMP() * 1000.0)"
         + " + EXTRACT(MICROSECOND FROM CURRENT_TIMESTAMP(3)) / 1000"
-        + " WHERE fileset_id = #{filesetId} AND deleted_at = 0";
+        + " WHERE fileset_id = #{filesetId}"
+        + " AND current_version = #{currentVersion} AND deleted_at = 0";
   }
 
   public String deleteFilesetMetasByLegacyTimeline(
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FilesetVersionBaseSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FilesetVersionBaseSQLProvider.java
index 12204fe0ce..61e013ac1c 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FilesetVersionBaseSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/base/FilesetVersionBaseSQLProvider.java
@@ -116,6 +116,19 @@ public class FilesetVersionBaseSQLProvider {
         + " WHERE deleted_at > 0 AND deleted_at < #{legacyTimeline} LIMIT 
#{limit}";
   }
 
+  /**
+   * Returns SQL that finds the highest active snapshot version owned by a 
fileset.
+   *
+   * @param filesetId the fileset ID
+   * @return the maximum-version query
+   */
+  public String selectMaxFilesetVersion(@Param("filesetId") Long filesetId) {
+    return "SELECT MAX(version)"
+        + " FROM "
+        + VERSION_TABLE_NAME
+        + " WHERE fileset_id = #{filesetId} AND deleted_at = 0";
+  }
+
   public String selectFilesetVersionsByRetentionCount(
       @Param("versionRetentionCount") Long versionRetentionCount) {
     return "SELECT fileset_id as filesetId,"
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FilesetMetaPostgreSQLProvider.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FilesetMetaPostgreSQLProvider.java
index ebb4d1731f..84e53bb79a 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FilesetMetaPostgreSQLProvider.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/FilesetMetaPostgreSQLProvider.java
@@ -57,11 +57,12 @@ public class FilesetMetaPostgreSQLProvider extends 
FilesetMetaBaseSQLProvider {
   }
 
   @Override
-  public String softDeleteFilesetMetasByFilesetId(Long filesetId) {
+  public String softDeleteFilesetMetasByFilesetId(Long filesetId, Long 
currentVersion) {
     return "UPDATE "
         + META_TABLE_NAME
         + " SET deleted_at = CAST(EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000 
AS BIGINT)"
-        + " WHERE fileset_id = #{filesetId} AND deleted_at = 0";
+        + " WHERE fileset_id = #{filesetId}"
+        + " AND current_version = #{currentVersion} AND deleted_at = 0";
   }
 
   @Override
@@ -93,15 +94,23 @@ public class FilesetMetaPostgreSQLProvider extends 
FilesetMetaBaseSQLProvider {
         + " #{filesetMeta.lastVersion},"
         + " #{filesetMeta.deletedAt}"
         + " )"
-        + " ON CONFLICT(fileset_id) DO UPDATE SET"
+        // Overwrite is selected by name, and a create request normally 
carries a newly generated
+        // ID. Target the natural key so PostgreSQL preserves the ID of the 
row being replaced, the
+        // same behavior that MySQL and H2 provide for their duplicate-key 
upsert.
+        + " ON CONFLICT(schema_id, fileset_name, deleted_at) DO UPDATE SET"
         + " fileset_name = #{filesetMeta.filesetName},"
         + " metalake_id = #{filesetMeta.metalakeId},"
         + " catalog_id = #{filesetMeta.catalogId},"
         + " schema_id = #{filesetMeta.schemaId},"
         + " type = #{filesetMeta.type},"
         + " audit_info = #{filesetMeta.auditInfo},"
-        + " current_version = #{filesetMeta.currentVersion},"
-        + " last_version = #{filesetMeta.lastVersion},"
+        // PostgreSQL requires the stored row to be qualified on the update 
side of ON CONFLICT.
+        + " current_version = "
+        + META_TABLE_NAME
+        + ".current_version + 1,"
+        + " last_version = "
+        + META_TABLE_NAME
+        + ".current_version + 1,"
         + " deleted_at = #{filesetMeta.deletedAt}";
   }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java
index b29d198193..70ce93113a 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/FilesetMetaService.java
@@ -22,9 +22,12 @@ import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATI
 
 import com.google.common.base.Preconditions;
 import java.io.IOException;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 import java.util.Objects;
-import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
@@ -33,6 +36,7 @@ import org.apache.gravitino.HasIdentifier;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.StringIdentifier;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
 import org.apache.gravitino.meta.FilesetEntity;
 import org.apache.gravitino.meta.NamespacedEntityId;
@@ -163,8 +167,10 @@ public class FilesetMetaService {
       fillFilesetPOBuilderParentEntityId(builder, filesetEntity.namespace());
 
       FilesetPO po = 
POConverters.initializeFilesetPOWithVersion(filesetEntity, builder);
+      AtomicReference<FilesetPO> persistedPO = new AtomicReference<>(po);
 
-      // insert both fileset meta table and version table
+      // The schema lock, metadata row, and every storage-location version row 
share one
+      // transaction. A failure in any later step restores all earlier writes.
       SessionUtils.doMultipleWithCommit(
           // Hold the parent schema row until this transaction ends, so the 
fileset cannot be
           // written below a schema that is being dropped.
@@ -179,22 +185,42 @@ public class FilesetMetaService {
               SessionUtils.doWithoutCommit(
                   FilesetMetaMapper.class,
                   mapper -> {
-                    if (overwrite) {
-                      mapper.insertFilesetMetaOnDuplicateKeyUpdate(po);
-                    } else {
+                    FilesetPO storedPO =
+                        overwrite
+                            ? 
mapper.selectFilesetMetaBySchemaIdAndNameForUpdate(
+                                po.getSchemaId(), po.getFilesetName())
+                            : null;
+                    if (storedPO == null) {
                       mapper.insertFilesetMeta(po);
+                      return;
                     }
+
+                    // Resolve the natural-key overwrite before building its 
snapshot. This keeps
+                    // the stored ID in both the metadata row and identifier 
property without a
+                    // post-insert JSON/PO rewrite.
+                    FilesetEntity replacement =
+                        filesetWithPersistedId(filesetEntity, 
storedPO.getFilesetId());
+                    Long maxStoredVersion =
+                        SessionUtils.getWithoutCommit(
+                            FilesetVersionMapper.class,
+                            versionMapper ->
+                                
versionMapper.selectMaxFilesetVersion(storedPO.getFilesetId()));
+                    FilesetPO replacementPO =
+                        POConverters.updateFilesetPOWithVersion(
+                            storedPO, replacement, maxStoredVersion);
+                    Integer updated = mapper.updateFilesetMeta(replacementPO, 
storedPO);
+                    Preconditions.checkState(
+                        updated != null && updated == 1,
+                        "The overwritten fileset %s in schema %s changed while 
its row was held",
+                        po.getFilesetName(),
+                        po.getSchemaId());
+                    persistedPO.set(replacementPO);
                   }),
           () ->
               SessionUtils.doWithoutCommit(
                   FilesetVersionMapper.class,
-                  mapper -> {
-                    if (overwrite) {
-                      
mapper.insertFilesetVersionsOnDuplicateKeyUpdate(po.getFilesetVersionPOs());
-                    } else {
-                      mapper.insertFilesetVersions(po.getFilesetVersionPOs());
-                    }
-                  }));
+                  mapper ->
+                      
mapper.insertFilesetVersions(persistedPO.get().getFilesetVersionPOs())));
     } catch (RuntimeException re) {
       ExceptionUtils.checkSQLException(
           re, Entity.EntityType.FILESET, 
filesetEntity.nameIdentifier().toString());
@@ -213,120 +239,68 @@ public class FilesetMetaService {
     FilesetEntity newEntity = (FilesetEntity) updater.apply((E) 
oldFilesetEntity);
     Preconditions.checkArgument(
         Objects.equals(oldFilesetEntity.id(), newEntity.id()),
-        "The updated fileset entity id: %s should be same with the table 
entity id before: %s",
+        "The updated fileset entity id: %s should be same with the fileset 
entity id before: %s",
         newEntity.id(),
         oldFilesetEntity.id());
 
-    Integer updateResult;
     try {
-      boolean checkNeedUpdateVersion =
-          POConverters.checkFilesetVersionNeedUpdate(
-              oldFilesetPO.getFilesetVersionPOs(), newEntity);
       FilesetPO newFilesetPO =
-          POConverters.updateFilesetPOWithVersion(oldFilesetPO, newEntity, 
checkNeedUpdateVersion);
-      if (checkNeedUpdateVersion) {
-        // These operations are performed atomically within a single 
transaction. The version
-        // insert is protected by a unique constraint on `fileset_id + version 
+ deleted_at`. If
-        // the meta update affects 0 rows (concurrent modification), the 
transaction is rolled
-        // back — including the version insert — and the update is treated as 
a conflict.
-        int[] metaUpdateCountRef = new int[1];
-        try {
-          SessionUtils.doMultipleWithCommit(
-              () ->
-                  SessionUtils.doWithoutCommit(
-                      FilesetVersionMapper.class,
-                      mapper -> 
mapper.insertFilesetVersions(newFilesetPO.getFilesetVersionPOs())),
-              () -> {
-                metaUpdateCountRef[0] =
-                    SessionUtils.getWithoutCommit(
-                        FilesetMetaMapper.class,
-                        mapper -> mapper.updateFilesetMeta(newFilesetPO, 
oldFilesetPO));
-                if (metaUpdateCountRef[0] == 0) {
-                  throw new RuntimeException("Failed to update the entity: " + 
identifier);
-                }
-              });
-          updateResult = 1;
-        } catch (RuntimeException re) {
-          if (metaUpdateCountRef[0] == 0) {
-            // The meta update matched no rows; the transaction was rolled 
back,
-            // including the version insert above.
-            throw new IOException("Failed to update the entity: " + 
identifier);
-          } else {
-            ExceptionUtils.checkSQLException(
-                re, Entity.EntityType.FILESET, 
newEntity.nameIdentifier().toString());
-            throw re;
-          }
-        }
-      } else {
-        int[] metaUpdateCountRef = new int[1];
-        SessionUtils.doMultipleWithCommit(
-            () ->
-                metaUpdateCountRef[0] =
-                    SessionUtils.getWithoutCommit(
-                        FilesetMetaMapper.class,
-                        mapper -> mapper.updateFilesetMeta(newFilesetPO, 
oldFilesetPO)));
-        updateResult = metaUpdateCountRef[0];
+          POConverters.updateFilesetPOWithVersion(oldFilesetPO, newEntity, 
null);
+      if (tryUpdateFileset(newFilesetPO, oldFilesetPO)) {
+        return newEntity;
+      }
+
+      // The metadata CAS also rejects a version that already has an active 
stored snapshot. Only
+      // that uncommon legacy case needs the MAX(version) round trip; normal 
alters finish above.
+      Long maxStoredVersion =
+          SessionUtils.getWithoutCommit(
+              FilesetVersionMapper.class,
+              mapper -> 
mapper.selectMaxFilesetVersion(oldFilesetPO.getFilesetId()));
+      if (maxStoredVersion != null
+          && maxStoredVersion >= newFilesetPO.getCurrentVersion()
+          && tryUpdateFileset(
+              POConverters.updateFilesetPOWithVersion(oldFilesetPO, newEntity, 
maxStoredVersion),
+              oldFilesetPO)) {
+        return newEntity;
       }
+
+      throw filesetWriteFailure(identifier, oldFilesetPO);
     } catch (RuntimeException re) {
       ExceptionUtils.checkSQLException(
           re, Entity.EntityType.FILESET, 
newEntity.nameIdentifier().toString());
       throw re;
     }
-
-    if (updateResult > 0) {
-      return newEntity;
-    } else {
-      throw new IOException("Failed to update the entity: " + identifier);
-    }
   }
 
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "deleteFileset")
   public boolean deleteFileset(NameIdentifier identifier) {
+    deleteFilesetAndGet(identifier);
+    return true;
+  }
+
+  /**
+   * Deletes a fileset and returns the exact entity snapshot protected by the 
delete CAS.
+   *
+   * <p>Callers that also remove managed storage use this snapshot rather than 
a separate earlier
+   * read. Otherwise an alter between the two reads could make metadata 
deletion succeed for new
+   * locations while physical cleanup still deletes the old locations.
+   *
+   * @param identifier the fileset identifier
+   * @return the fileset snapshot that was deleted
+   */
+  public FilesetEntity deleteFilesetAndGet(NameIdentifier identifier) {
     FilesetPO filesetPO = getFilesetPOByIdentifier(identifier);
-    Long filesetId = filesetPO.getFilesetId();
+    FilesetEntity deletedFileset = POConverters.fromFilesetPO(filesetPO, 
identifier.namespace());
 
-    // We should delete meta and version info
-    AtomicInteger deleteResult = new AtomicInteger(0);
+    // Delete the root row first and only if it still has the version we read. 
A stale drop stops
+    // before it can remove versions, tags, policies, or any other related 
data.
     SessionUtils.doMultipleWithCommit(
-        () ->
-            deleteResult.set(
-                SessionUtils.getWithoutCommit(
-                    FilesetMetaMapper.class,
-                    mapper -> 
mapper.softDeleteFilesetMetasByFilesetId(filesetId))),
-        () -> {
-          if (deleteResult.get() > 0) {
-            SessionUtils.doWithoutCommit(
-                FilesetVersionMapper.class,
-                mapper -> 
mapper.softDeleteFilesetVersionsByFilesetId(filesetId));
-            SessionUtils.doWithoutCommit(
-                OwnerMetaMapper.class,
-                mapper ->
-                    mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
-                        filesetId, MetadataObject.Type.FILESET.name()));
-            SessionUtils.doWithoutCommit(
-                SecurableObjectMapper.class,
-                mapper ->
-                    mapper.softDeleteObjectRelsByMetadataObject(
-                        filesetId, MetadataObject.Type.FILESET.name()));
-            SessionUtils.doWithoutCommit(
-                TagMetadataObjectRelMapper.class,
-                mapper ->
-                    mapper.softDeleteTagMetadataObjectRelsByMetadataObject(
-                        filesetId, MetadataObject.Type.FILESET.name()));
-            SessionUtils.doWithoutCommit(
-                StatisticMetaMapper.class,
-                mapper -> mapper.softDeleteStatisticsByEntityId(filesetId));
-            SessionUtils.doWithoutCommit(
-                PolicyMetadataObjectRelMapper.class,
-                mapper ->
-                    mapper.softDeletePolicyMetadataObjectRelsByMetadataObject(
-                        filesetId, MetadataObject.Type.FILESET.name()));
-          }
-        });
+        () -> deleteFilesetWithVersion(identifier, filesetPO),
+        () -> deleteFilesetDependents(filesetPO.getFilesetId()));
 
-    return deleteResult.get() > 0;
+    return deletedFileset;
   }
 
   @Monitored(
@@ -480,4 +454,117 @@ public class FilesetMetaService {
           return POConverters.fromFilesetPOs(filesetPOs, 
firstIdent.namespace());
         });
   }
+
+  /**
+   * Soft-deletes the observed fileset metadata row without starting a 
transaction.
+   *
+   * <p>The caller must run this method in the same transaction as dependent 
cleanup. Package access
+   * also lets concurrency tests submit a deliberately stale snapshot without 
duplicating the
+   * production CAS logic.
+   *
+   * @param identifier the fileset identity observed by the caller
+   * @param observedFilesetPO the fileset row and OCC version observed by the 
caller
+   */
+  void deleteFilesetWithVersion(NameIdentifier identifier, FilesetPO 
observedFilesetPO) {
+    int deleted =
+        SessionUtils.getWithoutCommit(
+            FilesetMetaMapper.class,
+            mapper ->
+                mapper.softDeleteFilesetMetasByFilesetId(
+                    observedFilesetPO.getFilesetId(), 
observedFilesetPO.getCurrentVersion()));
+    if (deleted == 0) {
+      throw filesetWriteFailure(identifier, observedFilesetPO);
+    }
+  }
+
+  private boolean tryUpdateFileset(FilesetPO newFilesetPO, FilesetPO 
oldFilesetPO) {
+    AtomicBoolean updated = new AtomicBoolean(false);
+    SessionUtils.doMultipleWithCommit(
+        () -> {
+          Integer updateCount =
+              SessionUtils.getWithoutCommit(
+                  FilesetMetaMapper.class,
+                  mapper -> mapper.updateFilesetMeta(newFilesetPO, 
oldFilesetPO));
+          updated.set(updateCount != null && updateCount > 0);
+        },
+        () -> {
+          if (updated.get()) {
+            // The metadata row now points to this complete snapshot. It stays 
in the same
+            // transaction so a failed version insert also restores the 
metadata version.
+            SessionUtils.doWithoutCommit(
+                FilesetVersionMapper.class,
+                mapper -> 
mapper.insertFilesetVersions(newFilesetPO.getFilesetVersionPOs()));
+          }
+        });
+    return updated.get();
+  }
+
+  private FilesetEntity filesetWithPersistedId(FilesetEntity filesetEntity, 
Long persistedId) {
+    Map<String, String> properties = filesetEntity.properties();
+    if (properties != null && properties.containsKey(StringIdentifier.ID_KEY)) 
{
+      properties = new HashMap<>(properties);
+      properties.put(StringIdentifier.ID_KEY, 
StringIdentifier.fromId(persistedId).toString());
+    }
+
+    return FilesetEntity.builder()
+        .withId(persistedId)
+        .withName(filesetEntity.name())
+        .withNamespace(filesetEntity.namespace())
+        .withComment(filesetEntity.comment())
+        .withFilesetType(filesetEntity.filesetType())
+        .withStorageLocations(filesetEntity.storageLocations())
+        .withProperties(properties)
+        .withAuditInfo(filesetEntity.auditInfo())
+        .build();
+  }
+
+  private void deleteFilesetDependents(Long filesetId) {
+    // The fileset row has already passed its version check. All cleanup below 
uses the same
+    // transaction, so either the root and every related row are deleted 
together, or none are.
+    SessionUtils.doWithoutCommit(
+        FilesetVersionMapper.class,
+        mapper -> mapper.softDeleteFilesetVersionsByFilesetId(filesetId));
+    SessionUtils.doWithoutCommit(
+        OwnerMetaMapper.class,
+        mapper ->
+            mapper.softDeleteOwnerRelByMetadataObjectIdAndType(
+                filesetId, MetadataObject.Type.FILESET.name()));
+    SessionUtils.doWithoutCommit(
+        SecurableObjectMapper.class,
+        mapper ->
+            mapper.softDeleteObjectRelsByMetadataObject(
+                filesetId, MetadataObject.Type.FILESET.name()));
+    SessionUtils.doWithoutCommit(
+        TagMetadataObjectRelMapper.class,
+        mapper ->
+            mapper.softDeleteTagMetadataObjectRelsByMetadataObject(
+                filesetId, MetadataObject.Type.FILESET.name()));
+    SessionUtils.doWithoutCommit(
+        StatisticMetaMapper.class, mapper -> 
mapper.softDeleteStatisticsByEntityId(filesetId));
+    SessionUtils.doWithoutCommit(
+        PolicyMetadataObjectRelMapper.class,
+        mapper ->
+            mapper.softDeletePolicyMetadataObjectRelsByMetadataObject(
+                filesetId, MetadataObject.Type.FILESET.name()));
+  }
+
+  private RuntimeException filesetWriteFailure(
+      NameIdentifier identifier, FilesetPO observedFilesetPO) {
+    // The failed CAS has already serialized with an in-flight writer. A 
non-locking natural-key
+    // lookup is enough to distinguish a disappeared name from one that still 
names either the
+    // modified fileset or a replacement, without holding another row lock on 
the failure path.
+    Long currentFilesetId =
+        SessionUtils.getWithoutCommit(
+            FilesetMetaMapper.class,
+            mapper ->
+                mapper.selectFilesetIdBySchemaIdAndName(
+                    observedFilesetPO.getSchemaId(), 
observedFilesetPO.getFilesetName()));
+    if (currentFilesetId == null) {
+      return new NoSuchEntityException(
+          NoSuchEntityException.NO_SUCH_ENTITY_MESSAGE,
+          Entity.EntityType.FILESET.name().toLowerCase(),
+          identifier.name());
+    }
+    return ExceptionUtils.concurrentModification(Entity.EntityType.FILESET, 
identifier);
+  }
 }
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
index e166a9f4c6..5557378eda 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/utils/POConverters.java
@@ -29,6 +29,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.stream.Collectors;
+import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.MetadataObject;
@@ -701,43 +702,47 @@ public class POConverters {
    *
    * @param oldFilesetPO the existing {@link FilesetPO} containing the current 
and last version data
    * @param newFileset the {@link FilesetEntity} with updated metadata and 
storage locations
-   * @param needUpdateVersion true to increment and update version fields; 
false to keep versions
-   *     unchanged
+   * @param maxStoredVersion the highest version the fileset still has a 
stored snapshot for, or
+   *     {@code null} when it has none
    * @return {@code FilesetPO} object with updated version
    * @throws RuntimeException if JSON serialization of properties fails
    */
   public static FilesetPO updateFilesetPOWithVersion(
-      FilesetPO oldFilesetPO, FilesetEntity newFileset, boolean 
needUpdateVersion) {
+      FilesetPO oldFilesetPO, FilesetEntity newFileset, @Nullable Long 
maxStoredVersion) {
     try {
-      Long lastVersion = oldFilesetPO.getLastVersion();
-      Long currentVersion;
-      List<FilesetVersionPO> newFilesetVersionPOs;
-      // Will set the version to the last version + 1
-      if (needUpdateVersion) {
-        lastVersion++;
-        currentVersion = lastVersion;
-        String props = 
JsonUtils.anyFieldMapper().writeValueAsString(newFileset.properties());
-        newFilesetVersionPOs =
-            newFileset.storageLocations().entrySet().stream()
-                .map(
-                    entry ->
-                        FilesetVersionPO.builder()
-                            .withMetalakeId(oldFilesetPO.getMetalakeId())
-                            .withCatalogId(oldFilesetPO.getCatalogId())
-                            .withSchemaId(oldFilesetPO.getSchemaId())
-                            .withFilesetId(newFileset.id())
-                            .withVersion(currentVersion)
-                            .withFilesetComment(newFileset.comment())
-                            .withLocationName(entry.getKey())
-                            .withStorageLocation(entry.getValue())
-                            .withProperties(props)
-                            .withDeletedAt(DEFAULT_DELETED_AT)
-                            .build())
-                .collect(Collectors.toList());
-      } else {
-        currentVersion = oldFilesetPO.getCurrentVersion();
-        newFilesetVersionPOs = oldFilesetPO.getFilesetVersionPOs();
+      // Every successful fileset alter advances the OCC token. The current 
version is also the
+      // value used by reads to find the fileset details, so even a rename or 
audit-only change
+      // needs a complete snapshot at the new version. Alters that change 
nothing therefore still
+      // write one row per storage location; the version retention job is what 
removes them again.
+      //
+      // The stored snapshots are taken into account as well, because a 
fileset written before the
+      // version reset was fixed can carry snapshots newer than the version 
its metadata row
+      // records. Starting from the metadata row alone would rebuild a version 
that already exists
+      // and collide with the unique key over (fileset_id, version, 
storage_location_name).
+      long previousVersion =
+          Math.max(oldFilesetPO.getLastVersion(), 
oldFilesetPO.getCurrentVersion());
+      if (maxStoredVersion != null) {
+        previousVersion = Math.max(previousVersion, maxStoredVersion);
       }
+      Long currentVersion = previousVersion + 1;
+      String props = 
JsonUtils.anyFieldMapper().writeValueAsString(newFileset.properties());
+      List<FilesetVersionPO> newFilesetVersionPOs =
+          newFileset.storageLocations().entrySet().stream()
+              .map(
+                  entry ->
+                      FilesetVersionPO.builder()
+                          .withMetalakeId(oldFilesetPO.getMetalakeId())
+                          .withCatalogId(oldFilesetPO.getCatalogId())
+                          .withSchemaId(oldFilesetPO.getSchemaId())
+                          .withFilesetId(newFileset.id())
+                          .withVersion(currentVersion)
+                          .withFilesetComment(newFileset.comment())
+                          .withLocationName(entry.getKey())
+                          .withStorageLocation(entry.getValue())
+                          .withProperties(props)
+                          .withDeletedAt(DEFAULT_DELETED_AT)
+                          .build())
+              .collect(Collectors.toList());
       return FilesetPO.builder()
           .withFilesetId(newFileset.id())
           .withFilesetName(newFileset.name())
@@ -747,7 +752,7 @@ public class POConverters {
           .withType(newFileset.filesetType().name())
           
.withAuditInfo(JsonUtils.anyFieldMapper().writeValueAsString(newFileset.auditInfo()))
           .withCurrentVersion(currentVersion)
-          .withLastVersion(lastVersion)
+          .withLastVersion(currentVersion)
           .withDeletedAt(DEFAULT_DELETED_AT)
           .withFilesetVersionPOs(newFilesetVersionPOs)
           .build();
@@ -756,31 +761,6 @@ public class POConverters {
     }
   }
 
-  public static boolean checkFilesetVersionNeedUpdate(
-      List<FilesetVersionPO> oldFilesetVersionPOs, FilesetEntity newFileset) {
-    Map<String, String> storageLocations =
-        oldFilesetVersionPOs.stream()
-            .collect(
-                Collectors.toMap(
-                    FilesetVersionPO::getLocationName, 
FilesetVersionPO::getStorageLocation));
-    if (!StringUtils.equals(oldFilesetVersionPOs.get(0).getFilesetComment(), 
newFileset.comment())
-        || !Objects.equals(storageLocations, newFileset.storageLocations())) {
-      return true;
-    }
-
-    try {
-      Map<String, String> oldProperties =
-          JsonUtils.anyFieldMapper()
-              .readValue(oldFilesetVersionPOs.get(0).getProperties(), 
Map.class);
-      if (oldProperties == null) {
-        return newFileset.properties() != null;
-      }
-      return !oldProperties.equals(newFileset.properties());
-    } catch (JsonProcessingException e) {
-      throw new RuntimeException("Failed to deserialize json object:", e);
-    }
-  }
-
   public static boolean checkPolicyVersionNeedUpdate(
       PolicyVersionPO oldPolicyVersionPO, PolicyEntity newPolicy) {
     if (!StringUtils.equals(oldPolicyVersionPO.getPolicyComment(), 
newPolicy.comment())
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestFilesetMetaBaseSQLProvider.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestFilesetMetaBaseSQLProvider.java
new file mode 100644
index 0000000000..cdbfcffd37
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/base/TestFilesetMetaBaseSQLProvider.java
@@ -0,0 +1,77 @@
+/*
+ * 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.storage.relational.mapper.provider.base;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestFilesetMetaBaseSQLProvider {
+
+  private static final FilesetMetaBaseSQLProvider PROVIDER = new 
FilesetMetaBaseSQLProvider();
+
+  @Test
+  void testOverwriteAdvancesStoredVersion() {
+    String sql = PROVIDER.insertFilesetMetaOnDuplicateKeyUpdate(null);
+    String updateClause = sql.substring(sql.indexOf(" ON DUPLICATE KEY 
UPDATE"));
+
+    Assertions.assertTrue(updateClause.contains("last_version = 
current_version + 1"));
+    Assertions.assertTrue(updateClause.contains("current_version = 
current_version + 1"));
+    Assertions.assertTrue(
+        updateClause.indexOf("last_version =") < 
updateClause.indexOf("current_version ="));
+    Assertions.assertFalse(
+        updateClause.contains("current_version = 
#{filesetMeta.currentVersion}"));
+    Assertions.assertFalse(updateClause.contains("last_version = 
#{filesetMeta.lastVersion}"));
+  }
+
+  @Test
+  void testUpdateUsesVersionCasAndRejectsAnOccupiedSnapshotVersion() {
+    String sql = PROVIDER.updateFilesetMeta(null, null);
+    String whereClause = sql.substring(sql.indexOf(" WHERE"));
+
+    Assertions.assertEquals(
+        " WHERE fileset_id = #{oldFilesetMeta.filesetId}"
+            + " AND current_version = #{oldFilesetMeta.currentVersion}"
+            + " AND deleted_at = 0"
+            + " AND NOT EXISTS (SELECT 1 FROM fileset_version_info fv"
+            + " WHERE fv.fileset_id = #{oldFilesetMeta.filesetId}"
+            + " AND fv.version >= #{newFilesetMeta.currentVersion}"
+            + " AND fv.deleted_at = 0)",
+        whereClause);
+  }
+
+  @Test
+  void testDirectDeleteUsesVersionCas() {
+    String sql = PROVIDER.softDeleteFilesetMetasByFilesetId(null, null);
+
+    Assertions.assertTrue(sql.contains("AND current_version = 
#{currentVersion}"));
+    Assertions.assertTrue(sql.endsWith("AND deleted_at = 0"));
+  }
+
+  @Test
+  void testOverwriteReadUsesNaturalKeyAndMetadataOnly() {
+    String sql = PROVIDER.selectFilesetMetaBySchemaIdAndNameForUpdate(null, 
null);
+
+    Assertions.assertTrue(
+        sql.contains(
+            "WHERE schema_id = #{schemaId} AND fileset_name = #{filesetName}"
+                + " AND deleted_at = 0"));
+    Assertions.assertFalse(sql.contains("fileset_version_info"));
+    Assertions.assertTrue(sql.endsWith("FOR UPDATE"));
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestFilesetMetaPostgreSQLProvider.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestFilesetMetaPostgreSQLProvider.java
new file mode 100644
index 0000000000..49753bfdea
--- /dev/null
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/mapper/provider/postgresql/TestFilesetMetaPostgreSQLProvider.java
@@ -0,0 +1,51 @@
+/*
+ * 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.storage.relational.mapper.provider.postgresql;
+
+import org.apache.gravitino.storage.relational.mapper.FilesetMetaMapper;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+class TestFilesetMetaPostgreSQLProvider {
+
+  @Test
+  void testOverwriteAdvancesStoredVersion() {
+    String sql = new 
FilesetMetaPostgreSQLProvider().insertFilesetMetaOnDuplicateKeyUpdate(null);
+    String conflictClause = sql.substring(sql.indexOf(" ON CONFLICT"));
+
+    Assertions.assertTrue(
+        conflictClause.startsWith(" ON CONFLICT(schema_id, fileset_name, 
deleted_at)"));
+    Assertions.assertTrue(
+        conflictClause.contains(
+            "current_version = " + FilesetMetaMapper.META_TABLE_NAME + 
".current_version + 1"));
+    Assertions.assertTrue(
+        conflictClause.contains(
+            "last_version = " + FilesetMetaMapper.META_TABLE_NAME + 
".current_version + 1"));
+    
Assertions.assertFalse(conflictClause.contains("#{filesetMeta.currentVersion}"));
+    
Assertions.assertFalse(conflictClause.contains("#{filesetMeta.lastVersion}"));
+  }
+
+  @Test
+  void testDirectDeleteUsesVersionCas() {
+    String sql = new 
FilesetMetaPostgreSQLProvider().softDeleteFilesetMetasByFilesetId(null, null);
+
+    Assertions.assertTrue(sql.contains("AND current_version = 
#{currentVersion}"));
+    Assertions.assertTrue(sql.endsWith("AND deleted_at = 0"));
+  }
+}
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestFilesetMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestFilesetMetaService.java
index 677c6ada5a..5255065194 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestFilesetMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestFilesetMetaService.java
@@ -36,7 +36,9 @@ import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.stream.Collectors;
+import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.reflect.FieldUtils;
 import org.apache.commons.lang3.tuple.Pair;
 import org.apache.gravitino.Config;
@@ -46,14 +48,22 @@ import org.apache.gravitino.EntityAlreadyExistsException;
 import org.apache.gravitino.GravitinoEnv;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
+import org.apache.gravitino.StringIdentifier;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.exceptions.OptimisticLockException;
 import org.apache.gravitino.file.Fileset;
 import org.apache.gravitino.integration.test.util.GravitinoITUtils;
 import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.FilesetEntity;
+import org.apache.gravitino.meta.SchemaEntity;
 import org.apache.gravitino.storage.RandomIdGenerator;
 import org.apache.gravitino.storage.relational.TestJDBCBackend;
+import org.apache.gravitino.storage.relational.mapper.FilesetMetaMapper;
+import org.apache.gravitino.storage.relational.mapper.FilesetVersionMapper;
+import org.apache.gravitino.storage.relational.po.FilesetPO;
+import org.apache.gravitino.storage.relational.po.FilesetVersionPO;
 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;
@@ -392,8 +402,7 @@ public class TestFilesetMetaService extends TestJDBCBackend 
{
   }
 
   @TestTemplate
-  public void 
testUpdateFilesetReturnsSuccessWhenVersionedMetaUpdateAffectsNoRows()
-      throws IOException {
+  public void testAlterReportsOptimisticLockConflictAndKeepsWinnerVersion() 
throws IOException {
     String filesetName = GravitinoITUtils.genRandomName("tst_fs_conflict");
     NameIdentifier filesetIdent =
         NameIdentifier.of(metalakeName, catalogName, schemaName, filesetName);
@@ -405,6 +414,7 @@ public class TestFilesetMetaService extends TestJDBCBackend 
{
             AUDIT_INFO,
             "/tmp");
     FilesetMetaService.getInstance().insertFileset(filesetEntity, true);
+    FilesetPO initialPO = getFilesetPO(filesetEntity.id());
 
     AuditInfo conflictingAuditInfo =
         AuditInfo.builder()
@@ -427,36 +437,26 @@ public class TestFilesetMetaService extends 
TestJDBCBackend {
                     .build())
             .build();
 
-    Exception exception =
-        Assertions.assertThrows(
-            IOException.class,
-            () ->
-                FilesetMetaService.getInstance()
-                    .updateFileset(
-                        filesetIdent,
-                        e -> {
-                          // Simulate an optimistic locking conflict
-                          try {
-                            backend.update(
-                                filesetIdent,
-                                Entity.EntityType.FILESET,
-                                entity -> {
-                                  FilesetEntity cloned =
-                                      createFilesetEntity(
-                                          entity.id(),
-                                          entity.namespace(),
-                                          entity.name(),
-                                          conflictingAuditInfo,
-                                          "/tmp");
-                                  return cloned;
-                                });
-                          } catch (Exception ex) {
-                            throw new RuntimeException(ex);
-                          }
-                          return updatedFilesetEntity;
-                        }));
-    Assertions.assertTrue(
-        exception.getMessage().contains("Failed to update the entity: " + 
filesetIdent));
+    Assertions.assertThrows(
+        OptimisticLockException.class,
+        () ->
+            FilesetMetaService.getInstance()
+                .updateFileset(
+                    filesetIdent,
+                    e -> {
+                      // Commit another alter after the outer call has read 
its snapshot. The
+                      // outer write must then lose the current_version 
comparison.
+                      updateFilesetUnchecked(
+                          filesetIdent,
+                          entity ->
+                              createFilesetEntity(
+                                  entity.id(),
+                                  entity.namespace(),
+                                  entity.name(),
+                                  conflictingAuditInfo,
+                                  "/tmp"));
+                      return updatedFilesetEntity;
+                    }));
 
     FilesetEntity persistedEntity =
         FilesetMetaService.getInstance().getFilesetByIdentifier(filesetIdent);
@@ -465,5 +465,574 @@ public class TestFilesetMetaService extends 
TestJDBCBackend {
     Assertions.assertNull(persistedEntity.properties());
     Assertions.assertEquals("/tmp", 
persistedEntity.storageLocations().get(LOCATION_NAME_UNKNOWN));
     Assertions.assertNotEquals(updatedFilesetEntity, persistedEntity);
+    FilesetPO currentPO = getFilesetPO(filesetEntity.id());
+    Assertions.assertEquals(
+        initialPO.getCurrentVersion() + 1, 
currentPO.getCurrentVersion().longValue());
+    Assertions.assertEquals(currentPO.getCurrentVersion(), 
currentPO.getLastVersion());
+    Assertions.assertEquals(2, listFilesetVersions(filesetEntity.id()).size());
+  }
+
+  @TestTemplate
+  public void testOverwriteAdvancesVersionAndRejectsStaleAlter() throws 
IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_overwrite_occ");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-original");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+    FilesetPO beforeOverwrite = getFilesetPO(original.id());
+    FilesetEntity replacement =
+        copyFileset(
+            original,
+            original.id(),
+            original.name(),
+            "overwrite winner",
+            "/tmp-overwrite",
+            original.auditInfo());
+
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            FilesetMetaService.getInstance()
+                .updateFileset(
+                    original.nameIdentifier(),
+                    entity -> {
+                      insertFilesetUnchecked(replacement, true);
+                      FilesetEntity current = (FilesetEntity) entity;
+                      return copyFileset(
+                          current,
+                          current.id(),
+                          current.name(),
+                          "stale alter",
+                          "/tmp-stale",
+                          current.auditInfo());
+                    }));
+
+    FilesetEntity winner =
+        
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier());
+    FilesetPO afterOverwrite = getFilesetPO(original.id());
+    Assertions.assertEquals("overwrite winner", winner.comment());
+    Assertions.assertEquals("/tmp-overwrite", 
winner.storageLocations().get(LOCATION_NAME_UNKNOWN));
+    Assertions.assertEquals(
+        beforeOverwrite.getCurrentVersion() + 1, 
afterOverwrite.getCurrentVersion().longValue());
+    Assertions.assertEquals(afterOverwrite.getCurrentVersion(), 
afterOverwrite.getLastVersion());
+  }
+
+  @TestTemplate
+  public void testNaturalKeyOverwriteUsesPersistedFilesetId() throws 
IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_natural_key_overwrite");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-original");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+    FilesetPO beforeOverwrite = getFilesetPO(original.id());
+    Map<String, String> replacementLocations =
+        ImmutableMap.of(LOCATION_NAME_UNKNOWN, "/tmp-replacement", "archive", 
"/tmp-archive");
+    FilesetEntity replacement =
+        FilesetEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName(original.name())
+            .withNamespace(original.namespace())
+            .withFilesetType(original.filesetType())
+            .withStorageLocations(replacementLocations)
+            .withComment("replacement")
+            .withProperties(original.properties())
+            .withAuditInfo(original.auditInfo())
+            .build();
+
+    FilesetMetaService.getInstance().insertFileset(replacement, true);
+
+    FilesetEntity stored =
+        
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier());
+    FilesetPO afterOverwrite = getFilesetPO(original.id());
+    Assertions.assertEquals(original.id(), stored.id());
+    Assertions.assertEquals("replacement", stored.comment());
+    Assertions.assertEquals(replacementLocations, stored.storageLocations());
+    Assertions.assertEquals(
+        beforeOverwrite.getCurrentVersion() + 1, 
afterOverwrite.getCurrentVersion().longValue());
+  }
+
+  @TestTemplate
+  public void testAlterReportsNoSuchWhenRenamedConcurrently() throws 
IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_rename_conflict");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+    String renamedName = filesetName + "_winner";
+    NameIdentifier renamedIdentifier = NameIdentifier.of(original.namespace(), 
renamedName);
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            FilesetMetaService.getInstance()
+                .updateFileset(
+                    original.nameIdentifier(),
+                    entity -> {
+                      updateFilesetUnchecked(
+                          original.nameIdentifier(),
+                          current ->
+                              copyFileset(
+                                  current,
+                                  current.id(),
+                                  renamedName,
+                                  "rename winner",
+                                  "/tmp",
+                                  current.auditInfo()));
+                      FilesetEntity current = (FilesetEntity) entity;
+                      return copyFileset(
+                          current,
+                          current.id(),
+                          current.name(),
+                          "stale alter",
+                          "/tmp",
+                          current.auditInfo());
+                    }));
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier()));
+    Assertions.assertEquals(
+        "rename winner",
+        
FilesetMetaService.getInstance().getFilesetByIdentifier(renamedIdentifier).comment());
+  }
+
+  @TestTemplate
+  public void testDeleteRejectsStaleVersionAndKeepsVersions() throws 
IOException {
+    String filesetName = GravitinoITUtils.genRandomName("tst_fs_stale_delete");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-v1");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+    FilesetPO stalePO = getFilesetPO(original.id());
+
+    FilesetMetaService.getInstance()
+        .updateFileset(
+            original.nameIdentifier(),
+            entity -> {
+              FilesetEntity current = (FilesetEntity) entity;
+              return copyFileset(
+                  current,
+                  current.id(),
+                  current.name(),
+                  "winning alter",
+                  "/tmp-v2",
+                  current.auditInfo());
+            });
+
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            SessionUtils.doMultipleWithCommit(
+                () ->
+                    FilesetMetaService.getInstance()
+                        .deleteFilesetWithVersion(original.nameIdentifier(), 
stalePO)));
+
+    FilesetEntity current =
+        
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier());
+    Assertions.assertEquals("winning alter", current.comment());
+    Assertions.assertEquals("/tmp-v2", 
current.storageLocations().get(LOCATION_NAME_UNKNOWN));
+    Map<Integer, Long> versions = listFilesetVersions(original.id());
+    Assertions.assertEquals(2, versions.size());
+    assertVersionActive(versions, 1);
+    assertVersionActive(versions, 2);
+  }
+
+  @TestTemplate
+  public void testDeleteReportsNoSuchWhenDeletedConcurrently() throws 
IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_double_delete");
+    FilesetEntity fileset =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp");
+    FilesetMetaService.getInstance().insertFileset(fileset, false);
+    FilesetPO stalePO = getFilesetPO(fileset.id());
+
+    FilesetMetaService.getInstance().deleteFileset(fileset.nameIdentifier());
+
+    assertThrows(
+        NoSuchEntityException.class,
+        () ->
+            SessionUtils.doMultipleWithCommit(
+                () ->
+                    FilesetMetaService.getInstance()
+                        .deleteFilesetWithVersion(fileset.nameIdentifier(), 
stalePO)));
+  }
+
+  @TestTemplate
+  public void testDeleteAndGetReturnsSnapshotProtectedByDeleteCas() throws 
IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_delete_snapshot");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-v1");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+    FilesetEntity updated =
+        FilesetMetaService.getInstance()
+            .updateFileset(
+                original.nameIdentifier(),
+                entity -> {
+                  FilesetEntity current = (FilesetEntity) entity;
+                  return copyFileset(
+                      current,
+                      current.id(),
+                      current.name(),
+                      "snapshot selected by delete",
+                      "/tmp-v2",
+                      current.auditInfo());
+                });
+
+    FilesetEntity deleted =
+        
FilesetMetaService.getInstance().deleteFilesetAndGet(original.nameIdentifier());
+
+    Assertions.assertEquals(updated, deleted);
+    assertThrows(
+        NoSuchEntityException.class,
+        () -> 
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier()));
+    Map<Integer, Long> versions = listFilesetVersions(original.id());
+    assertVersionSoftDeleted(versions, 1);
+    assertVersionSoftDeleted(versions, 2);
+  }
+
+  @TestTemplate
+  public void testUpdateRollsBackMetadataWhenVersionInsertFails() throws 
IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_update_rollback");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-v1");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+    FilesetPO initialPO = getFilesetPO(original.id());
+    // fileset_meta carries no comment, so an over-long comment passes the 
metadata update and only
+    // fails once the version snapshot is written.
+    String tooLongComment = StringUtils.repeat("c", 300);
+
+    // Each backend reports the rejected snapshot differently, so only the 
rollback below is
+    // asserted on.
+    assertThrows(
+        Exception.class,
+        () ->
+            FilesetMetaService.getInstance()
+                .updateFileset(
+                    original.nameIdentifier(),
+                    entity -> {
+                      FilesetEntity current = (FilesetEntity) entity;
+                      return copyFileset(
+                          current,
+                          current.id(),
+                          current.name(),
+                          tooLongComment,
+                          "/tmp-v2",
+                          current.auditInfo());
+                    }));
+
+    FilesetEntity current =
+        
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier());
+    FilesetPO currentPO = getFilesetPO(original.id());
+    Assertions.assertEquals(original.comment(), current.comment());
+    Assertions.assertEquals(
+        original.storageLocations().get(LOCATION_NAME_UNKNOWN),
+        current.storageLocations().get(LOCATION_NAME_UNKNOWN));
+    Assertions.assertEquals(initialPO.getCurrentVersion(), 
currentPO.getCurrentVersion());
+    Assertions.assertEquals(initialPO.getLastVersion(), 
currentPO.getLastVersion());
+  }
+
+  @TestTemplate
+  public void testAlterSkipsVersionsAlreadyStored() throws IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_stale_version");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-v1");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+    FilesetPO initialPO = getFilesetPO(original.id());
+
+    // A fileset written before the version reset was fixed owns snapshots 
above the version its
+    // metadata row records. The next alter has to start above those, not on 
top of them.
+    FilesetVersionPO staleVersion =
+        FilesetVersionPO.builder()
+            .withMetalakeId(initialPO.getMetalakeId())
+            .withCatalogId(initialPO.getCatalogId())
+            .withSchemaId(initialPO.getSchemaId())
+            .withFilesetId(initialPO.getFilesetId())
+            .withVersion(initialPO.getCurrentVersion() + 1)
+            .withFilesetComment("left behind by an older release")
+            // Use a different location name from the new snapshot. The legacy 
row therefore would
+            // not cause a unique-key collision; the metadata CAS itself must 
detect it.
+            .withLocationName("legacy-location")
+            .withStorageLocation("/tmp-stale")
+            .withDeletedAt(0L)
+            .build();
+    SessionUtils.doWithCommit(
+        FilesetVersionMapper.class, mapper -> 
mapper.insertFilesetVersions(List.of(staleVersion)));
+
+    FilesetEntity altered =
+        FilesetMetaService.getInstance()
+            .updateFileset(
+                original.nameIdentifier(),
+                entity -> {
+                  FilesetEntity current = (FilesetEntity) entity;
+                  return copyFileset(
+                      current,
+                      current.id(),
+                      current.name(),
+                      "altered past the stale version",
+                      "/tmp-v2",
+                      current.auditInfo());
+                });
+
+    Assertions.assertEquals("altered past the stale version", 
altered.comment());
+    FilesetPO afterAlter = getFilesetPO(original.id());
+    Assertions.assertEquals(
+        staleVersion.getVersion() + 1, 
afterAlter.getCurrentVersion().longValue());
+    FilesetEntity reloaded =
+        
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier());
+    Assertions.assertEquals("altered past the stale version", 
reloaded.comment());
+    Assertions.assertEquals("/tmp-v2", 
reloaded.storageLocations().get(LOCATION_NAME_UNKNOWN));
+  }
+
+  @TestTemplate
+  public void testOverwriteSkipsVersionsAlreadyStored() throws IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_overwrite_stale_version");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-v1");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+    FilesetPO initialPO = getFilesetPO(original.id());
+
+    // The same legacy shape the alter path already handles: a snapshot above 
the version the
+    // metadata row records. The overwrite derives its version from that row, 
so it has to be
+    // lifted above the snapshot instead of rewriting it.
+    FilesetVersionPO staleVersion =
+        FilesetVersionPO.builder()
+            .withMetalakeId(initialPO.getMetalakeId())
+            .withCatalogId(initialPO.getCatalogId())
+            .withSchemaId(initialPO.getSchemaId())
+            .withFilesetId(initialPO.getFilesetId())
+            .withVersion(initialPO.getCurrentVersion() + 1)
+            .withFilesetComment("left behind by an older release")
+            .withLocationName(LOCATION_NAME_UNKNOWN)
+            .withStorageLocation("/tmp-stale")
+            .withDeletedAt(0L)
+            .build();
+    SessionUtils.doWithCommit(
+        FilesetVersionMapper.class, mapper -> 
mapper.insertFilesetVersions(List.of(staleVersion)));
+
+    FilesetEntity replacement =
+        copyFileset(
+            original,
+            original.id(),
+            original.name(),
+            "overwritten past the stale version",
+            "/tmp-v2",
+            original.auditInfo());
+    FilesetMetaService.getInstance().insertFileset(replacement, true);
+
+    FilesetPO afterOverwrite = getFilesetPO(original.id());
+    Assertions.assertEquals(
+        staleVersion.getVersion() + 1, 
afterOverwrite.getCurrentVersion().longValue());
+    FilesetEntity stored =
+        
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier());
+    Assertions.assertEquals("overwritten past the stale version", 
stored.comment());
+    Assertions.assertEquals("/tmp-v2", 
stored.storageLocations().get(LOCATION_NAME_UNKNOWN));
+    // The snapshot that was left behind is untouched at its own version.
+    Assertions.assertEquals(
+        "/tmp-stale",
+        storageLocationOfVersion(initialPO.getFilesetId(), 
staleVersion.getVersion()));
+  }
+
+  @TestTemplate
+  public void testNaturalKeyOverwriteRewritesIdentifierProperty() throws 
IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_overwrite_identifier");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-original");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+
+    long replacementId = RandomIdGenerator.INSTANCE.nextId();
+    FilesetEntity replacement =
+        FilesetEntity.builder()
+            .withId(replacementId)
+            .withName(original.name())
+            .withNamespace(original.namespace())
+            .withFilesetType(original.filesetType())
+            .withStorageLocations(ImmutableMap.of(LOCATION_NAME_UNKNOWN, 
"/tmp-replacement"))
+            .withComment("replacement")
+            .withProperties(
+                ImmutableMap.of(
+                    StringIdentifier.ID_KEY, 
StringIdentifier.fromId(replacementId).toString()))
+            .withAuditInfo(original.auditInfo())
+            .build();
+
+    FilesetMetaService.getInstance().insertFileset(replacement, true);
+
+    // The overwrite keeps the fileset ID the database already had, so the 
identifier property has
+    // to name that ID as well instead of the one the rejected snapshot was 
built with.
+    FilesetEntity stored =
+        
FilesetMetaService.getInstance().getFilesetByIdentifier(original.nameIdentifier());
+    Assertions.assertEquals(original.id(), stored.id());
+    Assertions.assertEquals(
+        StringIdentifier.fromId(original.id()).toString(),
+        stored.properties().get(StringIdentifier.ID_KEY));
+  }
+
+  @TestTemplate
+  public void testDeleteAndGetRefusesAPreCommitActionItCannotHonor() {
+    // Only the fileset path runs the action while the delete can still be 
rolled back. Any other
+    // entity type would run it after the commit, which is the opposite of 
what callers rely on, so
+    // it has to say so instead of doing it anyway.
+    Assertions.assertThrows(
+        UnsupportedOperationException.class,
+        () ->
+            backend.deleteAndGet(
+                NameIdentifier.of(metalakeName, catalogName, schemaName),
+                Entity.EntityType.SCHEMA,
+                SchemaEntity.class,
+                ignored -> {
+                  throw new IllegalStateException("not reached");
+                }));
+  }
+
+  @TestTemplate
+  public void testAlterReportsConflictWhenTheNameWasTakenOver() throws 
IOException {
+    String filesetName = 
GravitinoITUtils.genRandomName("tst_fs_name_taken_over");
+    FilesetEntity original =
+        createFilesetEntity(
+            RandomIdGenerator.INSTANCE.nextId(),
+            NamespaceUtil.ofFileset(metalakeName, catalogName, schemaName),
+            filesetName,
+            AUDIT_INFO,
+            "/tmp-original");
+    FilesetMetaService.getInstance().insertFileset(original, false);
+
+    // The fileset this alter resolved is renamed away and a different one 
takes over its name. The
+    // name still resolves, so the loser is told to retry rather than that the 
name is gone.
+    assertThrows(
+        OptimisticLockException.class,
+        () ->
+            FilesetMetaService.getInstance()
+                .updateFileset(
+                    original.nameIdentifier(),
+                    entity -> {
+                      updateFilesetUnchecked(
+                          original.nameIdentifier(),
+                          current ->
+                              copyFileset(
+                                  current,
+                                  current.id(),
+                                  filesetName + "_moved",
+                                  "rename winner",
+                                  "/tmp-moved",
+                                  current.auditInfo()));
+                      insertFilesetUnchecked(
+                          createFilesetEntity(
+                              RandomIdGenerator.INSTANCE.nextId(),
+                              original.namespace(),
+                              filesetName,
+                              AUDIT_INFO,
+                              "/tmp-taken-over"),
+                          false);
+                      FilesetEntity current = (FilesetEntity) entity;
+                      return copyFileset(
+                          current,
+                          current.id(),
+                          current.name(),
+                          "stale alter",
+                          "/tmp-loser",
+                          current.auditInfo());
+                    }));
+  }
+
+  private String storageLocationOfVersion(Long filesetId, Long version) {
+    try (SqlSession sqlSession =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = sqlSession.getConnection();
+        Statement statement = connection.createStatement();
+        ResultSet rs =
+            statement.executeQuery(
+                String.format(
+                    "SELECT storage_location FROM fileset_version_info"
+                        + " WHERE fileset_id = %d AND version = %d AND 
deleted_at = 0",
+                    filesetId, version))) {
+      return rs.next() ? rs.getString("storage_location") : null;
+    } catch (SQLException e) {
+      throw new RuntimeException("SQL execution failed", e);
+    }
+  }
+
+  private FilesetPO getFilesetPO(Long filesetId) {
+    return SessionUtils.getWithoutCommit(
+        FilesetMetaMapper.class, mapper -> 
mapper.selectFilesetMetaById(filesetId));
+  }
+
+  private FilesetEntity copyFileset(
+      FilesetEntity source,
+      Long id,
+      String name,
+      String comment,
+      String location,
+      AuditInfo auditInfo) {
+    return FilesetEntity.builder()
+        .withId(id)
+        .withName(name)
+        .withNamespace(source.namespace())
+        .withFilesetType(source.filesetType())
+        .withStorageLocations(ImmutableMap.of(LOCATION_NAME_UNKNOWN, location))
+        .withComment(comment)
+        .withProperties(source.properties())
+        .withAuditInfo(auditInfo)
+        .build();
+  }
+
+  private void updateFilesetUnchecked(
+      NameIdentifier identifier, Function<FilesetEntity, FilesetEntity> 
updater) {
+    try {
+      FilesetMetaService.getInstance().updateFileset(identifier, updater);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private void insertFilesetUnchecked(FilesetEntity fileset, boolean 
overwrite) {
+    try {
+      FilesetMetaService.getInstance().insertFileset(fileset, overwrite);
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
   }
 }
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
index 994432dcaa..2935e0b4e7 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/utils/TestPOConverters.java
@@ -760,11 +760,8 @@ public class TestPOConverters {
         
FilesetPO.builder().withMetalakeId(1L).withCatalogId(1L).withSchemaId(1L);
     FilesetPO initPO = 
POConverters.initializeFilesetPOWithVersion(filesetEntity, builder);
 
-    // map has updated
-    boolean checkNeedUpdate1 =
-        
POConverters.checkFilesetVersionNeedUpdate(initPO.getFilesetVersionPOs(), 
updatedFileset);
-    FilesetPO updatePO1 =
-        POConverters.updateFilesetPOWithVersion(initPO, updatedFileset, 
checkNeedUpdate1);
+    // A content change advances the version and writes a complete new 
snapshot.
+    FilesetPO updatePO1 = POConverters.updateFilesetPOWithVersion(initPO, 
updatedFileset, null);
     assertEquals(1, initPO.getCurrentVersion());
     assertEquals(1, initPO.getLastVersion());
     assertEquals(0, initPO.getDeletedAt());
@@ -783,11 +780,9 @@ public class TestPOConverters {
             
.readValue(updatePO1.getFilesetVersionPOs().get(0).getProperties(), Map.class);
     assertEquals("value1", updatedProperties.get("key"));
 
-    // will not update version, but update the fileset name
-    boolean checkNeedUpdate2 =
-        
POConverters.checkFilesetVersionNeedUpdate(initPO.getFilesetVersionPOs(), 
updatedFileset1);
-    FilesetPO updatePO2 =
-        POConverters.updateFilesetPOWithVersion(initPO, updatedFileset1, 
checkNeedUpdate2);
+    // Metadata-only changes must also advance the OCC token. Reads join the 
version table through
+    // current_version, so the converter writes the unchanged content as a new 
complete snapshot.
+    FilesetPO updatePO2 = POConverters.updateFilesetPOWithVersion(initPO, 
updatedFileset1, null);
     Map<String, String> storageLocations2 =
         updatePO2.getFilesetVersionPOs().stream()
             .collect(
@@ -795,10 +790,17 @@ public class TestPOConverters {
                     FilesetVersionPO::getLocationName, 
FilesetVersionPO::getStorageLocation));
     assertEquals(filesetEntity.storageLocation(), 
storageLocations2.get(LOCATION_NAME_UNKNOWN));
     assertEquals(filesetEntity.storageLocations(), storageLocations2);
-    assertEquals(1, updatePO2.getCurrentVersion());
-    assertEquals(1, updatePO2.getLastVersion());
-    assertEquals(1, updatePO2.getFilesetVersionPOs().get(0).getVersion());
+    assertEquals(2, updatePO2.getCurrentVersion());
+    assertEquals(2, updatePO2.getLastVersion());
+    assertEquals(2, updatePO2.getFilesetVersionPOs().get(0).getVersion());
     assertEquals("test1", updatePO2.getFilesetName());
+
+    // A snapshot stored above the version the metadata row records must not 
be rebuilt: the next
+    // version starts above every snapshot the fileset still owns.
+    FilesetPO updatePO3 = POConverters.updateFilesetPOWithVersion(initPO, 
updatedFileset, 7L);
+    assertEquals(8, updatePO3.getCurrentVersion());
+    assertEquals(8, updatePO3.getLastVersion());
+    assertEquals(8, updatePO3.getFilesetVersionPOs().get(0).getVersion());
   }
 
   @Test

Reply via email to