Orphan Series: is removal an owner action, and what happens to the Cover bytes #125

Closed
opened 2026-08-17 16:53:20 +07:00 by sulthan · 1 comment
Owner

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.Delete removes a Bookmark only, nothing
deletes a series row, 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 that
no 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:

  • Whether removal is an owner action at all, or whether orphans are only reported and left in
    place. A series row is small; a Cover file is not.
  • What removal does to the Cover. Covers are content-addressed and shared, so two Series may
    hold the same address. Deleting the file needs a reference check, and doing none leaks disk.
  • Whether the action is per-Series from /admin/series/{key}, a bulk sweep over the whole
    orphan filter, or both. A bulk delete over an unbounded set is not the same risk as one row.
  • Whether the bookmarks_series_fk (migrations/0002_series.sql:44-47, no cascade) makes the
    delete 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.
  • What removal does to the Cover of a Series whose Cover was replaced rather than removed. #120 deferred
    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 EXISTS guard over
    series.cover_address was the shape considered; deleting bytes a live Series still points at is unrepairable,
    since cover_address is not blank and neither fillBlankCover (poller.go:83) nor prefetchCover
    (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-gating. Cinder requires a .confirm-row for anything that pulls a row out of a list,
    and this is destructive in a way no other admin action is.
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.Delete` removes a Bookmark only, nothing deletes a `series` row, 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 that no 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: - Whether removal is an owner action at all, or whether orphans are only *reported* and left in place. A `series` row is small; a Cover file is not. - What removal does to the Cover. Covers are content-addressed and shared, so two Series may hold the same address. Deleting the file needs a reference check, and doing none leaks disk. - Whether the action is per-Series from `/admin/series/{key}`, a bulk sweep over the whole orphan filter, or both. A bulk delete over an unbounded set is not the same risk as one row. - Whether the `bookmarks_series_fk` (migrations/0002_series.sql:44-47, no cascade) makes the delete 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. - What removal does to the Cover of a Series whose Cover was **replaced** rather than removed. #120 deferred 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 EXISTS` guard over `series.cover_address` was the shape considered; deleting bytes a live Series still points at is unrepairable, since `cover_address` is not blank and neither `fillBlankCover` (poller.go:83) nor `prefetchCover` (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-gating. Cinder requires a `.confirm-row` for anything that pulls a row out of a list, and this is destructive in a way no other admin action is.
sulthan added the wayfinder:grilling label 2026-08-17 16:53:20 +07:00
sulthan self-assigned this 2026-08-20 20:06:02 +07:00
Author
Owner

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/series row (as #122's prototype drafted it: .ghost.danger opening an in-place .confirm-row on --danger-wash with Keep / Remove) and on /admin/series/{key}. Both render the control only at reader_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 the orphan filter — 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: Upsert re-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. No NOT EXISTS pre-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 bookmarks join, so an orphan can never be Polled: the press would write force_poll_at, no Lane would ever consume it, and the row would age a requested <n> ago marker for ever — firing #119's stuck-Lane signal on a Series that is not stuck. So a row at reader_count = 0 carries 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_address is the only reference that keeps bytes alive; series.cover holds 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 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 callsites prevent, and needs a schedule this backend has no time.Ticker for. 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, covers row second — and #120's "unrepairable" note is superseded. That note ("neither fillBlankCover (poller.go:83) nor prefetchCover (poller.go:106) heals a non-blank cover_address") described the routine paths only. #120 then added the forced replace path: ReplaceSeriesCover has no cover_address = '' predicate, putCover links the file only when absent (os.Link ignoring fs.ErrExist, store.go:686) and re-inserts ON 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 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: getCoverByAddress cannot 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 covers row. An unlink failure other than fs.ErrNotExist leaves the row in place and logs — keeping the handle is what makes a retry possible. For orphan removal the whole sequence is DELETE 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

  • #127: any new per-Series table keys (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 to bookmarks.
  • #122: amended by point 3 (no Check now on an orphan row) and point 6 (confirm copy). Row shape, tokens and confirm mechanics stand.
  • #120: its point-3 note that a non-blank cover_address is unrepairable is superseded by point 5 — its own forced replace path is the repair.
  • #124: unaffected, as it recorded. Finished and orphan are independent.
  • No glossary change needed: Orphan Series is already defined on branch docs/context-admin-terms. No ADR — the reclamation rule is one helper and reversible; #120 already commissions the ADR for byte-derived addresses.
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/series` row (as #122's prototype drafted it: `.ghost.danger` opening an in-place `.confirm-row` on `--danger-wash` with *Keep* / *Remove*) **and** on `/admin/series/{key}`. Both render the control only at `reader_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 the `orphan` filter — 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: `Upsert` re-`INSERT`s 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. No `NOT EXISTS` pre-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 `bookmarks` join, so an orphan can never be Polled: the press would write `force_poll_at`, no Lane would ever consume it, and the row would age a `requested <n> ago` marker for ever — firing #119's stuck-Lane signal on a Series that is not stuck. So a row at `reader_count = 0` carries *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_address` is the only reference that keeps bytes alive; `series.cover` holds 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 `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 callsites prevent, and needs a schedule this backend has no `time.Ticker` for. 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, `covers` row second — and #120's "unrepairable" note is superseded.** That note ("neither `fillBlankCover` (poller.go:83) nor `prefetchCover` (poller.go:106) heals a non-blank `cover_address`") described the routine paths only. #120 then added the forced replace path: `ReplaceSeriesCover` has no `cover_address = ''` predicate, `putCover` links the file only when absent (`os.Link` ignoring `fs.ErrExist`, store.go:686) and re-inserts `ON 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 `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: `getCoverByAddress` cannot 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 `covers` row. An unlink failure other than `fs.ErrNotExist` leaves the row in place and logs** — keeping the handle is what makes a retry possible. For orphan removal the whole sequence is `DELETE 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 - **#127**: any new per-Series table keys `(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 to `bookmarks`. - **#122**: amended by point 3 (no *Check now* on an orphan row) and point 6 (confirm copy). Row shape, tokens and confirm mechanics stand. - **#120**: its point-3 note that a non-blank `cover_address` is unrepairable is superseded by point 5 — its own forced replace path is the repair. - **#124**: unaffected, as it recorded. Finished and orphan are independent. - No glossary change needed: **Orphan Series** is already defined on branch `docs/context-admin-terms`. No ADR — the reclamation rule is one helper and reversible; #120 already commissions the ADR for byte-derived addresses.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sulthan/mangaBookmark#125