You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by di...@apache.org on 2026/07/16 22:06:05 UTC

(polaris) branch main updated: Clean up orphan metadata files on failed table/view commits (#5057)

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

dimas-b pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new 4215a84335 Clean up orphan metadata files on failed table/view commits (#5057)
4215a84335 is described below

commit 4215a84335467de86f3a9fdf02c3d86229df7dcd
Author: Ayush Saxena <ay...@apache.org>
AuthorDate: Fri Jul 17 03:36:00 2026 +0530

    Clean up orphan metadata files on failed table/view commits (#5057)
---
 .../catalog/iceberg/IcebergCatalogHandler.java     |   6 +-
 .../catalog/iceberg/LocalIcebergCatalog.java       | 262 ++++++++++++---------
 .../iceberg/AbstractLocalIcebergCatalogTest.java   |  64 +++++
 .../AbstractLocalIcebergCatalogViewTest.java       |  85 +++++++
 4 files changed, 297 insertions(+), 120 deletions(-)

diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
index 5c84ed673c..e6ad4bc0a1 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogHandler.java
@@ -1578,17 +1578,17 @@ public abstract class IcebergCatalogHandler extends CatalogHandler implements Au
     eventAttributeMap().put(EventAttributes.TABLE_METADATAS, tableMetadataObjs);
   }
 
-  private record FileToDelete(FileIO io, String location) {
+  record FileToDelete(FileIO io, String location) {
     void cleanup() {
       try {
         io.deleteFile(location);
       } catch (Exception e) {
-        LOGGER.warn("Failed to clean up metadata file {} after transaction failure", location, e);
+        LOGGER.warn("Failed to clean up metadata file {} after commit failure", location, e);
       }
     }
   }
 
-  private static void cleanupWrittenMetadataFiles(List<FileToDelete> writtenMetadataFiles) {
+  static void cleanupWrittenMetadataFiles(List<FileToDelete> writtenMetadataFiles) {
     writtenMetadataFiles.forEach(FileToDelete::cleanup);
   }
 
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalog.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalog.java
index 52da0099b8..8574d2632c 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalog.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/LocalIcebergCatalog.java
@@ -161,6 +161,9 @@ public class LocalIcebergCatalog extends BaseMetastoreViewCatalog
 
   private static final Joiner SLASH = Joiner.on("/");
 
+  /** Result of {@code writeNewMetadataIfRequired}: the file location and whether we wrote it. */
+  record MetadataWriteResult(String location, boolean written) {}
+
   public static final Predicate<Exception> SHOULD_RETRY_REFRESH_PREDICATE =
       ex -> {
         // Default arguments from BaseMetastoreTableOperation only stop retries on
@@ -1879,86 +1882,98 @@ public class LocalIcebergCatalog extends BaseMetastoreViewCatalog
                   PolarisStorageActions.WRITE,
                   PolarisStorageActions.LIST));
 
-      String newLocation = writeNewMetadataIfRequired(base == null, metadata);
+      MetadataWriteResult writeResult = writeNewMetadataIfRequired(base == null, metadata);
+      String newLocation = writeResult.location();
       String oldLocation = base == null ? null : base.metadataFileLocation();
-
-      // TODO: Consider using the entity from doRefresh() directly to do the conflict detection
-      // instead of a two-layer CAS (checking metadataLocation to detect concurrent modification
-      // between doRefresh() and doCommit(), and then updateEntityPropertiesIfNotChanged to detect
-      // concurrent
-      // modification between our checking of unchanged metadataLocation here and actual
-      // persistence-layer commit).
-      PolarisResolvedPathWrapper resolvedPath =
-          resolvedEntityView.getPassthroughResolvedPath(
-              ResolvedPathKey.ofTableLike(tableIdentifier), PolarisEntitySubType.ANY_SUBTYPE);
-      if (resolvedPath != null && resolvedPath.getRawLeafEntity() != null) {
-        var subType = resolvedPath.getRawLeafEntity().getSubType();
-        if (subType != PolarisEntitySubType.ICEBERG_TABLE) {
-          throw alreadyExistsExceptionWithSameNameForTableLikeEntity(tableIdentifier, subType);
-        }
-      }
-      Map<String, String> storedProperties = buildTableMetadataPropertiesMap(metadata);
-      IcebergTableLikeEntity entity =
-          IcebergTableLikeEntity.of(resolvedPath == null ? null : resolvedPath.getRawLeafEntity());
-      String existingLocation;
-      if (null == entity) {
-        existingLocation = null;
-        Map<String, String> internalProperties =
-            idempotencyInternalProperties(storedProperties, null);
-        entity =
-            new IcebergTableLikeEntity.Builder(
-                    PolarisEntitySubType.ICEBERG_TABLE,
-                    tableIdentifier,
-                    Map.of(),
-                    internalProperties,
-                    newLocation)
-                .setCatalogId(getCatalogId())
-                .setBaseLocation(metadata.location())
-                .setId(
-                    getMetaStoreManager().generateNewEntityId(getCurrentPolarisContext()).getId())
-                .build();
-      } else {
-        existingLocation = entity.getMetadataLocation();
-        Map<String, String> internalProperties =
-            idempotencyInternalProperties(storedProperties, entity);
-        entity =
-            new IcebergTableLikeEntity.Builder(entity)
-                .setInternalProperties(internalProperties)
-                .setBaseLocation(metadata.location())
-                .setMetadataLocation(newLocation)
-                .build();
-      }
-      if (!Objects.equal(existingLocation, oldLocation)) {
-        if (null == base) {
-          throw alreadyExistsExceptionForTableLikeEntity(
-              fullTableName, PolarisEntitySubType.ICEBERG_TABLE);
+      boolean writeSucceeded = false;
+      try {
+        // TODO: Consider using the entity from doRefresh() directly to do the conflict detection
+        // instead of a two-layer CAS (checking metadataLocation to detect concurrent modification
+        // between doRefresh() and doCommit(), and then updateEntityPropertiesIfNotChanged to detect
+        // concurrent
+        // modification between our checking of unchanged metadataLocation here and actual
+        // persistence-layer commit).
+        PolarisResolvedPathWrapper resolvedPath =
+            resolvedEntityView.getPassthroughResolvedPath(
+                ResolvedPathKey.ofTableLike(tableIdentifier), PolarisEntitySubType.ANY_SUBTYPE);
+        if (resolvedPath != null && resolvedPath.getRawLeafEntity() != null) {
+          var subType = resolvedPath.getRawLeafEntity().getSubType();
+          if (subType != PolarisEntitySubType.ICEBERG_TABLE) {
+            throw alreadyExistsExceptionWithSameNameForTableLikeEntity(tableIdentifier, subType);
+          }
         }
-
-        if (null == existingLocation) {
-          throw notFoundExceptionForTableLikeEntity(
-              fullTableName, PolarisEntitySubType.ICEBERG_TABLE);
+        Map<String, String> storedProperties = buildTableMetadataPropertiesMap(metadata);
+        IcebergTableLikeEntity entity =
+            IcebergTableLikeEntity.of(
+                resolvedPath == null ? null : resolvedPath.getRawLeafEntity());
+        String existingLocation;
+        if (null == entity) {
+          existingLocation = null;
+          Map<String, String> internalProperties =
+              idempotencyInternalProperties(storedProperties, null);
+          entity =
+              new IcebergTableLikeEntity.Builder(
+                      PolarisEntitySubType.ICEBERG_TABLE,
+                      tableIdentifier,
+                      Map.of(),
+                      internalProperties,
+                      newLocation)
+                  .setCatalogId(getCatalogId())
+                  .setBaseLocation(metadata.location())
+                  .setId(
+                      getMetaStoreManager().generateNewEntityId(getCurrentPolarisContext()).getId())
+                  .build();
+        } else {
+          existingLocation = entity.getMetadataLocation();
+          Map<String, String> internalProperties =
+              idempotencyInternalProperties(storedProperties, entity);
+          entity =
+              new IcebergTableLikeEntity.Builder(entity)
+                  .setInternalProperties(internalProperties)
+                  .setBaseLocation(metadata.location())
+                  .setMetadataLocation(newLocation)
+                  .build();
         }
+        if (!Objects.equal(existingLocation, oldLocation)) {
+          if (null == base) {
+            throw alreadyExistsExceptionForTableLikeEntity(
+                fullTableName, PolarisEntitySubType.ICEBERG_TABLE);
+          }
 
-        throw new CommitFailedException(
-            "Cannot commit to table %s metadata location from %s to %s "
-                + "because it has been concurrently modified to %s",
-            tableIdentifier, oldLocation, newLocation, existingLocation);
-      }
+          if (null == existingLocation) {
+            throw notFoundExceptionForTableLikeEntity(
+                fullTableName, PolarisEntitySubType.ICEBERG_TABLE);
+          }
 
-      // We diverge from `BaseMetastoreTableOperations` in the below code block
-      if (makeMetadataCurrentOnCommit) {
-        currentMetadata =
-            TableMetadata.buildFrom(metadata)
-                .withMetadataLocation(newLocation)
-                .discardChanges()
-                .build();
-        currentMetadataLocation = newLocation;
-      }
+          throw new CommitFailedException(
+              "Cannot commit to table %s metadata location from %s to %s "
+                  + "because it has been concurrently modified to %s",
+              tableIdentifier, oldLocation, newLocation, existingLocation);
+        }
 
-      if (null == existingLocation) {
-        createTableLike(tableIdentifier, entity);
-      } else {
-        updateTableLike(tableIdentifier, entity);
+        if (null == existingLocation) {
+          createTableLike(tableIdentifier, entity);
+        } else {
+          updateTableLike(tableIdentifier, entity);
+        }
+        // We diverge from `BaseMetastoreTableOperations`: only update the in-memory state after
+        // the metastore persistence succeeds. If we updated it before and persistence threw,
+        // the finally-block cleanup would delete newLocation while this ops instance still
+        // pointed at it — leaving a dangling reference until the caller refreshes.
+        if (makeMetadataCurrentOnCommit) {
+          currentMetadata =
+              TableMetadata.buildFrom(metadata)
+                  .withMetadataLocation(newLocation)
+                  .discardChanges()
+                  .build();
+          currentMetadataLocation = newLocation;
+        }
+        writeSucceeded = true;
+      } finally {
+        if (!writeSucceeded && writeResult.written()) {
+          IcebergCatalogHandler.cleanupWrittenMetadataFiles(
+              List.of(new IcebergCatalogHandler.FileToDelete(io(), newLocation)));
+        }
       }
     }
 
@@ -2032,10 +2047,12 @@ public class LocalIcebergCatalog extends BaseMetastoreViewCatalog
       };
     }
 
-    protected String writeNewMetadataIfRequired(boolean newTable, TableMetadata metadata) {
-      return newTable && metadata.metadataFileLocation() != null
-          ? metadata.metadataFileLocation()
-          : writeNewMetadata(metadata, version + 1);
+    protected MetadataWriteResult writeNewMetadataIfRequired(
+        boolean newTable, TableMetadata metadata) {
+      if (newTable && metadata.metadataFileLocation() != null) {
+        return new MetadataWriteResult(metadata.metadataFileLocation(), false);
+      }
+      return new MetadataWriteResult(writeNewMetadata(metadata, version + 1), true);
     }
 
     protected String writeNewMetadata(TableMetadata metadata, int newVersion) {
@@ -2323,53 +2340,64 @@ public class LocalIcebergCatalog extends BaseMetastoreViewCatalog
               tableProperties,
               Set.of(PolarisStorageActions.READ, PolarisStorageActions.WRITE));
 
-      String newLocation = writeNewMetadataIfRequired(metadata);
+      MetadataWriteResult writeResult = writeNewMetadataIfRequired(metadata);
+      String newLocation = writeResult.location();
       String oldLocation = base == null ? null : currentMetadataLocation;
-
-      IcebergTableLikeEntity entity =
-          IcebergTableLikeEntity.of(
-              resolvedEntities == null ? null : resolvedEntities.getRawLeafEntity());
-      String existingLocation;
-      if (null == entity) {
-        existingLocation = null;
-        entity =
-            new IcebergTableLikeEntity.Builder(
-                    PolarisEntitySubType.ICEBERG_VIEW, identifier, newLocation)
-                .setCatalogId(getCatalogId())
-                .setId(
-                    getMetaStoreManager().generateNewEntityId(getCurrentPolarisContext()).getId())
-                .build();
-      } else {
-        existingLocation = entity.getMetadataLocation();
-        entity =
-            new IcebergTableLikeEntity.Builder(entity).setMetadataLocation(newLocation).build();
-      }
-      if (!Objects.equal(existingLocation, oldLocation)) {
-        if (null == base) {
-          throw alreadyExistsExceptionForTableLikeEntity(
-              identifier, PolarisEntitySubType.ICEBERG_VIEW);
+      boolean writeSucceeded = false;
+      try {
+        IcebergTableLikeEntity entity =
+            IcebergTableLikeEntity.of(
+                resolvedEntities == null ? null : resolvedEntities.getRawLeafEntity());
+        String existingLocation;
+        if (null == entity) {
+          existingLocation = null;
+          entity =
+              new IcebergTableLikeEntity.Builder(
+                      PolarisEntitySubType.ICEBERG_VIEW, identifier, newLocation)
+                  .setCatalogId(getCatalogId())
+                  .setId(
+                      getMetaStoreManager().generateNewEntityId(getCurrentPolarisContext()).getId())
+                  .build();
+        } else {
+          existingLocation = entity.getMetadataLocation();
+          entity =
+              new IcebergTableLikeEntity.Builder(entity).setMetadataLocation(newLocation).build();
         }
+        if (!Objects.equal(existingLocation, oldLocation)) {
+          if (null == base) {
+            throw alreadyExistsExceptionForTableLikeEntity(
+                identifier, PolarisEntitySubType.ICEBERG_VIEW);
+          }
+
+          if (null == existingLocation) {
+            throw notFoundExceptionForTableLikeEntity(
+                identifier, PolarisEntitySubType.ICEBERG_VIEW);
+          }
 
+          throw new CommitFailedException(
+              "Cannot commit to view %s metadata location from %s to %s "
+                  + "because it has been concurrently modified to %s",
+              identifier, oldLocation, newLocation, existingLocation);
+        }
         if (null == existingLocation) {
-          throw notFoundExceptionForTableLikeEntity(identifier, PolarisEntitySubType.ICEBERG_VIEW);
+          createTableLike(identifier, entity);
+        } else {
+          updateTableLike(identifier, entity);
+        }
+        writeSucceeded = true;
+      } finally {
+        if (!writeSucceeded && writeResult.written()) {
+          IcebergCatalogHandler.cleanupWrittenMetadataFiles(
+              List.of(new IcebergCatalogHandler.FileToDelete(io(), newLocation)));
         }
-
-        throw new CommitFailedException(
-            "Cannot commit to view %s metadata location from %s to %s "
-                + "because it has been concurrently modified to %s",
-            identifier, oldLocation, newLocation, existingLocation);
-      }
-      if (null == existingLocation) {
-        createTableLike(identifier, entity);
-      } else {
-        updateTableLike(identifier, entity);
       }
     }
 
-    protected String writeNewMetadataIfRequired(ViewMetadata metadata) {
-      return null != metadata.metadataFileLocation()
-          ? metadata.metadataFileLocation()
-          : writeNewMetadata(metadata, version + 1);
+    protected MetadataWriteResult writeNewMetadataIfRequired(ViewMetadata metadata) {
+      if (null != metadata.metadataFileLocation()) {
+        return new MetadataWriteResult(metadata.metadataFileLocation(), false);
+      }
+      return new MetadataWriteResult(writeNewMetadata(metadata, version + 1), true);
     }
 
     private String writeNewMetadata(ViewMetadata metadata, int newVersion) {
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogTest.java
index c69c2bf900..ff55b4b683 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogTest.java
@@ -26,6 +26,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
 import static org.mockito.ArgumentMatchers.anyList;
 import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.ArgumentMatchers.isA;
 import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
@@ -3263,4 +3264,67 @@ public abstract class AbstractLocalIcebergCatalogTest extends CatalogTests<Local
       "Test is not compatible with Polaris: rename-table op does not change the table location")
   @Override
   public void dropAfterRenameDoesntCorruptTable() {}
+
+  @Test
+  public void testFailedTableCommitDeletesOrphanMetadataFile() {
+    LocalIcebergCatalog catalog = this.catalog();
+    if (this.requiresNamespaceCreate()) {
+      catalog.createNamespace(NS);
+    }
+
+    catalog.buildTable(TABLE, SCHEMA).withPartitionSpec(SPEC).create();
+
+    List<String> writtenLocations = new ArrayList<>();
+    FileIO spyIO = spy(fileIO);
+    doAnswer(
+            inv -> {
+              writtenLocations.add(inv.getArgument(0));
+              return inv.callRealMethod();
+            })
+        .when(spyIO)
+        .newOutputFile(anyString());
+
+    TableOperations realOps = ((BaseTable) catalog.loadTable(TABLE)).operations();
+    TableOperations spyOps = spy(realOps);
+    Mockito.when(spyOps.io()).thenReturn(spyIO);
+
+    catalog.loadTable(TABLE).newFastAppend().appendFile(FILE_A).commit();
+
+    TableMetadata staleBase = realOps.current();
+    TableMetadata attempted =
+        TableMetadata.buildFrom(staleBase).setProperties(Map.of("orphan-check", "v1")).build();
+
+    Assertions.assertThatThrownBy(() -> spyOps.commit(staleBase, attempted))
+        .isInstanceOf(CommitFailedException.class);
+
+    Assertions.assertThat(writtenLocations).isNotEmpty();
+    String orphanLocation = writtenLocations.get(writtenLocations.size() - 1);
+    Assertions.assertThat(fileIO.fileExists(orphanLocation)).isFalse();
+  }
+
+  @Test
+  public void testFailedRegisterTableDoesNotDeleteCallerProvidedMetadataFile() {
+    LocalIcebergCatalog catalog = catalog();
+    Namespace namespace = Namespace.of("register_orphan_guard");
+    TableIdentifier table = TableIdentifier.of(namespace, "table");
+    if (requiresNamespaceCreate()) {
+      catalog.createNamespace(namespace);
+    }
+
+    Table created = catalog.buildTable(table, SCHEMA).create();
+    TableMetadata currentMetadata = ((BaseTable) created).operations().current();
+    String metadataDir =
+        currentMetadata
+            .metadataFileLocation()
+            .substring(0, currentMetadata.metadataFileLocation().lastIndexOf('/') + 1);
+    String externalMetadataLocation = metadataDir + "external-v1.metadata.json";
+    fileIO.addFile(
+        externalMetadataLocation, TableMetadataParser.toJson(currentMetadata).getBytes(UTF_8));
+
+    Assertions.assertThatThrownBy(
+            () -> catalog.registerTable(table, externalMetadataLocation, false))
+        .isInstanceOf(AlreadyExistsException.class);
+
+    Assertions.assertThat(fileIO.fileExists(externalMetadataLocation)).isTrue();
+  }
 }
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogViewTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogViewTest.java
index b708062c6c..7bafceea1c 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogViewTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractLocalIcebergCatalogViewTest.java
@@ -27,13 +27,20 @@ import jakarta.inject.Inject;
 import java.io.IOException;
 import java.lang.reflect.Method;
 import java.time.Duration;
+import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.exceptions.CommitFailedException;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.view.BaseView;
 import org.apache.iceberg.view.View;
 import org.apache.iceberg.view.ViewCatalogTests;
+import org.apache.iceberg.view.ViewMetadata;
+import org.apache.iceberg.view.ViewOperations;
 import org.apache.polaris.core.PolarisCallContext;
 import org.apache.polaris.core.PolarisDiagnostics;
 import org.apache.polaris.core.admin.model.CreateCatalogRequest;
@@ -276,4 +283,82 @@ public abstract class AbstractLocalIcebergCatalogViewTest
             afterRefreshEvent.attributes().getRequired(EventAttributes.VIEW_IDENTIFIER))
         .isEqualTo(TestData.TABLE);
   }
+
+  @Test
+  public void testFailedViewCommitDeletesOrphanMetadataFile() {
+    List<String> writtenLocations = new ArrayList<>();
+    List<String> deletedLocations = new ArrayList<>();
+    FileIOFactory spiedFactory = Mockito.spy(fileIOFactory);
+    Mockito.doAnswer(
+            inv -> {
+              FileIO real = (FileIO) inv.callRealMethod();
+              FileIO spy = Mockito.spy(real);
+              Mockito.doAnswer(
+                      out -> {
+                        writtenLocations.add(out.getArgument(0));
+                        return out.callRealMethod();
+                      })
+                  .when(spy)
+                  .newOutputFile(Mockito.anyString());
+              Mockito.doAnswer(
+                      del -> {
+                        deletedLocations.add(del.getArgument(0));
+                        return del.callRealMethod();
+                      })
+                  .when(spy)
+                  .deleteFile(Mockito.anyString());
+              return spy;
+            })
+        .when(spiedFactory)
+        .loadFileIO(Mockito.any(), Mockito.any(), Mockito.any());
+
+    PolarisPassthroughResolutionView passthroughView =
+        new PolarisPassthroughResolutionView(
+            resolutionManifestFactory, authenticatedRoot, CATALOG_NAME);
+    LocalIcebergCatalog spiedCatalog =
+        new LocalIcebergCatalog(
+            diagServices,
+            resolverFactory,
+            metaStoreManager,
+            polarisContext,
+            passthroughView,
+            authenticatedRoot,
+            Mockito.mock(),
+            storageAccessConfigProvider,
+            spiedFactory,
+            polarisEventDispatcher,
+            eventMetadataFactory);
+    spiedCatalog.initialize(
+        CATALOG_NAME,
+        ImmutableMap.<String, String>builder()
+            .put(CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.inmemory.InMemoryFileIO")
+            .putAll(VIEW_PREFIXES)
+            .build());
+
+    spiedCatalog.createNamespace(TestData.NAMESPACE);
+    spiedCatalog
+        .buildView(TestData.TABLE)
+        .withDefaultNamespace(TestData.NAMESPACE)
+        .withSchema(TestData.SCHEMA)
+        .withQuery("a", "b")
+        .create();
+
+    ViewOperations staleOps = ((BaseView) spiedCatalog.loadView(TestData.TABLE)).operations();
+    ViewMetadata staleBase = staleOps.current();
+
+    spiedCatalog.loadView(TestData.TABLE).updateProperties().set("concurrent", "true").commit();
+
+    Set<String> legitimateWrites = new HashSet<>(writtenLocations);
+
+    ViewMetadata attempted =
+        ViewMetadata.buildFrom(staleBase).setProperties(Map.of("orphan-check", "v1")).build();
+
+    Assertions.assertThatThrownBy(() -> staleOps.commit(staleBase, attempted))
+        .isInstanceOf(CommitFailedException.class);
+
+    List<String> orphanCandidates =
+        writtenLocations.stream().filter(loc -> !legitimateWrites.contains(loc)).toList();
+    Assertions.assertThat(orphanCandidates).isNotEmpty();
+    Assertions.assertThat(deletedLocations).containsAll(orphanCandidates);
+  }
 }