Cover byte reclamation: one guarded helper, file first, covers row last #154

Closed
opened 2026-08-22 08:39:02 +07:00 by sulthan · 1 comment
Owner

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

  • The helper deletes the covers row and the sharded file when no series row points at the address
  • It deletes neither when a second Series points at the same address
  • The test asserts the sharded path is actually gone, not just the row
  • An interrupted reclamation (row present, file absent) is findable by the unreferenced-covers query, and re-running finishes the job
  • A failed file removal leaves the covers row in place and logs, with no user-facing surface
  • The forced Cover replacement calls it for the stranded old address

Blocked by

  • #153 — its first caller.
## 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 - [ ] The helper deletes the covers row and the sharded file when no series row points at the address - [ ] It deletes **neither** when a second Series points at the same address - [ ] The test asserts the sharded path is actually gone, not just the row - [ ] An interrupted reclamation (row present, file absent) is findable by the unreferenced-covers query, and re-running finishes the job - [ ] A failed file removal leaves the covers row in place and logs, with no user-facing surface - [ ] The forced Cover replacement calls it for the stranded old address ## Blocked by - #153 — its first caller.
sulthan added the ready-for-agent label 2026-08-22 08:39:02 +07:00
sulthan self-assigned this 2026-08-22 09:26:36 +07:00
Author
Owner

Landed on spec-135 as a --no-ff merge of ticket/154-cover-byte-reclamation (a4ea80d).

Implemented: (*Store).ReclaimCover(address string) error beside 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 and DELETE FROM covers last, both parameterized. fs.ErrNotExist counts 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 previous in 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.

Landed on `spec-135` as a `--no-ff` merge of `ticket/154-cover-byte-reclamation` (a4ea80d). Implemented: `(*Store).ReclaimCover(address string) error` beside 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 and `DELETE FROM covers` last, both parameterized. `fs.ErrNotExist` counts 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 `previous` in 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.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#154