Skip to content

purge: reclaim what the deletes leave behind - #3

Merged
renshao merged 1 commit into
mainfrom
purge
Sep 8, 2026
Merged

purge: reclaim what the deletes leave behind#3
renshao merged 1 commit into
mainfrom
purge

Conversation

@renshao

@renshao renshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Implements #2.

Five stages, in an order where each releases work for the next: untagged manifests (opt-in), stale P memberships, blobs, abandoned uploads, empty names. Every stage is a bounded resumable step over one key range, with no state anywhere but the store, so a pass killed half way through is not lost work — it is work the next pass does.

The blob stage, and the one new key

"Is this blob referenced" is one seek over R <digest>. "Does any repository still hold it" is not askable at all: P is keyed <repo> <digest>, so a blob's memberships are scattered across the range in repository order. Nor can the stage be driven by watching memberships disappear — finish_repo_sweep drops a dead repository's whole P range with one DeletePrefix and never enumerates it.

So the stage walks L, and the gap it has to cover is the mount: commit_blob adds P and no R, so a blob mounted a moment ago looks exactly like one nothing has wanted for a year.

C <digest>purge's mark — closes it. Written the first time a blob is seen unreferenced; it has to stand for a whole grace period before the bytes go; and every path that creates a reference or a membership retracts it in the batch it was already writing. Retracting a membership retracts the mark too, and that rule is what makes a push safe without a lock: a manifest may only name a blob its repository holds, so a push that plans successfully saw either an R edge (which stops the collection outright) or a live P, whose existence and whose removal both reset the clock. A push has milliseconds between plan and apply; the collection is a grace period away.

A new prefix rather than a field on BlobRecord, so there is no SCHEMA_VERSION bump and no migration — an existing store simply has no marks and acquires them on the first pass. L and C stay out of the prefix-extractor domain, so the bloom filters are unchanged.

The one lock

BlobLocks: 256 mutexes striped by digest, on the pattern of the tag locks from 0e18360. Held from a blob's rename to its metadata commit, and by purge over its decision to reclaim — the only window where those two interleave. The state it prevents is an L record naming a file that is gone, which is the failure this registry treats as corruption rather than as garbage. Taken after the body is written and never around it, so it costs an fsync and a batch rather than a layer transfer.

Empty names need no lock: ids are handed out in order and never reused, so the pass keeps the id counter as it stood at the start of the previous pass and retires only names below it. A name interned during a pass is never retired by it, which closes the intern-then-write gap without touching the upload path. The first pass after a restart therefore retires nothing.

Retirement is narrower than "no manifests", too. A repository that ever had a tag has H/J events, and history outliving what it describes is a promise the README makes — so a name goes only when M, P, H, J and A are all empty beneath it and no upload holds it open.

Untagged manifests

Off by default, gated on --purge-untagged, because pulling by digest is ordinary and a digest-pinned deployment names manifests no tag does. Even on: an index's children, a referrer whose subject still exists, and anything pushed inside --purge-untagged-min-age are kept. The cosign leak cosign.rs documents closes here — deleting a subject makes its signature reclaimable through the ordinary path, which is what the synthesised F edges were for.

Surfaces

Schedule On by default beside the repository sweeper. --purge-interval 1h, --purge-grace 24h, --upload-ttl 24h, --no-purge stops the schedule and not the endpoint
API POST /api/v1/purge runs a pass and answers with what it did; ?dry-run=true counts and writes nothing; GET returns the last pass
UI A panel on the catalogue with the last pass and Purge now / Dry run, behind the same write auth as the delete button

No offline summ purge subcommand: RocksDB holds the store to one process, so a one-shot run would mean stopping the registry to clean it.

Known limit, stated in the module docs

The archived manifest copies are not reclaimed. They carry no metadata by design, so the pass never touches one — and never takes one either, after its manifest is deleted. Finding those means walking blobs/ and asking M about every file, which is the orphan-file scrub: its own opt-in pass, not this one.

Tests

  • summ-registry/tests/purge.rs — ten, over the passes and every guard, including the mark-clearing rule that keeps a push safe.
  • summ-server/tests/purge.rs — seven over the real stack, which is the only place "did the bytes actually leave the disk" can be asked.
  • summ-server/tests/concurrency.rs S8 — a pass running flat out against commits and pulls of the same blobs: a few hundred collections per run with no torn read, no 5xx and no deadlock between the tag and digest locks. Its doc says what it does not establish: the window BlobLocks closes is a hundred microseconds wide, and removing the lock does not reliably fail this test.

cargo fmt, cargo clippy --workspace --all-targets -D warnings and cargo test --workspace are clean. Verified by hand against a running summ serve: push, delete, sweep, two passes, bytes gone from the disk and the counts on GET /api/v1/purge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01139cg98Xh2bzaqd6xB92aR

Nothing on the request path frees bytes. A manifest delete retracts edges
and a repository delete releases the name, but whether one repository was a
layer's last user is a question about the whole store. A background pass
answers it, on the repository sweeper's shape: an interval tick, a bounded
resumable step per key range, and no state anywhere but the store.

Five stages, in an order where each releases work for the next: untagged
manifests (opt-in), stale memberships, blobs, abandoned uploads, and empty
names.

Blobs need a clock. "Is this blob referenced" is one seek, but "does any
repository still hold it" is not askable in this schema - `P` is keyed
<repo> <digest>, so one blob's memberships are scattered across the range.
A mount makes the gap visible: it writes `P` and no `R`, so a layer mounted
a moment ago looks exactly like one nothing has wanted for a year. The new
`C <digest>` -> BlobMark records when the pass first saw a blob with no `R`
edge. The mark has to stand for a whole grace period before the bytes go,
and every path that creates a reference or a membership retracts it in the
batch it was already writing - which is a blind delete precisely because it
is its own key rather than a field on BlobRecord.

Metadata commits first and the bytes are removed after, the mirror of the
write path: an `L` with no file is a pull that fails, while a file with no
`L` is inert. The one lock is a digest's, striped 256 ways, held from a
blob's rename to its metadata commit and by the pass over its decision to
reclaim, which is the only window where those two can interleave.

Archive manifest copies are not reclaimed: they carry no `L` by design and
this pass walks `L`. Collecting them needs an orphan-file scrub, which does
not exist yet, so the docs say that rather than implying some later pass
picks them up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E1BhkZmass74aoBLJcHAcW
@renshao
renshao merged commit 9249920 into main Sep 8, 2026
1 check passed
@renshao
renshao deleted the purge branch September 8, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant