Skip to content
52 changes: 52 additions & 0 deletions api/src/main/java/org/apache/iceberg/CleanupMode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* 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.iceberg;

/** An enum representing possible clean up mode used in snapshot expiration. */
public enum CleanupMode {
Comment thread
dramaticlly marked this conversation as resolved.
Outdated
NONE(0),
METADATA_ONLY(1),
ALL(2);

CleanupMode(int id) {
this.id = id;
}

private final int id;

public int id() {
return id;
}

public boolean requireCleanup() {
Comment thread
dramaticlly marked this conversation as resolved.
Outdated
return this.id > 0;
}

public static CleanupMode fromId(int id) {
switch (id) {
case 0:
return NONE;
case 1:
return METADATA_ONLY;
case 2:
return ALL;
}
throw new IllegalArgumentException("Unknown cleanup mode: " + id);
}
}
30 changes: 30 additions & 0 deletions api/src/main/java/org/apache/iceberg/ExpireSnapshots.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,39 @@ public interface ExpireSnapshots extends PendingUpdate<List<Snapshot>> {
*
* @param clean setting this to false will skip deleting expired manifests and files
* @return this for method chaining
* @deprecated since 1.10.0, will be removed in 2.0.0; use {@link #cleanMode(CleanupMode)}
* instead.
*/
@Deprecated
ExpireSnapshots cleanExpiredFiles(boolean clean);

/**
* Configures the cleanup mode for expired files.
*
* <p>This method provides fine-grained control over which files are cleaned up during snapshot
* expiration. The cleanup modes are:
*
* <ul>
Comment thread
dramaticlly marked this conversation as resolved.
Outdated
* <li>{@link CleanupMode#ALL} - Clean up both metadata and data files (default)
* <li>{@link CleanupMode#METADATA_ONLY} - Clean up only metadata files (manifests, manifest
Comment thread
dramaticlly marked this conversation as resolved.
Outdated
* lists), retain data files
* <li>{@link CleanupMode#NONE} - Skip all file cleanup, only remove snapshot metadata
* </ul>
*
* <p>consider METADATA_ONLY mode when data files are shared across tables or when using
* procedures like add-files that may reference the same data files.
*
* <p>consider NONE mode when data and manifest files may be more efficiently removed using a
* distributed framework through the actions API
*
* @param mode the cleanup mode to use for expired snapshots
* @return this for method chaining
*/
default ExpireSnapshots cleanMode(CleanupMode mode) {
Comment thread
dramaticlly marked this conversation as resolved.
Outdated
throw new UnsupportedOperationException(
this.getClass().getName() + " doesn't implement cleanMode");
}

/**
* Enable cleaning up unused metadata, such as partition specs, schemas, etc.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,18 +45,21 @@ public void accept(String file) {

protected final FileIO fileIO;
protected final ExecutorService planExecutorService;
protected final CleanupMode cleanupMode;
private final Consumer<String> deleteFunc;
private final ExecutorService deleteExecutorService;

protected FileCleanupStrategy(
FileIO fileIO,
ExecutorService deleteExecutorService,
ExecutorService planExecutorService,
Consumer<String> deleteFunc) {
Consumer<String> deleteFunc,
CleanupMode cleanupMode) {
Comment thread
dramaticlly marked this conversation as resolved.
Outdated
this.fileIO = fileIO;
this.deleteExecutorService = deleteExecutorService;
this.planExecutorService = planExecutorService;
this.deleteFunc = deleteFunc;
this.cleanupMode = cleanupMode;
}

public abstract void cleanFiles(TableMetadata beforeExpiration, TableMetadata afterExpiration);
Expand Down
17 changes: 11 additions & 6 deletions core/src/main/java/org/apache/iceberg/IncrementalFileCleanup.java
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@ class IncrementalFileCleanup extends FileCleanupStrategy {
FileIO fileIO,
ExecutorService deleteExecutorService,
ExecutorService planExecutorService,
Consumer<String> deleteFunc) {
super(fileIO, deleteExecutorService, planExecutorService, deleteFunc);
Consumer<String> deleteFunc,
CleanupMode cleanupMode) {
super(fileIO, deleteExecutorService, planExecutorService, deleteFunc, cleanupMode);
}

@Override
Expand Down Expand Up @@ -251,11 +252,15 @@ public void cleanFiles(TableMetadata beforeExpiration, TableMetadata afterExpira
}
});

Set<String> filesToDelete =
findFilesToDelete(
manifestsToScan, manifestsToRevert, validIds, beforeExpiration.specsById());
if (CleanupMode.ALL == cleanupMode) {
Set<String> filesToDelete =
findFilesToDelete(
manifestsToScan, manifestsToRevert, validIds, beforeExpiration.specsById());
LOG.debug("Deleting {} data files", filesToDelete.size());
deleteFiles(filesToDelete, "data");
}

deleteFiles(filesToDelete, "data");
LOG.debug("Deleting {} manifest files", manifestsToDelete.size());
Comment thread
dramaticlly marked this conversation as resolved.
deleteFiles(manifestsToDelete, "manifest");
Comment thread
dramaticlly marked this conversation as resolved.
deleteFiles(manifestListsToDelete, "manifest list");

Expand Down
14 changes: 10 additions & 4 deletions core/src/main/java/org/apache/iceberg/ReachableFileCleanup.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ class ReachableFileCleanup extends FileCleanupStrategy {
FileIO fileIO,
ExecutorService deleteExecutorService,
ExecutorService planExecutorService,
Consumer<String> deleteFunc) {
super(fileIO, deleteExecutorService, planExecutorService, deleteFunc);
Consumer<String> deleteFunc,
CleanupMode cleanupMode) {
super(fileIO, deleteExecutorService, planExecutorService, deleteFunc, cleanupMode);
}

@Override
Expand All @@ -72,10 +73,15 @@ public void cleanFiles(TableMetadata beforeExpiration, TableMetadata afterExpira
snapshotsAfterExpiration, deletionCandidates, currentManifests::add);

if (!manifestsToDelete.isEmpty()) {
Set<String> dataFilesToDelete = findFilesToDelete(manifestsToDelete, currentManifests);
deleteFiles(dataFilesToDelete, "data");
if (CleanupMode.ALL == cleanupMode) {
Set<String> dataFilesToDelete = findFilesToDelete(manifestsToDelete, currentManifests);
LOG.debug("Deleting {} data files", dataFilesToDelete.size());
deleteFiles(dataFilesToDelete, "data");
}

Set<String> manifestPathsToDelete =
manifestsToDelete.stream().map(ManifestFile::path).collect(Collectors.toSet());
LOG.debug("Deleting {} manifest files", manifestPathsToDelete.size());
deleteFiles(manifestPathsToDelete, "manifest");
}
}
Expand Down
33 changes: 26 additions & 7 deletions core/src/main/java/org/apache/iceberg/RemoveSnapshots.java
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ class RemoveSnapshots implements ExpireSnapshots {
private final Set<Long> idsToRemove = Sets.newHashSet();
private final long now;
private final long defaultMaxRefAgeMs;
private boolean cleanExpiredFiles = true;
private final CleanupMode defaultCleanupMode = CleanupMode.ALL;
private TableMetadata base;
private long defaultExpireOlderThan;
private int defaultMinNumSnapshots;
Expand All @@ -79,6 +79,7 @@ class RemoveSnapshots implements ExpireSnapshots {
private Boolean incrementalCleanup;
private boolean specifiedSnapshotId = false;
private boolean cleanExpiredMetadata = false;
private CleanupMode cleanupMode = defaultCleanupMode;

RemoveSnapshots(TableOperations ops) {
this.ops = ops;
Expand All @@ -103,7 +104,12 @@ class RemoveSnapshots implements ExpireSnapshots {

@Override
public ExpireSnapshots cleanExpiredFiles(boolean clean) {
this.cleanExpiredFiles = clean;
LOG.warn("cleanExpiredFiles(boolean) is deprecated. Use cleanMode(CleanupMode) instead.");
Comment thread
dramaticlly marked this conversation as resolved.
Outdated
Preconditions.checkArgument(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about throwing here, why not just override whatever the cleanup mode was set to?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Eduard, I added such validation mainly want to prevent double intention where one set both the cleanExpired files as well as cleanupMode, so intention is not clear and override might be risky, this could lead to some unexpected results so throw early maybe helpful.

This can be found more in my unit test named testCannotSetCleanExpiredFilesAndCleanModeTogether in https://github.com/apache/iceberg/pull/14287/files?new_files_changed=true#diff-35ed4072da58b6d638da909476780a4da8d97390bd56e41f8555b15b91499b64R2071-R2091.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems ok to throw an exception here because we don't want to users to call both setters. only one should be used.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but the condition may not be precise/perfect. e.g., if the client called cleanupLevel(ALL) (default value), it would still allow this method to go through.

the other way is to default cleanupLevel to null. but we would need to do a bit if-else check during read to apply the value.

maybe the complexity is not worth it. I am wondering if it is simpler to just go with what @nastra suggested. just rely on API deprecation to move users away from the old API.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's some nuance here and value to keep:

Take what I included in the unit test for an example here, use preconditions check here prevent the case where both cleanupLevel(ExpireSnapshots.CleanupLevel.METADATA_ONLY) and cleanExpiredFiles(false) is configured on snapshot expiration, as the intention is unclear at the moment, override to either NONE or METADATA_ONLY could potentially result in undesired results.

Although unlikely, for the corner case discussed here when client called cleanupLevel(ALL) and also set the cleanExpiredFiles

  • if cleanExpiredFiles = true, then cleanupLevel ends up resolve to ALL and logic is equivalent
  • if cleanExpiredFiles = false, we allow such override to happen and end up with cleanupLevel=None and retain all files, I think it's acceptable as we are moving from most restrictive and least restrictive, and those files can be later cleaned with orphan removal.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the current impleemntation is reasonable so long as there's no breakages when someone upgrades to 1.11 and using the deprecated API, which it looks like there's not since the condition is based on whether someone additionally set the new API. I also prefer the approach of failing if a non-default cleanup level is set and the old one is also set because it's pretty unlikely a user intended to do that and it forces them to resolve that ambiguity that @dramaticlly mentioned.

cleanupMode == defaultCleanupMode,
"Cannot set cleanExpiredFiles when cleanMode has already been set to: %s",
cleanupMode);
this.cleanupMode = clean ? CleanupMode.ALL : CleanupMode.NONE;
return this;
}

Expand Down Expand Up @@ -167,6 +173,17 @@ public ExpireSnapshots cleanExpiredMetadata(boolean clean) {
return this;
}

@Override
public ExpireSnapshots cleanMode(CleanupMode mode) {
Preconditions.checkNotNull(mode, "CleanupMode cannot be null");
Comment thread
dramaticlly marked this conversation as resolved.
Outdated
Preconditions.checkArgument(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as mentioned earlier about throwing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unresolving this one, I do think this is a case where we should just be consistent with what's done in the other options in this API, which is just override what was previously set. e.g. we can set cleanExpiredFiles multiple times, and it'll just take the last. Any reason why this particular one should be different and throw @dramaticlly ?

@dramaticlly dramaticlly Nov 17, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this mostly want to avoid ambiguous intention when user set both options together regardless of which option is chained first.

i.e both shall fail with exception to ask user for further clarification

table.expireSnapshots()
.cleanupLevel(ExpireSnapshots.CleanupLevel.METADATA_ONLY)
.cleanExpiredFiles(false)

and

table
.expireSnapshots()
.cleanExpiredFiles(false)
.cleanupLevel(ExpireSnapshots.CleanupLevel.METADATA_ONLY)

Updated preconditions to better assertion condition and message

cleanupMode == defaultCleanupMode,
"Cannot set cleanMode when it has already been set to: %s",
cleanupMode);
this.cleanupMode = mode;
return this;
}

@Override
public List<Snapshot> apply() {
TableMetadata updated = internalApply();
Expand All @@ -184,6 +201,8 @@ private TableMetadata internalApply() {
return base;
}

LOG.debug("Using cleanup mode: {}", cleanupMode);

Set<Long> idsToRetain = Sets.newHashSet();
// Identify refs that should be removed
Map<String, SnapshotRef> retainedRefs = computeRetainedRefs(base.refs());
Expand Down Expand Up @@ -352,8 +371,8 @@ public void commit() {
});
LOG.info("Committed snapshot changes");

if (cleanExpiredFiles && !base.snapshots().isEmpty()) {
Comment thread
dramaticlly marked this conversation as resolved.
cleanExpiredSnapshots();
if (cleanupMode.requireCleanup() && !base.snapshots().isEmpty()) {
cleanExpiredSnapshots(cleanupMode);
}
}

Expand All @@ -362,7 +381,7 @@ ExpireSnapshots withIncrementalCleanup(boolean useIncrementalCleanup) {
return this;
}

private void cleanExpiredSnapshots() {
private void cleanExpiredSnapshots(CleanupMode mode) {
TableMetadata current = ops.refresh();

if (Boolean.TRUE.equals(incrementalCleanup)) {
Expand All @@ -380,9 +399,9 @@ private void cleanExpiredSnapshots() {
FileCleanupStrategy cleanupStrategy =
incrementalCleanup
? new IncrementalFileCleanup(
ops.io(), deleteExecutorService, planExecutorService(), deleteFunc)
ops.io(), deleteExecutorService, planExecutorService(), deleteFunc, mode)
: new ReachableFileCleanup(
ops.io(), deleteExecutorService, planExecutorService(), deleteFunc);
ops.io(), deleteExecutorService, planExecutorService(), deleteFunc, mode);

cleanupStrategy.cleanFiles(base, current);
}
Expand Down
Loading