Skip to content

feat(attachments): §16 quiescent byte GC — orphaned-workspace purge#291

Open
Stvad wants to merge 5 commits into
masterfrom
claude/adoring-maxwell-nioh1g
Open

feat(attachments): §16 quiescent byte GC — orphaned-workspace purge#291
Stvad wants to merge 5 commits into
masterfrom
claude/adoring-maxwell-nioh1g

Conversation

@Stvad

@Stvad Stvad commented Jul 2, 2026

Copy link
Copy Markdown
Owner

What & why

Lands the deferred §16 reference-counted byte GC for the media-attachments plugin — the keystone that gives byteStore.purgeWorkspace its first live caller. Until now the OPFS byte store grew monotonically; nothing reclaimed revoked/left-workspace bytes.

v1 = "Branch A": a quiescent, grace-windowed sweep that purges the whole local OPFS byte prefix of a workspace the active user no longer has access to (revoke / leave / workspace delete). Design + full model: docs/media-attachments/byte-gc-design.md.

The model

  • Reference set = the local blocks table (live + soft-deleted media). Revoke physically drops a workspace's rows, so its prefix goes unreferenced → reclaimable; a soft-deleted tombstone keeps its hash → retained (undo-safe). row_events / the server's encrypted blocks_history are deliberately not counted — counting them would make revoke un-reclaimable, and the client can't read the server history anyway.
  • Membership signal = the local synced workspaces list (SELECT id FROM workspaces — revoke drops the row). A stored prefix absent from it is orphaned.
  • Grace window is load-bearing (not a nicety): it survives the checksum-wipe transient that can briefly empty the local DB — never a live zero-crossing. Markers persist across sessions so the clock keeps accruing.
  • Coordination: each purge runs under the same per-(user,workspace) down-lane lock the replicator holds, closing the put-vs-purge race documented on purgeWorkspace. Gated on isRemoteSyncActive() + hasSynced. Sole-copy guard skips a workspace that still holds un-uploaded bytes.
  • Not the reverted eager MediaRevocationPurger (b3ce6f7e): this observes the settled truth under the lock, not a transient membership signal, so it needs no epoch/retry machinery.

Deferred (designed in the doc, not built here)

Branch B (per-content-key selective reap — staged orphans / replaced media within a live workspace), remote-object GC (reference-permanent — accepted monotonic remote growth), and quota-pressure eviction. All ride the same content-refcount infrastructure.

Files

  • new mediaGc.ts (pure sweep + grace logic), assetGc.ts (app wiring + gating), gcMarkerStore.ts (durable grace markers), MediaGcSweeper.tsx (idle-scheduled user-scoped mount), plus tests for each.
  • changed byteStore.ts (+listWorkspaceIds), assetDownLane.ts (export downLaneLockName), index.ts (wire the mount), design.html (§16 status).

Verification

tsc clean; full eslint clean; 4210 tests pass (63 new across byteStore / gcMarkerStore / mediaGc / assetGc).

🤖 Generated with Claude Code


Generated by Claude Code

…purge)

Give byteStore.purgeWorkspace its first live caller: a quiescent, grace-windowed
reference-GC sweep that reclaims the local OPFS byte prefix of a workspace the
active user no longer has access to (revoke / leave / workspace delete).

The model (docs/media-attachments/byte-gc-design.md):
- Reference set = the local `blocks` table (live + soft-deleted media). Revoke
  physically drops a workspace's rows, so its prefix goes unreferenced ->
  reclaimable; a soft-deleted tombstone keeps its hash -> retained (undo-safe).
  row_events / server blocks_history are deliberately NOT counted (counting them
  would make revoke un-reclaimable, and the client can't read the encrypted
  server history).
- Branch A (built): a stored workspace prefix absent from the local synced
  `workspaces` list is orphaned; purge the whole prefix (no content-key
  derivation) behind a grace window + the down-lane lock, on a settled-synced
  remote session, with a sole-copy guard for un-uploaded bytes.
- The grace window is load-bearing: it survives the checksum-wipe transient that
  can briefly empty the local DB (never a live zero-crossing); markers persist
  across sessions so the clock keeps accruing.
- Branch B (per-content-key selective reap), remote-object GC, and quota eviction
  are designed in the doc and deferred (reference-permanent in v1).

Not the reverted eager MediaRevocationPurger (b3ce6f7): this observes the settled
truth under the lock, not a transient membership signal, so it needs no
epoch/retry machinery.

New: gcMarkerStore (durable grace markers), mediaGc (pure sweep + tests), assetGc
(app wiring + gating tests), MediaGcSweeper (idle-scheduled user-scoped mount),
byteStore.listWorkspaceIds. Full gate green (tsc + eslint + 4210 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdXcVVBv5yV7x8Ag97dafo
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

claude added 4 commits July 2, 2026 02:18
…ad records

A `failed` upload record's bytes were never uploaded (and there is no §9 recovery
actor yet), so they are an un-uploaded sole copy exactly like `staged`/`pending`.
The orphaned-workspace purge now defers on any of the three states, erring toward
retention over dropping a sole copy (the §16 posture). A stuck `failed` record
pins its workspace until it is cleared/recovered — an accepted, bounded leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdXcVVBv5yV7x8Ag97dafo
…test)

Round-1 /areview follow-ups (all nit-level; no defects were found):
- byteStore.purgeWorkspace: the caveat comment no longer says the §16 GC has
  "no caller today" — assetGc.runMediaGcSweep is now that caller and holds the lock.
- mediaGc header: reword the over-stated "never throws per workspace" — a purge
  throw ends the sweep early and the rest retry next sweep (retention-biased).
- MediaGcSweeper: note the first-sync trigger binds to the mount-time user, and
  why that's a latency-only detail (runMediaGcSweep re-reads the live user).
- assetGc.test: pin the purge LOCK NAME (downLaneLockName) — the mutual exclusion
  with the down-lane depends on it, and the old runSingleOwner mock ignored it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdXcVVBv5yV7x8Ag97dafo
… write-amp)

Full /areview fan-out (data-loss + races/lifecycle + quality) found NO
blocker/major/minor defect — the loop converged. Applied the nits worth doing:

- Correct the purge-safety comments (byteStore.purgeWorkspace, assetGc): the
  down-lane lock only excludes the DOWN-LANE put; capture/demand-resolve puts
  aren't under it. The real guard is that the GC only purges a workspace orphaned
  past its grace window (never accessible), which by construction isn't being
  captured into or viewed. Flagged that a future accessible-workspace reuse
  (Branch B) would need its own exclusion.
- Avoid write amplification: only clear an accessible workspace's grace marker
  when one actually exists (cheap readonly get), instead of a commit-durable
  readwrite delete per accessible workspace every sweep.
- Test cleanup: replace the 3-deep status ternary in the upload-store mock with a
  lookup.

Left for greenlight: extracting the coalesced deep-idle scheduler shared verbatim
by MediaGcSweeper and MediaDownLaneReplicator (a refactor of existing code; the
repo has a stated inline-mount bias).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdXcVVBv5yV7x8Ag97dafo
…heduler

MediaGcSweeper and MediaDownLaneReplicator hand-rolled the identical cancelled +
scheduled coalescing + scheduleDeepIdle machinery. Extract it to
`coalescedDeepIdlePass(work, warnLabel) -> { schedulePass, cancel }`; each mount
keeps its own work fn, its own triggers, and its own decision to fire an initial
pass (the down-lane does; the GC waits for first-sync settle) — the divergent
parts stay inline, matching the split laneArming.ts already draws for triggers.

Behavior-preserving: the existing MediaDownLaneReplicator.test.tsx (idle-defer +
coalesce-on-workspace-switch) stays green, and laneSchedule.test.ts pins the
helper's contract directly (defer, coalesce, re-arm, cancel-after-teardown,
log-only on a rejected pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GdXcVVBv5yV7x8Ag97dafo
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.

2 participants