Skip to content

Orphaned blob GC: deleting the last referencing record leaves bytes on disk forever #64

Description

@cuibonobo

Problem

Attachment bytes are only ever removed by an explicit deleteAttachment(fileId) call. Normal app flows delete records — and nothing notices when the last record referencing a file goes away. The bytes (and the file's _attachment@1 metadata records) persist indefinitely. A second orphan source: putAttachment() stores bytes first, then creates the metadata record (packages/core/src/stack.ts:604–613) — a failure between the two leaves bytes with no record at all.

For a personal data stack whose pitch includes user ownership of a portable store, unbounded invisible growth of unreachable blobs is a real defect, not a nicety.

Decided direction

Explicit owner-invoked sweep — not automatic refcounting. Auto-delete on last dissociate/record-delete is racy without #46's transactions, couples every record write to blob lifecycle, and — decisively — breaks the recoverability model: #60 makes soft-deleted records restorable, and an undeleted record must find its attachments intact.

Which yields the load-bearing rule:

  1. Soft-deleted records count as references. A file is garbage only when no record — live or soft-deleted — references it via attachment association or file-ref field (file-ref field kind: content-held file references are invisible to the reference checks the spec says exist #63). Only hard deletion (or the record never existing) removes a reference. This is the same principle as Record-level write stays one bit — but everything it permits must be recoverable, and irreversible verbs get carved out #59/Undelete API: the spec promises soft-delete recovery, nothing implements it #60: nothing reachable from a recoverable state gets destroyed.
  2. A file's own _attachment@1 metadata records do not count as references (else nothing is ever garbage) — but recent ones defer collection: a grace period covers the legitimate upload→associate window the spec already acknowledges (§Attachments, access "between upload and record association"). Concretely: skip files whose newest metadata record is younger than the grace period; likewise skip bare bytes newer than it (the upload-crash orphan, once bytes are old enough to be clearly abandoned).
  3. API: Stack.collectAttachmentGarbage(opts?: { graceMs?: number, dryRun?: boolean }): Promise<{ deleted: FileId[], reclaimedBytes: number }> (naming open). Owner-only on ScopedStack. dryRun matters — the first thing anyone does with a GC is ask what it would delete.
  4. Deletion goes through the deleteAttachment path so the reference check runs once more per file at deletion time (belt and suspenders against races; Single-writer storage model: durability fixes for adapter-local + spec/README guidance on multi-app topology #45's single-writer rule plus Split SQLite support: native record-adapter-sqlite for Node, browser-only record-adapter-sqljs, and a StackTokenStore interface in core #46's transactions make the residual window negligible at this scale).
  5. Sweep must be exhaustive: cursor-walk all pages (Core code treats one query() page as the complete result set (grants, attachment metadata, uploader checks) #50's queryAllPages), and the blob adapter needs a listFiles()-shaped capability to find bare-bytes orphans — BlobAdapter currently has no enumeration; content-addressed disk storage can enumerate cheaply. Adapter contract addition, capability-flagged.
  6. Wire: optional owner-only POST /attachments/gc for served stacks; scheduling/automation is a server concern, out of scope for core.

Sequencing

Blocked by #63. GC's correctness is exactly "what counts as a reference"; running it while content-held references are invisible deletes files that are still in use. #63 must land first.

Work items

Refs #63 (blocking), #60 (soft-delete-counts rationale), #59 (recoverability model), #50 (exhaustive pagination), #46 (transactions), #45 (single-writer context), #52 (endpoint fixture).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions