This is an automated email from the ASF dual-hosted git repository.
amogh-jahagirdar pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git
The following commit(s) were added to refs/heads/main by this push:
new c131e75958 Core: Fix file cleanup logic to retain Puffin files shared
by live deletion vectors (#18408)
c131e75958 is described below
commit c131e75958ff7bef67e22d241b02e5f8338b3955
Author: Yuya Ebihara <[email protected]>
AuthorDate: Wed Oct 14 11:59:42 2026 +0900
Core: Fix file cleanup logic to retain Puffin files shared by live deletion
vectors (#18408)
---
.../org/apache/iceberg/IncrementalFileCleanup.java | 65 ++++++++++++++++-
.../org/apache/iceberg/TestRemoveSnapshots.java | 83 ++++++++++++++++++++++
2 files changed, 146 insertions(+), 2 deletions(-)
diff --git a/core/src/main/java/org/apache/iceberg/IncrementalFileCleanup.java
b/core/src/main/java/org/apache/iceberg/IncrementalFileCleanup.java
index 911a94c13f..73a1e4584f 100644
--- a/core/src/main/java/org/apache/iceberg/IncrementalFileCleanup.java
+++ b/core/src/main/java/org/apache/iceberg/IncrementalFileCleanup.java
@@ -111,6 +111,7 @@ class IncrementalFileCleanup extends FileCleanupStrategy {
// find manifests to clean up that are still referenced by a valid
snapshot, but written by an
// expired snapshot
Set<String> validManifests = ConcurrentHashMap.newKeySet();
+ Set<ManifestFile> liveDeleteManifests = ConcurrentHashMap.newKeySet();
Set<ManifestFile> manifestsToScan = ConcurrentHashMap.newKeySet();
// Reads and deletes are done using
Tasks.foreach(...).suppressFailureWhenFinished to complete
@@ -131,6 +132,9 @@ class IncrementalFileCleanup extends FileCleanupStrategy {
try (CloseableIterable<ManifestFile> manifests =
readManifests(snapshot)) {
for (ManifestFile manifest : manifests) {
validManifests.add(manifest.path());
+ if (manifest.content() == ManifestContent.DELETES) {
+ liveDeleteManifests.add(manifest.copy());
+ }
long snapshotId = manifest.snapshotId();
// whether the manifest was created by a valid snapshot
(true) or an expired
@@ -261,7 +265,11 @@ class IncrementalFileCleanup extends FileCleanupStrategy {
if (ExpireSnapshots.CleanupLevel.ALL == cleanupLevel) {
Set<String> filesToDelete =
findFilesToDelete(
- manifestsToScan, manifestsToRevert, validIds,
beforeExpiration.specsById());
+ manifestsToScan,
+ manifestsToRevert,
+ liveDeleteManifests,
+ validIds,
+ beforeExpiration.specsById());
LOG.debug("Deleting {} data files", filesToDelete.size());
deleteFiles(filesToDelete, "data");
}
@@ -282,9 +290,11 @@ class IncrementalFileCleanup extends FileCleanupStrategy {
private Set<String> findFilesToDelete(
Set<ManifestFile> manifestsToScan,
Set<ManifestFile> manifestsToRevert,
+ Set<ManifestFile> liveDeleteManifests,
Set<Long> validIds,
Map<Integer, PartitionSpec> specsById) {
Set<String> filesToDelete = ConcurrentHashMap.newKeySet();
+ Set<String> dvFileRemovalCandidates = ConcurrentHashMap.newKeySet();
Tasks.foreach(manifestsToScan)
.retry(3)
.suppressFailureWhenFinished()
@@ -302,7 +312,12 @@ class IncrementalFileCleanup extends FileCleanupStrategy {
if (entry.status() == ManifestEntry.Status.DELETED
&& !validIds.contains(entry.snapshotId())) {
// use toString to ensure the path will not change (Utf8
is reused)
- filesToDelete.add(entry.file().location());
+ String location = entry.file().location();
+ if (entry.file().format() == FileFormat.PUFFIN) {
+ dvFileRemovalCandidates.add(location);
+ } else {
+ filesToDelete.add(location);
+ }
}
}
} catch (IOException e) {
@@ -310,6 +325,8 @@ class IncrementalFileCleanup extends FileCleanupStrategy {
}
});
+ filesToDelete.addAll(unreferencedDVs(dvFileRemovalCandidates,
liveDeleteManifests, specsById));
+
Tasks.foreach(manifestsToRevert)
.retry(3)
.suppressFailureWhenFinished()
@@ -335,4 +352,48 @@ class IncrementalFileCleanup extends FileCleanupStrategy {
return filesToDelete;
}
+
+ // A Puffin file can be shared by multiple deletion vectors, so keep it if
any live one remains
+ private Set<String> unreferencedDVs(
+ Set<String> dvFileRemovalCandidates,
+ Set<ManifestFile> liveDeleteManifests,
+ Map<Integer, PartitionSpec> specsById) {
+ if (dvFileRemovalCandidates.isEmpty()) {
+ return dvFileRemovalCandidates;
+ }
+
+ Set<String> unreferenced = ConcurrentHashMap.newKeySet();
+ unreferenced.addAll(dvFileRemovalCandidates);
+
+ try {
+ Tasks.foreach(liveDeleteManifests)
+ .retry(3)
+ .stopOnFailure()
+ .throwFailureWhenFinished()
+ .executeWith(planExecutorService)
+ .onFailure(
+ (item, exc) ->
+ LOG.warn("Failed to determine live DVs in manifest {}",
item.path(), exc))
+ .run(
+ manifest -> {
+ if (unreferenced.isEmpty()) {
+ return;
+ }
+
+ try (CloseableIterable<String> paths =
+ ManifestFiles.readPaths(manifest, fileIO, specsById)) {
+ for (String path : paths) {
+ unreferenced.remove(path);
+ }
+ } catch (IOException e) {
+ throw new RuntimeIOException(e, "Failed to read manifest
file: %s", manifest);
+ }
+ });
+ } catch (Throwable e) {
+ LOG.warn("Failed to determine live DVs, skipping DV cleanup", e);
+ return Set.of();
+ }
+
+ return unreferenced;
+ }
}
diff --git a/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java
b/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java
index 7a983bf585..e3dd980dfe 100644
--- a/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java
+++ b/core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java
@@ -2144,6 +2144,89 @@ public class TestRemoveSnapshots extends TestBase {
.hasMessageContaining("Invalid cleanup level: null");
}
+ @TestTemplate
+ void expireDoesNotDeletePuffinFileSharedByLiveDeletionVectors() {
+ assumeThat(formatVersion).as("Deletion vectors require V3 or
later").isGreaterThanOrEqualTo(3);
+
+ table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit();
+
+ // one Puffin file with deletion vectors for FILE_A and FILE_B
+ String sharedPuffinPath = "/path/to/shared-dvs-" + UUID.randomUUID() +
".puffin";
+ DeleteFile dvA = deletionVector(sharedPuffinPath, FILE_A, 4L);
+ DeleteFile dvB = deletionVector(sharedPuffinPath, FILE_B, 44L);
+ table.newRowDelta().addDeletes(dvA).addDeletes(dvB).commit();
+
+ // replace FILE_A's deletion vector; FILE_B's stays in the shared Puffin
file
+ DeleteFile newDvA =
+ deletionVector("/path/to/new-dv-" + UUID.randomUUID() + ".puffin",
FILE_A, 4L);
+ table
+ .newRowDelta()
+ .validateFromSnapshot(table.currentSnapshot().snapshotId())
+ .removeDeletes(dvA)
+ .addDeletes(newDvA)
+ .commit();
+
+ table.newAppend().appendFile(FILE_C).commit();
+
+ long tAfterCommits =
waitUntilAfter(table.currentSnapshot().timestampMillis());
+
+ Set<String> deletedFiles = Sets.newHashSet();
+
removeSnapshots(table).expireOlderThan(tAfterCommits).deleteWith(deletedFiles::add).commit();
+
+ assertThat(table.snapshots()).hasSize(1);
+ assertThat(deletedFiles)
+ .as("Puffin file with a live deletion vector must not be deleted")
+ .doesNotContain(sharedPuffinPath);
+ }
+
+ @TestTemplate
+ void expireDeletesPuffinFileWhenAllSharedDeletionVectorsReplaced() {
+ assumeThat(formatVersion).as("Deletion vectors require V3 or
later").isGreaterThanOrEqualTo(3);
+
+ table.newAppend().appendFile(FILE_A).appendFile(FILE_B).commit();
+
+ String sharedPuffinPath = "/path/to/shared-dvs-" + UUID.randomUUID() +
".puffin";
+ DeleteFile dvA = deletionVector(sharedPuffinPath, FILE_A, 4L);
+ DeleteFile dvB = deletionVector(sharedPuffinPath, FILE_B, 44L);
+ table.newRowDelta().addDeletes(dvA).addDeletes(dvB).commit();
+
+ DeleteFile newDvA =
+ deletionVector("/path/to/new-dv-a-" + UUID.randomUUID() + ".puffin",
FILE_A, 4L);
+ DeleteFile newDvB =
+ deletionVector("/path/to/new-dv-b-" + UUID.randomUUID() + ".puffin",
FILE_B, 4L);
+ table
+ .newRowDelta()
+ .validateFromSnapshot(table.currentSnapshot().snapshotId())
+ .removeDeletes(dvA)
+ .removeDeletes(dvB)
+ .addDeletes(newDvA)
+ .addDeletes(newDvB)
+ .commit();
+
+ table.newAppend().appendFile(FILE_C).commit();
+
+ long tAfterCommits =
waitUntilAfter(table.currentSnapshot().timestampMillis());
+
+ Set<String> deletedFiles = Sets.newHashSet();
+
removeSnapshots(table).expireOlderThan(tAfterCommits).deleteWith(deletedFiles::add).commit();
+
+ assertThat(table.snapshots()).hasSize(1);
+ assertThat(deletedFiles).contains(sharedPuffinPath);
+ }
+
+ private DeleteFile deletionVector(String puffinPath, DataFile dataFile, long
contentOffset) {
+ return FileMetadata.deleteFileBuilder(table.spec())
+ .ofPositionDeletes()
+ .withPath(puffinPath)
+ .withFileSizeInBytes(100L)
+ .withPartition(dataFile.partition())
+ .withRecordCount(1L)
+ .withReferencedDataFile(dataFile.location())
+ .withContentOffset(contentOffset)
+ .withContentSizeInBytes(40L)
+ .build();
+ }
+
@TestTemplate
void readManifestsProjectsManifestContent() throws IOException {
assumeThat(formatVersion)