namest504 opened a new issue, #18737:
URL: https://github.com/apache/iceberg/issues/18737

   ### Apache Iceberg version
   
   1.12.0 (latest release). Also reproduced on 1.10.0. Not reproducible on 
1.9.2.
   
   ### Query engine
   
   Other
   
   ### Please describe the bug
   
   When `expireSnapshots()` is used inside a `Transaction`, the expired 
manifest lists, manifests and data files are deleted as soon as the 
transaction-local `commit()` runs. Nothing has been committed to the catalog at 
that point. If the process dies, or `commitTransaction()` fails, the table 
still references snapshots whose files are gone.
   
   Before 1.10.0 the deletes were deferred. `BaseTransaction.expireSnapshots()` 
called `expire.deleteWith(enqueueDelete)` so the files were only removed by 
`commitTransaction()`. #218 added that callback ("keeps track of deletes 
instead of running them") and #9183 relies on it (the expired files "should be 
deleted when the transaction commits").
   
   #13631 replaced that method body with `appendUpdate(new 
RemoveSnapshots(transactionOps))`. `appendUpdate` only wires the delete 
callback for `SnapshotUpdate`:
   
   ```java
   // core/src/main/java/org/apache/iceberg/BaseTransaction.java
   if (update instanceof SnapshotUpdate) {
     ((SnapshotUpdate) update).deleteWith(enqueueDelete);
   }
   ```
   
   `RemoveSnapshots` implements `ExpireSnapshots`, not `SnapshotUpdate`, so 
`deleteFunc` stays `null` and `RemoveSnapshots.cleanExpiredSnapshots()` hands 
the files straight to `FileIO.deleteFile`. The existing test 
`testRetainNAvailableSnapshotsWithTransaction` only counts files after 
`commitTransaction()`, where both behaviours look the same.
   
   ### Reproduction
   
   Standalone program against the Maven artifacts (HadoopCatalog on local FS, 
real Parquet rows). `TxnExpireRepro.java`, `build.gradle` and `run-output.txt` 
are in this gist: 
https://gist.github.com/namest504/86f80badc49976a4b793f58cb9acd237. Run with 
`./gradlew run -PicebergVersion=1.12.0` (add the Gradle wrapper or use a local 
Gradle).
   
   Scenario A, the maintenance job is killed between the two commits:
   
   ```java
   Transaction txn = table.newTransaction();
   txn.updateProperties().set("maintenance.last-run", 
String.valueOf(cutoff)).commit();
   txn.expireSnapshots().expireOlderThan(cutoff).retainLast(1).commit();
   // JVM dies here, commitTransaction() never runs
   ```
   
   1.12.0 and 1.10.0:
   
   ```
   S1 in table metadata:        true
   S1 manifest list on disk:    false
   read VERSION AS OF S1:       FAILED NotFoundException: Failed to open input 
stream for file: .../metadata/snap-...avro
   read current snapshot:       OK, 200 rows
   ```
   
   1.9.2: manifest list still on disk, time travel to S1 returns 100 rows.
   
   Scenario B, another client tags S1 while the transaction is in flight. 
`commitTransaction()` fails on re-apply with `IllegalArgumentException: Cannot 
expire ... Still referenced by refs: [audit-2026-10]`. The table keeps S1 and 
the tag, but S1's manifest list is already deleted, so reading the tag fails 
with the same `NotFoundException`. On 1.9.2 the tag stays readable.
   
   A unit test shows the difference without any of that setup: after 
`removeSnapshots(tx.table()).expireOlderThan(t).retainLast(1).commit()` and 
before `tx.commitTransaction()`, the expired manifest lists must still exist 
and must be in `((BaseTransaction) tx).deletedFiles()`. That fails on main and 
passes with the change below.
   
   ### Scope
   
   Every release that contains #13631: 1.10.0, 1.10.1, 1.10.2, 1.11.0, 1.12.0 
and main. It affects snapshot expiration through the Java `Transaction` API. 
The built-in Spark expire action and the Flink maintenance operator call 
`table.expireSnapshots()` and do not use that path, but applications running on 
those engines can.
   
   With file cleanup enabled (the default) the files that go away are the ones 
the expiration would delete anyway, including manifest lists, unreferenced 
manifests and data files only reachable from the expired snapshots. The problem 
is that the catalog metadata still points at them, so time travel, tags, 
branches and rollback to those snapshots break, and the next `expireSnapshots` 
logs `NotFoundException` retries while reading the missing manifest lists.
   
   ### Fix
   
   Wire `ExpireSnapshots` in `appendUpdate` the same way as `SnapshotUpdate`:
   
   ```java
   if (update instanceof SnapshotUpdate) {
     ((SnapshotUpdate) update).deleteWith(enqueueDelete);
   } else if (update instanceof ExpireSnapshots) {
     ((ExpireSnapshots) update).deleteWith(enqueueDelete);
   }
   ```
   
   That restores the pre-1.10 behaviour. I have a PR ready with the change and 
a regression test in `TestRemoveSnapshots`, and can follow up with 1.10.x and 
1.11.x backports.
   
   One thing the restore does not cover, and that was already the case in 
1.9.x: if `commitTransaction()` fails for good with a `CleanableFailure`, 
`cleanUp()` deletes everything in `deletedFiles`, including the expired 
snapshots' files, even though those snapshots are still in the table. I think 
that deserves its own issue, since it needs a separate set that is only deleted 
after a successful commit.
   
   ### Willingness to contribute
   
   - [x] I can contribute a fix for this bug independently


-- 
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