Files
pad/internal/attachments
xarmian 08dfbdb318 fix(server): rowless-blob GC sweep — reclaim blobs no attachments row references (BUG-2406) (#1137)
* fix(server): rowless-blob GC sweep — reclaim blobs no attachments row references (BUG-2406)

Every attachment write path calls AttachmentStore.Put BEFORE inserting
the attachments row, so a failure (or crash) between the two leaves a
blob on disk that nothing references — and the row-driven orphan sweep,
which walks Store.OrphanedAttachments, can never see it. Disk that is
never returned; the upload handler's failure comment even claimed the
GC would reclaim it.

Fix: a rowless-blob sweep that runs after the row sweep on the same GC
tick. attachments.Lister is a new OPTIONAL backend capability
(ListBlobs → key/hash/size/mtime); FSStore implements it via one
WalkDir of the sharded tree with a base-name validHash gate (excludes
Put's dot-prefixed temp files and anything the store didn't write).
Backends without the capability are skipped with a once-per-process
notice. Candidate = blob whose content hash has ZERO rows in ANY state
(soft-deleted rows still own their bytes under the row sweep's
row-before-bytes claim protocol, BUG-2415) AND whose mtime predates the
same operator-configured GC grace the row sweep uses — a young rowless
blob is just an upload whose insert hasn't happened yet. Delete-time
guards run under inFlightHashesMu: the in-flight fence plus a
single-hash row RE-CHECK that closes the subtraction-to-delete TOCTOU
(the writer that marked, inserted, and released entirely inside the
gap). Cost: O(blobs) per tick, 24h cadence, never on a request path.
Also retro-reclaims blobs stranded by past row-sweep delete failures.

The wrong claim in handleUploadAttachment's failure path is corrected
to point at this sweep.

Tests: FSStore.ListBlobs impostor coverage; five sweep legs
(aged-rowless reclaimed with a row-sweep-can't-see-it counterfactual,
young kept, live/soft-deleted-row kept, in-flight kept then reclaimed
after release, hook-injected delete-time row kept) — mutation-verified:
removing the re-check, the age gate, or the in-flight fence each fails
its leg; the store-level subtraction contract is pinned separately.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V

* docs(store): state the any-row rule's real rationale per Codex review (round 1)

Codex flagged the thumbnail refusal-cleanup's grace-window protection as
inconsistent with the sweep comment's claim that deleting bytes under any
existing row violates the claim protocol. The cleanup (and the row sweep
itself) deliberately end a row's hash-protection when its own grace
expires — CountProtectingAttachmentsForHash documents exactly that, and
the row machinery may do it because its claim protocol coordinates row
and blob fates within a sweep. The overstatement was mine: the rowless
sweep's any-row rule is chosen because it holds no claim on any row and
has no such coordination, not because past-grace stranding is forbidden
to the machinery that does. Comment corrected; no behavior change on
either path.

Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
2026-08-17 09:03:44 -04:00
..