Skip to content

Storage lifecycle: close delete leaks, move sidecars into D1, authoritative sizes - #43

Merged
sardorml merged 3 commits into
mainfrom
fix/storage-leaks
Oct 1, 2026
Merged

sardorml merged 3 commits into
mainfrom
fix/storage-leaks

Conversation

@sardorml

@sardorml sardorml commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Closes out the storage-lifecycle audit: every R2 object and D1 row a recording or screenshot owns now has exactly one definition, one deletion path, and one accounting entry — and the numbers in D1 finally match the bytes in R2.

What was wrong (measured against prod)

  • The three recording delete paths (dashboard action, DELETE /api/r/[id], retention cron) each cleaned a different subset of a recording's objects: webcam streams, *.config.json and *.summary-chapters.json sidecars, and recording_activity rows leaked depending on which path ran.
  • Screenshots had no retention at all — idx_screenshots_gc existed for a sweep that was never written, and soft-deleted rows lived forever (4 ghost rows in prod).
  • recordings.size_bytes was the client's claimed value; finalize fetched R2's real size and discarded it. Webcam bytes were invisible to quota entirely.
  • The GC took one LIMIT 100 sample per day — a silent backlog cliff.
  • cron.ts referenced an R2 lifecycle safety-net rule that does not exist on the bucket.

Changes

  1. objectKeysFor / screenshotObjectKeysFor — single source of truth for the object closure; all delete paths and sweeps use it.
  2. recording_activity FK with ON DELETE CASCADE (migration 0008, table rebuild) — the constraint replaces all per-path manual cleanup.
  3. Sidecars move into the row (migration 0007: config_json, summary_chapters_json) — reads come off the row the page already holds (−2 R2 GETs per view), writes are scoped UPDATEs through the hydrate* validators, and the leak-prone object class is never created again. Legacy keys stay in the delete closure.
  4. Screenshot retention parity — same 30-days-from-last-view policy as recordings, soft-deleted rows purged after 24 h, /s/[id] not-found page discloses the policy like the recording one does.
  5. Authoritative sizes — objectSize (née headObject) persists R2's size at finalize; if the real size exceeds the claim and busts the cap, the object is deleted and finalize returns 413. totalStorageForUser now meters webcam bytes.
  6. Draining GC — all three sweeps page through their backlog (bounded at 50 × 100 rows/run) instead of sampling.

Rollout order

cd apps/web && npx wrangler d1 migrations apply captureflow --remote  # additive, safe under deployed worker
python3 scripts/backfill-recording-sidecars.py                        # copy legacy sidecar JSON into the columns
pnpm --filter @captureflow/web cf:deploy
python3 scripts/backfill-recording-sidecars.py                        # catch sidecars edited before cutover

Heads-up: first 04:00 UTC cron after deploy deletes the 4 screenshots currently >30 days idle (2 users, ~785 KB) — the agreed retention policy taking effect.

Verification

  • pnpm typecheck (9 projects), pnpm --filter @captureflow/web test (43/43, incl. new object-keys.test.ts), web build — all green.
  • Cron bundle checked with esbuild metafile: no @opennextjs/cloudflare / next/* reaches the wrangler-bundled worker.
  • Migration 0008 copy filters orphaned activity rows (prod count: 0) and preserves ids.

objectKeysFor / screenshotObjectKeysFor define every R2 object a
recording or screenshot owns, in one place, so no delete path has to
maintain its own (drifting) list. Pure extraction plus tests; kept free
of runtime imports so cron.ts can stay wrangler-bundleable.
finalize fetched R2's object size and threw it away, persisting the
client's claimed byte count instead — the number every quota check ran
on. headObject becomes objectSize; both finalize routes store what R2
reports and re-check the cap when the truth exceeds the claim, deleting
the over-cap object instead of keeping it. Webcam bytes now count
toward the storage total, which previously ignored them entirely.
Interlocking changes to how recording/screenshot data lives and dies:

- All three recording delete paths (dashboard action, DELETE /api/r/[id],
  retention sweep) now clear the full object closure; each previously
  stranded a different subset (webcam stream, config/summary sidecars).
- recording_activity gains a real FK with ON DELETE CASCADE (migration
  0008, table rebuild) — the constraint replaces per-path manual cleanup,
  two of three paths having forgotten it.
- The daily sweep gains the screenshot arm idx_screenshots_gc was built
  for: same 30-days-from-last-view policy as recordings, soft-deleted
  rows purged after 24h with their objects re-checked, and a not-found
  page disclosing the policy (recordings already had one).
- Both sweeps and the multipart GC drain their backlog in bounded pages
  instead of taking one LIMIT-100 sample per run, which silently falls
  behind forever once expiries outpace one batch per day.
- Recording config and AI summary/chapters move from R2 JSON sidecars
  into recordings.config_json / summary_chapters_json (migration 0007):
  reads come off the row the page already holds (two R2 GETs per view
  gone), writes are plain scoped UPDATEs, and the sidecar object class —
  the one the delete paths kept leaking — stops being created at all.
  Legacy sidecar keys stay in objectKeysFor so pre-migration recordings
  still delete clean; scripts/backfill-recording-sidecars.py copies
  existing sidecar payloads into the new columns.

Migrations are not yet applied remotely; run
  npx wrangler d1 migrations apply captureflow --remote
then the backfill script (and re-run it once after deploying).
@sardorml
sardorml marked this pull request as ready for review October 1, 2026 21:31
@sardorml
sardorml merged commit 17b900f into main Oct 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant