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);
+ }
}