Repository navigation
fix(iceberg): reclaim the manifest files expired snapshots leave behind (#835) - #1107
Merged
Merged
Conversation
…nd (#835) expireSnapshots runs WithPostCommit(false) on purpose (#632: iceberg-go's post-commit hook would also delete Arc's DATA files, which the exporter must never be allowed to do), and nothing else deleted the manifest lists and manifests an expired snapshot leaves behind. A dropped transaction leaves the same residue, since iceberg-go writes its manifests before Commit. So a table's metadata directory grew with every commit for the life of the deployment, and the whole pile was copied into every backup. A rig with one live snapshot held fifteen .avro files. The reconciler now sweeps them, after the pass has committed, expired and published its discovery files. Reachability is computed from every metadata.json ON DISK, not from the current snapshot. Arc deliberately keeps older entry points a reader can resolve -- retain+1 v<N>.metadata.json copies and iceberg-go's own NNNNN-* log -- so everything they name has to survive. That set only ever shrinks, and both the things that shrink it have already run by the time the sweep looks, so a manifest the sweep spares stays spared and one it deletes can never become referenced again. Three rails bound what it can touch: - Only names ending .avro DIRECTLY in the table's metadata directory. Data files are .parquet and live outside that directory anyway; version-hint.text, the metadata files and Puffin statistics are not .avro, so this path cannot delete any of them whatever reachability says. - An age grace, read through storage.ObjectLister: one hour, or twice iceberg.reconcile_interval when that is longer. This covers a commit that wrote its manifests and then died before its metadata.json landed. It does NOT extend the window for a reader that just resolved an older metadata version -- the grace is keyed to a file's age, not to how long it has been unreachable -- and that race stays bounded as before by pruneOldVersionFiles keeping retain+1 copies. A backend with no ObjectLister reports no ages, so the sweep does not run at all. - Every failure fails closed. A listing error, an unreadable or unparsable metadata.json, or a manifest list that cannot be opened all leave the reachable set incomplete, and an incomplete set never authorises a delete: a deleted manifest is the only record of which data files a snapshot held and nothing regenerates it. This bounds the directory rather than emptying it: the retained metadata versions keep their manifests alive on purpose, so the steady state is on the order of retain_snapshots commits' worth of .avro instead of unbounded growth. iceberg.orphan_sweep_enabled (default true) turns it off. It is the one deleter in the exporter whose work nothing regenerates, so it gets a switch, and Arc warns at startup when it is off. The second half of #835 -- iceberg-go carrying forward manifests that hold only DELETED entries, so the live manifest list grows with the removal history -- is #1106. It is a planning cost rather than disk growth and the fix belongs upstream. Verified: every one of the nine sweep tests fails with the sweep made a no-op, including the five guard tests, which carry inverted controls so "the file survived" cannot pass against a dead sweep. gofmt, go vet, go build ./cmd/... ./internal/..., go test and -race on ./internal/iceberg and ./internal/config, and the new tests alone under -race -count=20. Live on a single node with iceberg.enabled: 15 .avro down to 10 (deleted 5, reachable 10, within_grace 0), exactly one sweep across six passes, metadata.json count and version-hint.text unchanged, every on-disk metadata version still resolving, DuckDB's iceberg_scan still reading the table, and a second aged run deleting nothing because the set had converged. With orphan_sweep_enabled=false: fifteen files untouched over six passes plus the startup warning.
This was referenced Oct 7, 2026
xe-nvdk
added a commit
that referenced
this pull request
Oct 7, 2026
@efegokdemir implemented the #835 orphan-manifest sweep independently in #923, opened three weeks before the fix that shipped (#1107) and not reviewed when that work started. Credited in the contributors list and in the #835 release-notes section, with a pointer to #1125 for the one check #923 carries that the shipped sweep does not.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #835 (first half; the second half is #1106).
The problem
expireSnapshotsrunsWithPostCommit(false)deliberately — #632: iceberg-go's post-commit hook would also delete Arc's data files, which the exporter must never be allowed to do. Nothing else deleted the manifest lists and manifests an expired snapshot leaves behind, and a dropped transaction leaves the same residue because iceberg-go writes its manifests beforeCommit. So a table'smetadata/directory grew with every commit for the life of the deployment and the whole pile was copied into every backup. A rig with one live snapshot held fifteen.avrofiles.The fix
The reconciler sweeps them after the pass has committed, expired and published its discovery files.
Reachability is computed from every
metadata.jsonon disk, not from the current snapshot: Arc deliberately keepsretain+1v<N>.metadata.jsoncopies and iceberg-go keeps its ownNNNNN-*log as entry points a directory reader can resolve. That set only ever shrinks, and both things that shrink it have already run by the time the sweep looks — so a manifest the sweep spares stays spared, and one it deletes can never become referenced again.Three rails bound what it can touch:
.avrodirectly in the table's metadata directory.parquet, and outside that directory anyway),version-hint.text, the metadata files and Puffin statistics are not deletable by this path whatever reachability saysstorage.ObjectLister— 1 h, or 2×iceberg.reconcile_intervalif longermetadata.jsonlanded. A backend with noObjectListerreports no ages, so the sweep does not run at allmetadata.json, or an unopenable manifest list ⇒ incomplete set ⇒ zero deletions. A deleted manifest is the only record of which data files a snapshot held, and nothing regenerates itThis bounds the directory rather than emptying it — the retained metadata versions keep their manifests alive on purpose, so the steady state is on the order of
retain_snapshotscommits' worth of.avroinstead of unbounded growth.iceberg.orphan_sweep_enabled(defaulttrue) turns it off, with a startup warning when it is. It is the one deleter in the exporter whose work nothing regenerates, so it gets a switch.What the grace does not do: it is keyed to a file's age, not to how long the file has been unreachable, so it does not widen the window for a reader that just resolved an older metadata version — that race stays bounded exactly as before, by
pruneOldVersionFileskeepingretain+1copies. An earlier draft of the release notes claimed otherwise; the deep review caught it and the claim is gone from the notes, the code comment and the design doc.Configuration matrix
iceberg.enabled=falsemain.go:3520)CreateTablebranchexporter.go:583, after expiry +pruneOldVersionFilescommittednon-nil;e.backendnil-checked at:906;e.loggerset inNewExporter:555tblnon-nil fromEnsureTable;writeVersionHintalready derefs it two lines laterscheduler.go:252-256returns first; the first post-restart pass is the reclaim opportunityorphan_sweep_enabled=false(non-default):906.avrountouched over 6 passes + startup warningretain_snapshots1 and 10 (default)iceberg.warehouse= subdirectory (non-default, the #534 shape)dirKeycomes fromparseVersionAndMetaDir, the helperpruneOldVersionFilesuses; matching is by basename, so no warehouse-vs-root arithmetic exists here. Tested, with a siblingwh-other/decoy for the mid-segment match that broke #534's first fixdirKey""/"."):921(#743)pruneOldVersionFilesObjectLister:911runPassreturns at the writer gatemain.go:3573-3578)metadata.json), which is why that "configure only ONE compaction-capable node" warning is load-bearing.avroset lags itsmetadata.jsonset stays unswept until reconciled, which the code comment and release notes both sayReview
One adversarial pass on the plan and one deep pass on the implementation. The adversarial pass earned its keep: it killed the original design, which used "unreachable on two consecutive sweeps" instead of a clock on the false premise that
storage.Backendhas no mtime (ObjectLister.LastModifiedexists on all three backends) — and that scheme could never have deleted anything on a quiet table, because the fingerprint gate means such a table reaches the exporter once per process lifetime and the confirming sweep never arrives. It also argued the config key back in: #534 was about path arithmetic that coincides at the default, which a bool cannot do, and the real argument is blast radius.The deep pass found no blockers on the mechanism (it probed the non-default warehouse and repeated aged sweeps empirically) and two real problems: the false reader-grace claim above, and that five of the nine sweep tests passed against a dead sweep. Both fixed.
Test plan
gofmt -l ./internal ./cmdempty,go vetclean,go build ./cmd/... ./internal/...go testandgo test -raceon./internal/icebergand./internal/config-race -count=20(one of them was order-dependent at first — snapshot ids are random, so "the firstsnap-*.avro" varied; fixed to pick a reachable one).avro→ 10 (deleted:5 failed:0 reachable:10 within_grace:0), exactly one sweep across six passes,metadata.jsoncount andversion-hint.textunchanged, every on-disk metadata version still resolving, DuckDB'siceberg_scanreading the table afterfalse: fifteen files untouched over six passes plus the startup warningOwed separately
The docs site needs
iceberg.orphan_sweep_enabledin the Iceberg configuration table (docs.basekick.net,arc/integrations/apache-iceberg), plus a line about the sweep in the Limitations section. Not in this repo.