Orphan Series: is removal an owner action, and what happens to the Cover bytes #125
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?
Part of #114
Blocked by: #116
Question
Can the owner remove an orphan Series, and what happens to its Cover bytes?
Surfaced by #116, which made orphans visible:
store.Deleteremoves a Bookmark only, nothingdeletes a
seriesrow, and the Poll Lane skips a Series no Reader holds(
HAVING COUNT(*) > 0, backend/internal/store/store.go:981). So an orphan Series is a row thatno Reader can reach, no Poll ever touches, and no code path removes. It still owns a Cover file
under the store's cover directory (
putCover, store.go:657), addressed by content.To decide:
place. A
seriesrow is small; a Cover file is not.hold the same address. Deleting the file needs a reference check, and doing none leaks disk.
/admin/series/{key}, a bulk sweep over the wholeorphan filter, or both. A bulk delete over an unbounded set is not the same risk as one row.
bookmarks_series_fk(migrations/0002_series.sql:44-47, no cascade) makes thedelete safe by itself: the FK refuses the delete while any Bookmark exists, which is exactly
the orphan test, so the database may already be the guard.
this here rather than writing a second rule: a Forced Poll now replaces a Cover, and the previous bytes are
left on disk, so this ticket owns reclamation for both causes. One helper with a
NOT EXISTSguard overseries.cover_addresswas the shape considered; deleting bytes a live Series still points at is unrepairable,since
cover_addressis not blank and neitherfillBlankCover(poller.go:83) norprefetchCover(poller.go:106) heals the row. Measured 2026-08-18 on demonicscans: 12 Series, 12 distinct addresses, 12
distinct byte digests, and no shared placeholder image — sharing is rare, not impossible.
.confirm-rowfor anything that pulls a row out of a list,and this is destructive in a way no other admin action is.
Resolved. An orphan Series can be removed, by the owner, one at a time; its Cover bytes are reclaimed only when no other Series points at them.
1. Removal is an owner action, in two places, rendered only for orphans. Remove sits on the
/admin/seriesrow (as #122's prototype drafted it:.ghost.dangeropening an in-place.confirm-rowon--danger-washwith Keep / Remove) and on/admin/series/{key}. Both render the control only atreader_count = 0. Rejected: a permanently-disabled or always-present Remove that the database refuses — a dead control is one the owner learns to ignore, and the FK refusal (point 2) is for the race, not for everyday feedback. Rejected: a bulk sweep over theorphanfilter — an unbounded delete over rows the owner has not read individually is a different risk class, and the orphan set is short by construction (a Reader has to drop the last Bookmark).Not a one-way door, and the spec should say so:
Upsertre-INSERTs the series row from the next PUT (store.go:858-862) and Acquisition/the Poll refetch the Cover. Removing an orphan discards a row nothing reads, not history.2. The database is the guard. Plain
DELETE FROM series WHERE site = $1 AND series_id = $2;bookmarks_series_fk(migrations/0002_series.sql:44-47, deliberately no cascade) refuses it while any Bookmark exists, which is the definition of orphan. NoNOT EXISTSpre-check: it would add a read-then-write window and a second copy of the definition that can drift. A foreign-key violation is translated at the handler into "a Reader has bookmarked this Series again" and the row re-rendered with its new count.3. Check now leaves the orphan row — this amends #122. The prototype drew Check now beside Remove, but #119 settled that a Forced Poll never overrides the
bookmarksjoin, so an orphan can never be Polled: the press would writeforce_poll_at, no Lane would ever consume it, and the row would age arequested <n> agomarker for ever — firing #119's stuck-Lane signal on a Series that is not stuck. So a row atreader_count = 0carries Remove only. Rejected: reopening #119 so a Forced Poll overrides the join. A Series with no Reader has no consumer for the result.4. Cover reclamation: one helper, guarded, called at both causes.
DELETE FROM covers WHERE address = $1 AND NOT EXISTS (SELECT 1 FROM series WHERE cover_address = $1), then unlink the sharded path. Both callers already hold the address they orphaned — orphan removal, and #120's forced Cover replace, whose byte-derived addresses strand the old address the moment the art changes.series.cover_addressis the only reference that keeps bytes alive;series.coverholds the third-party source URL and references nothing. Sharing is rare but real (measured 2026-08-18: 12 demonicscans Series gave 12 distinct addresses and 12 distinct byte digests, no shared placeholder), so the guard is not optional.Rejected: a sweep over the whole
coverstable, owner-triggered or opportunistic. It runs a whole-table predicate — the one whose being wrong is unrecoverable — to fix a problem two known callsites prevent, and needs a schedule this backend has notime.Tickerfor. Rejected: never reclaiming. A Cover is 50-500 KB on a swapless 1974 MiB VPS; a series row is 200 bytes.5. Order: file first,
coversrow second — and #120's "unrepairable" note is superseded. That note ("neitherfillBlankCover(poller.go:83) norprefetchCover(poller.go:106) heals a non-blankcover_address") described the routine paths only. #120 then added the forced replace path:ReplaceSeriesCoverhas nocover_address = ''predicate,putCoverlinks the file only when absent (os.Linkignoringfs.ErrExist, store.go:686) and re-insertsON CONFLICT DO NOTHING. So a row whose file is gone is repaired by one Check now, whether the Site serves the same bytes (same address, file re-linked) or new ones (new address, row repointed).That inverts the ordering. The
coversrow is the handle: keep it until last and an interrupted reclamation is discoverable in SQL —coversrows unreferenced by anyseries.cover_address— and re-running finishes the job. Delete the row first and the leftover file is named by nothing:getCoverByAddresscannot reach it, no query lists it, and finding it means walking the sharded tree and diffing against the database. That is dark garbage needing manual deletion; the alternative is a repairable broken image.So: unlink, then delete the
coversrow. An unlink failure other thanfs.ErrNotExistleaves the row in place and logs — keeping the handle is what makes a retry possible. For orphan removal the whole sequence isDELETE FROM series→ guard-check → unlink →DELETE FROM covers, since the guard cannot pass while the series row still points at the address. Both orders self-heal if identical bytes ever return, and both share one hazard ordering cannot fix: a concurrent Forced Poll re-pointing a live Series at that address between guard and unlink. Narrow, and its outcome is the repairable case.Free consequence: a sweep, if ever wanted, is one SQL query over
covers, not a tree walk. This ticket does not build it.6. Confirm copy states only what is certain. #122's draft ("This removes the series and its cover bytes for every Reader") is wrong twice: an orphan has no Readers, and the bytes may survive the guard. Replaced with "Removes this series and its stored cover. No Reader has it bookmarked; one re-bookmarking it recreates the row." Byte deletion is not promised in the copy — the guard decides.
7. Sharing is not disclosed, and a failed unlink gets no surface. No "shared with N other series" line on either page: it costs a query per confirm-row render and buys a fact the owner cannot act on. No "unreclaimed covers: N" figure on the landing stats block, for a state that has never occurred and that the page cannot fix. A failed unlink is logged; point 5 makes it findable.
8. Responses. List row: the removed row's fragment plus a second out-of-band snippet re-rendering the
<N> series · <label>heading — the row and the count are one fact, and a heading still reading 1 after the last orphan is gone is a lie the owner must reload to clear. The filter<select>'s eight option counts (#122) stay stale until the next navigation: eight snippets per delete to keep one number honest is not worth it. Detail page: redirect to/admin/series?filter=orphan— the subject no longer exists, a refresh would 404, and the list is where the owner was headed.Consequences for the map
(site, series_id) REFERENCES series ON DELETE CASCADE, so removal stays a single statement and the FK-as-guard property in point 2 survives. A cascade to poll history must never become a cascade tobookmarks.cover_addressis unrepairable is superseded by point 5 — its own forced replace path is the repair.docs/context-admin-terms. No ADR — the reclamation rule is one helper and reversible; #120 already commissions the ADR for byte-derived addresses.