5.2.7 Orphan-GC job — scheduled sweep of unlinked attachment rows past the safety window (blob + row), the blob-failure backstop
Estimate: 24m · Depends on: 5.2.1
The lifecycle backstop. Unlinked rows accumulate by design: create-modal uploads whose modal was cancelled, embeds removed from bodies (5.2.3 unlinks), issue deletions (5.2.1 SetNull), and best-effort blob deletes that failed (5.2.2). A real product does not leak storage forever (the stub's "workspace-scoped access" + finding #57 discipline applied to storage).
Job (lib/jobs/definitions/attachmentGc.ts, the 1.6 defineJob harness on a cron schedule like dailyHealthCheck): page through listOrphans({ olderThan: 7 days }) (the safety window — long enough that an in-flight create/edit never loses its upload; constant, admin-configurable is Epic-8), and per row delete the blob then the row (blob first — if the blob delete fails the row survives for the next pass; the inverse strands the blob unfindably). Cursor-bounded batches per run (e.g. 200) so a backlog never produces an unbounded run; idempotent (a re-run after partial failure converges); per-run summary logged through the JobRun ledger. NOT swept: linked rows, and orphans younger than the window. (Blobs with NO row — e.g. workspace-cascade deletions — are out of this job's reach; recorded as the known Epic-8 hardening extension: a prefix-listing sweep against the blob store.)
Acceptance criteria
- The scheduled job exists on the 1.6 harness (cron, workspace-nullable system scope), sweeps unlinked rows older than the window in bounded cursor batches, deleting blob-then-row; younger orphans and linked rows are never touched.
- A failed blob delete leaves the row for the next pass (verified via a stubbed blob error); re-runs are idempotent; the JobRun ledger records each run's summary (scanned/deleted counts).
- The 5.2.2 post-commit blob-failure case is demonstrably swept by the next GC pass (the backstop contract).
@inngest/testcoverage of the batch/window/failure paths;pnpm test:coverageholds the gate; the known no-row-blob limitation is documented in the job header + design-notes.
Context refs
lib/jobs/defineJob.ts+definitions/dailyHealthCheck.ts(1.6 — the scheduled system-job exemplar) + the JobRun/DLQ ledgerlib/blob/uploader.ts(the blob delete API) +attachmentRepository.listOrphans(5.2.1)- The lifecycle decisions (SetNull, best-effort-after-commit, 7-day window) in the Story 5.2 description