laskoviymishka commented on code in PR #17791:
URL: https://github.com/apache/iceberg/pull/17791#discussion_r3958305555


##########
nessie/src/main/java/org/apache/iceberg/nessie/NessieUtil.java:
##########
@@ -130,7 +130,8 @@ private static void checkAndUpdateGCProperties(
 
     // To prevent accidental deletion of files that are still referenced by 
other branches/tags,
     // setting GC_ENABLED to 'false' is recommended, so that all Iceberg's gc 
operations like
-    // expire_snapshots, remove_orphan_files, drop_table with purge will fail 
with an error.
+    // expire_snapshots, remove_orphan_files, drop_table with purge will fail 
with an error unless

Review Comment:
   "unless they are configured to skip file deletion" reads as if it applies to 
all three, but only `expire_snapshots` has that knob (`CleanupLevel.NONE`) — 
`remove_orphan_files` and drop-with-purge still hard-fail. I'd scope it: "…will 
fail with an error; `expire_snapshots` can still run with `CleanupLevel.NONE` 
to expire metadata without deleting files."



##########
core/src/main/java/org/apache/iceberg/RemoveSnapshots.java:
##########
@@ -385,6 +384,15 @@ ExpireSnapshots withIncrementalCleanup(boolean 
useIncrementalCleanup) {
     return this;
   }
 
+  private void validateCleanupLevel() {

Review Comment:
   Moving the guard to a commit-time check is the right call, and validating 
after `internalApply()` refreshes `base` is exactly what makes the gate see the 
current `gc.enabled`.
   
   One thing I'd tighten: `validateCleanupLevel()` reads the `base` field, so 
it's only correct because it happens to be called right after `internalApply()` 
reassigns it — that ordering isn't enforced anywhere. A future caller, or 
`apply()`, or a subclass that calls it without refreshing first would silently 
validate against stale metadata. I'd pass the metadata in explicitly, 
`validateCleanupLevel(base)`, so the dependency is visible at the call site and 
the method is testable on its own. wdyt?



##########
core/src/main/java/org/apache/iceberg/RemoveSnapshots.java:
##########
@@ -369,6 +366,8 @@ public void commit() {
         .run(
             item -> {
               TableMetadata updated = internalApply();
+              // validate after internalApply so that gc.enabled is read from 
the refreshed base
+              validateCleanupLevel();
               ops.commit(base, updated);
             });
     LOG.info(

Review Comment:
   With `NONE` now a real path, this logs "prepare to clean up files at 
level=NONE" right before the `CleanupLevel.NONE != cleanupLevel` guard skips 
all cleanup — reads as a contradiction in the log. I'd branch it so the NONE 
case says something like "committed, no file cleanup (level=NONE)". It's the 
line an operator will stare at when debugging a gc-disabled expiry. 
(`cleanupLevel.name()` a couple lines down can also just be `cleanupLevel` — 
slf4j calls toString.)



##########
nessie/src/test/java/org/apache/iceberg/nessie/TestNessieTable.java:
##########
@@ -584,17 +585,37 @@ public void testListTables() {
   }
 
   @Test
-  public void testGCDisabled() {
+  public void testGCDisabled() throws IOException {
     Table icebergTable = catalog.loadTable(TABLE_IDENTIFIER);
 
     
assertThat(icebergTable.properties()).containsEntry(TableProperties.GC_ENABLED, 
"false");
 
+    String fileLocation = addRecordsToFile(icebergTable, "file");
+    DataFile file = makeDataFile(icebergTable, fileLocation);
+    icebergTable.newAppend().appendFile(file).commit();
+
+    long expiredSnapshotId = icebergTable.currentSnapshot().snapshotId();
+    String manifestListLocation =
+        icebergTable.currentSnapshot().manifestListLocation().replace("file:", 
"");
+
+    icebergTable.newDelete().deleteFile(file).commit();
+
     assertThatThrownBy(
             () ->
                 
icebergTable.expireSnapshots().expireOlderThan(System.currentTimeMillis()).commit())
         .isInstanceOf(ValidationException.class)
-        .hasMessage(
-            "Cannot expire snapshots: GC is disabled (deleting files may 
corrupt other tables)");
+        .hasMessageStartingWith("Cannot expire snapshots with cleanup level 
ALL: GC is disabled");
+
+    icebergTable
+        .expireSnapshots()
+        .expireOlderThan(Long.MAX_VALUE)
+        .cleanupLevel(ExpireSnapshots.CleanupLevel.NONE)
+        .commit();
+
+    icebergTable.refresh();
+    assertThat(icebergTable.snapshot(expiredSnapshotId)).isNull();
+    assertThat(new File(fileLocation)).exists();
+    assertThat(new File(manifestListLocation)).exists();

Review Comment:
   This is the assertion I'd most want added before this lands. You check the 
manifest list survives, but for the Nessie case the files that actually matter 
are the avro manifests inside it — those are what other branches share, and 
"don't delete shared files" is the whole point of the NONE path. A regression 
that deleted the manifests but left the manifest list would pass this test.
   
   I'd capture the expired snapshot's manifest paths before the NONE expiry — 
`icebergTable.snapshot(expiredSnapshotId).allManifests(icebergTable.io())`, 
mapping each to `ManifestFile::path` — and assert each still exists afterward, 
alongside the data file and manifest list you already check.



##########
core/src/test/java/org/apache/iceberg/TestRemoveSnapshots.java:
##########
@@ -968,13 +968,102 @@ public void testWithExpiringStagedThenCherrypick() {
 
   @TestTemplate
   public void testExpireSnapshotsWhenGarbageCollectionDisabled() {
+    table.newAppend().appendFile(FILE_A).commit();
+    Snapshot firstSnapshot = table.currentSnapshot();
+    table.newAppend().appendFile(FILE_B).commit();
+    long tAfterCommits = 
waitUntilAfter(table.currentSnapshot().timestampMillis());
+
     table.updateProperties().set(TableProperties.GC_ENABLED, "false").commit();
 
+    assertThatThrownBy(() -> 
removeSnapshots(table).expireOlderThan(tAfterCommits).commit())
+        .isInstanceOf(ValidationException.class)
+        .hasMessageStartingWith("Cannot expire snapshots with cleanup level 
ALL: GC is disabled");
+
+    assertThatThrownBy(
+            () ->
+                removeSnapshots(table)
+                    .expireOlderThan(tAfterCommits)
+                    .cleanupLevel(ExpireSnapshots.CleanupLevel.METADATA_ONLY)
+                    .commit())
+        .isInstanceOf(ValidationException.class)
+        .hasMessageStartingWith(
+            "Cannot expire snapshots with cleanup level METADATA_ONLY: GC is 
disabled");
+
+    // apply() deletes nothing, so it is not blocked by GC being disabled
+    assertThat(removeSnapshots(table).expireOlderThan(tAfterCommits).apply())
+        .containsExactly(firstSnapshot);
+
+    assertThat(table.snapshot(firstSnapshot.snapshotId())).isNotNull();
+  }
+
+  @TestTemplate
+  public void testExpireSnapshotsWithoutCleanupWhenGarbageCollectionDisabled() 
{
     table.newAppend().appendFile(FILE_A).commit();
+    Snapshot firstSnapshot = table.currentSnapshot();
+    table.newDelete().deleteFile(FILE_A).commit();
+    Snapshot secondSnapshot = table.currentSnapshot();
+    table.newAppend().appendFile(FILE_B).commit();
+    Snapshot currentSnapshot = table.currentSnapshot();
+    long tAfterCommits = waitUntilAfter(currentSnapshot.timestampMillis());
+
+    table.updateProperties().set(TableProperties.GC_ENABLED, "false").commit();
+
+    Set<String> deletedFiles = Sets.newHashSet();
+    removeSnapshots(table)
+        .expireOlderThan(tAfterCommits)
+        .cleanupLevel(ExpireSnapshots.CleanupLevel.NONE)
+        .deleteWith(deletedFiles::add)
+        .commit();
 
-    assertThatThrownBy(() -> table.expireSnapshots())
+    assertThat(table.snapshot(firstSnapshot.snapshotId())).isNull();
+    assertThat(table.snapshot(secondSnapshot.snapshotId())).isNull();
+    assertThat(table.currentSnapshot()).isEqualTo(currentSnapshot);
+    assertThat(deletedFiles).isEmpty();
+  }
+
+  @TestTemplate
+  public void testDisableGarbageCollectionBeforeCommit() {
+    table.newAppend().appendFile(FILE_A).commit();
+    Snapshot firstSnapshot = table.currentSnapshot();
+    table.newAppend().appendFile(FILE_B).commit();
+    long tAfterCommits = 
waitUntilAfter(table.currentSnapshot().timestampMillis());
+
+    Set<String> deletedFiles = Sets.newHashSet();
+    ExpireSnapshots expireSnapshots =
+        removeSnapshots(table)
+            .expireOlderThan(tAfterCommits)
+            .cleanupLevel(ExpireSnapshots.CleanupLevel.ALL)
+            .deleteWith(deletedFiles::add);
+
+    table.updateProperties().set(TableProperties.GC_ENABLED, "false").commit();
+
+    assertThatThrownBy(expireSnapshots::commit)
         .isInstanceOf(ValidationException.class)
-        .hasMessageStartingWith("Cannot expire snapshots: GC is disabled");
+        .hasMessageStartingWith("Cannot expire snapshots with cleanup level 
ALL: GC is disabled");
+
+    assertThat(table.snapshot(firstSnapshot.snapshotId())).isNotNull();
+    assertThat(deletedFiles).isEmpty();
+  }
+
+  @TestTemplate
+  public void testDisableGarbageCollectionBeforeCommitWithoutCleanup() {
+    table.newAppend().appendFile(FILE_A).commit();
+    Snapshot firstSnapshot = table.currentSnapshot();
+    table.newAppend().appendFile(FILE_B).commit();
+    long tAfterCommits = 
waitUntilAfter(table.currentSnapshot().timestampMillis());
+
+    Set<String> deletedFiles = Sets.newHashSet();
+    ExpireSnapshots expireSnapshots =
+        removeSnapshots(table)
+            .expireOlderThan(tAfterCommits)
+            .cleanupLevel(ExpireSnapshots.CleanupLevel.NONE)
+            .deleteWith(deletedFiles::add);
+
+    table.updateProperties().set(TableProperties.GC_ENABLED, "false").commit();
+    expireSnapshots.commit();
+
+    assertThat(table.snapshot(firstSnapshot.snapshotId())).isNull();
+    assertThat(deletedFiles).isEmpty();

Review Comment:
   Could you assert the current snapshot survived here too? 
`testExpireSnapshotsWithoutCleanupWhenGarbageCollectionDisabled` does it 
(`assertThat(table.currentSnapshot())...`), and without it a regression that 
expired everything would still pass, since `deletedFiles` is empty either way.



##########
api/src/main/java/org/apache/iceberg/ExpireSnapshots.java:
##########
@@ -143,6 +143,10 @@ enum CleanupLevel {
    * <p>Consider {@link CleanupLevel#NONE} when data and metadata files may be 
more efficiently
    * removed using a distributed framework through the actions API.
    *
+   * <p>When the table property {@code gc.enabled} is false, any level other 
than {@link
+   * CleanupLevel#NONE} causes {@link #commit()} to fail: the expiration is 
not committed and no

Review Comment:
   Good to document the gc.enabled behavior on the level here. While you're in 
this file — the class-level javadoc up top still says manifest and data files 
"will be deleted" unconditionally, which isn't true anymore for the `NONE` 
path. Worth a line up there pointing at `cleanupLevel(CleanupLevel)` so the two 
don't contradict.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to