fix(table): fail commits with delete-file removals instead of replaying - #1784
Conversation
Delete-file removals (the deleteFilesToRemove argument of Transaction.ReplaceFiles, and the ref-keyed deletion-vector supersession performed by v3 merge-on-read deletes) are resolved against the snapshot the writer built on — removal identity is snapshot-relative. If such a commit hit a CAS conflict and went through doCommit's refresh-and-replay, a concurrent replacement could be inherited from the fresh base while the stale removal replays as a no-op (checkRemovedFiles deliberately treats a superseded entry as absent), stranding two live deletion vectors on one data file — which the v3 spec forbids — or silently discarding a peer's delete against a data file the replay removes. Snapshot producers now mark the transaction non-replayable when the staged snapshot carries delete-file removals (path-keyed delete files or ref-keyed deletion vectors); on ErrCommitFailed the commit fails immediately instead of entering refresh-and-replay. The caller must re-resolve the removal against the current snapshot and try again. Data-file removals stay replayable: they are path-keyed and the retry rebuild already fails terminally when the path is gone from the fresh base. Co-authored-by: Cursor <cursoragent@cursor.com>
Annotate the fail-fast error so callers can distinguish "refused to replay snapshot-relative removals" from an exhausted retry budget (errors.Is on ErrCommitFailed is preserved), clarify in the CommitNumRetriesKey doc that recovery requires a new transaction on a reloaded table, and pin that a no-replay conflict leaves the transaction retriable rather than latched as committed. Co-authored-by: Cursor <cursoragent@cursor.com>
The snapshot-relative-removal argument was repeated at four sites. Keep the full explanation at the producer flag site (where the decision is made and checkRemovedFiles lives) and the user-facing CommitNumRetriesKey doc; the commitOpts field, the doCommit early return, and the Transaction field now state the mechanism and point there. Co-authored-by: Cursor <cursoragent@cursor.com>
zeroshade
left a comment
There was a problem hiding this comment.
The two failure narratives — stranded double DVs from replayed supersession, and resurrected rows from a compaction replay removing a peer-referenced data file — are exactly why removal identity can't survive refresh-and-replay, and the fix draws the line at precisely the right place: delete-file removals and DV supersessions fail fast with an annotated ErrCommitFailed, while path-keyed data-file removals stay replayable under their existing terminal check. One flag, one early return, thorough docs at both the property and the flag site. This is how to handle a correctness hole in a retry loop.
This review was drafted with an AI-assisted tool and may contain mistakes; an Apache Iceberg Go maintainer has reviewed and confirmed the submission. See the contributing docs for what the project considers a maintainer review.
What
Commits whose staged snapshot carries delete-file removals — the
deleteFilesToRemoveargument ofTransaction.ReplaceFiles, and the deletion-vector supersession a v3 merge-on-read delete performs when the data file already carries a DV — no longer enterdoCommit's refresh-and-replay retry loop. On a CAS conflict they fail immediately withErrCommitFailed(annotated so it's distinguishable from an exhausted retry budget), and the caller re-resolves the removals against the current snapshot in a new transaction. The mechanism is one flag set by the snapshot producer, one early return in the retry loop, docs, and regression tests.Why
Removal identity is snapshot-relative: the removed entries were resolved against the snapshot the writer built on, so a replayed removal is semantically a different operation. Concretely: writer A supersedes DV₁ with its merged DV₂ (what any v3 MoR delete does on a file that already has a DV). Writer B supersedes DV₁ with DV₁′ and wins the CAS. A's replay inherits DV₁′ from the fresh base while A's stale removal of DV₁ replays as a no-op (
checkRemovedFilesdeliberately treats a superseded DV as absent — expunging by ref alone would discard the peer's newer deletes). The committed snapshot has two live DVs on one data file, which the v3 spec forbids. The same shape hits path-keyedReplaceFilesremovals today: a compaction replay can remove the data file a peer's freshly added delete file references, silently no-oping that delete and resurrecting its rows in the compacted replacement.Java does not have this bug because
SnapshotProducer.commit()re-runs the whole apply-validate-commit cycle afterrefresh(), re-resolving removals against the fresh base. Go's replay patches the already-built updates instead. The correct long-term fix is Java-style re-apply on retry; until that lands, removal-carrying commits must fail fast rather than replay incorrectly (once re-apply exists, this flag simply becomes unnecessary and can be removed).Behavior changes
Only for removal-carrying commits with
commit.retry.num-retries> 0, which today retry incorrectly. Such commits now fail on the first conflict even when the peer's change is unrelated and a replay would have been harmless — the library cannot tell the cases apart, and failing loudly beats corrupting silently. Callers of the removal APIs (compaction/maintenance) already handle commit failure by rebuilding against a fresh table. Data-file-only removals are unaffected and keep replaying: they are path-keyed, and the retry rebuild already fails terminally when the path is gone from the fresh base.Tests
TestMoRDeleteSupersedingDVFailsInsteadOfReplaying: the two-live-DV shape throughTransaction.Deleteon a v3 MoR table — exactly oneCommitTableattempt,ErrCommitFailed, exactly one live DV (the peer's) afterward, and the transaction stays retriable.TestReplaceFilesWithRemovalsFailsInsteadOfReplaying: the delete-loss shape throughReplaceFiles, where the peer's new position delete is invisible to the serializable added-data-files validator.TestReplaceFilesDataOnlyStillReplays: regression guard that a data-file-only replace still replays to success.Both removal tests fail on the pre-fix code — the corrupting replay commits successfully.
go test ./table/...andgolangci-lint runare clean.Made with Cursor