Cover byte reclamation: one guarded helper, file first, covers row last #154
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Parent
Spec #135.
What to build
A forced Cover replacement strands the old address the moment the art changes, and a Cover is 50-500 KB on a swapless 1974 MiB VPS while a series row is 200 bytes. This ticket adds the one helper that reclaims those bytes, and calls it from the replacement path. Orphan removal is its second caller and lands separately.
The guard is the whole design: delete the covers row only when no series row points at the address, and remove the sharded file. Sharing is rare but real — measured 2026-08-18 against demonicscans.org, twelve Series gave twelve distinct addresses and twelve distinct digests, and a page for a Series that does not exist publishes no cover image at all, so there is no shared placeholder. Rare is not impossible, and removing one Series must never blank another's artwork.
Order: remove the file first, delete the covers row last. This inverts the obvious order deliberately, because the covers row is the handle. Keep it until last and an interrupted reclamation is discoverable in SQL — covers rows unreferenced by any series cover address — and re-running finishes the job. Delete the row first and the leftover file is named by nothing: no query lists it, and finding it means walking the sharded tree and diffing against the database. Dark garbage needing manual deletion, versus a repairable broken image.
A file-removal failure other than "already gone" leaves the row in place and logs. Keeping the handle is what makes a retry possible. That failure gets no UI surface — the ordering is what makes it findable, and there is no "unreclaimed covers" figure for a state that has never occurred and that the page could not fix anyway.
One hazard ordering cannot fix: a concurrent Forced Poll repointing a live Series at that address between the guard and the file removal. Narrow, and its outcome is the repairable case.
Rejected: a sweep over the whole covers table, owner-triggered or opportunistic. It runs a whole-table predicate — the one whose being wrong is unrecoverable — to fix a problem two known call sites prevent, and it needs a schedule this backend has no ticker for. Free consequence, not built here: if one is ever wanted, it is a single SQL query rather than a tree walk.
Acceptance criteria
Blocked by
Landed on
spec-135as a--no-ffmerge ofticket/154-cover-byte-reclamation(a4ea80d).Implemented:
(*Store).ReclaimCover(address string) errorbeside the other cover internals.""is a no-op; any series row still pointing at the address is a no-op (the guard - sharing is rare but real, and after ADR-0014 two Series serving byte-identical artwork share one row by construction); otherwise the sharded file goes first andDELETE FROM coverslast, both parameterized.fs.ErrNotExistcounts as success, so a re-run finishes an interrupted reclamation. A file-removal failure other than "already gone" returns the error with the covers row left in place; the store does not log, each caller does.The inverted order is the design and its reason is written down: the covers row is the handle. Keep it until last and an interrupted reclamation is discoverable in SQL as a covers row unreferenced by any series
cover_address, and re-running finishes the job. Delete the row first and the leftover file is named by nothing.Caller: exactly one line in the poller's stranded-address branch, where #153 left
previousin a named local. A reclamation failure is logged and the Poll carries on.ReplaceSeriesCover's doc comment no longer claims a stranded address keeps being served.No migration, no route, no template, no figure on any page, and no whole-table sweep - the rejected design stays rejected, with the one-line note that if one is ever wanted it is a single SQL query rather than a tree walk.
Tests assert the sharded path is actually gone rather than just the row: unreferenced address removed file-and-row; shared address removes neither and the artwork still reads back through
CoverByAddress; the interrupted case (row present, file absent) is found by the unreferenced-covers query and a re-run completes without error; and from the poller, a forced replacement reclaims the previous address while the identical-bytes no-op reclaims nothing.Signature matches the contract, so #155 calls it unchanged as its second caller.
The full backend suite is green on the merged base: 585 tests, 10 packages.
No concerns handed back.