|
| 1 | +# ADR 0001: Event residue scrub and SQLite auto-vacuum reclamation |
| 2 | + |
| 3 | +- **Status:** Accepted |
| 4 | +- **Date:** 2026-09-04 |
| 5 | +- **Issue:** #524 (delivery: PR #537 → `dev`) |
| 6 | +- **Supersedes:** none |
| 7 | + |
| 8 | +## Context |
| 9 | + |
| 10 | +The durable event store had no reclamation semantics. `Event.remove(aggregateID)` ran only on |
| 11 | +explicit session removal and covered the session aggregate alone, and SQLite ran without |
| 12 | +`auto_vacuum`, so deleted rows never returned pages to the filesystem. Two failure shapes |
| 13 | +motivated the decision: |
| 14 | + |
| 15 | +- A crash between the session-row delete and any cleanup stranded durable event aggregates |
| 16 | + whose `SessionTable` and `WorkflowTable` read models were both gone. Replaying such an |
| 17 | + aggregate is impossible: the `WorkflowCreated` projector INSERT dies on the |
| 18 | + `workflow.session_id` foreign key once the session row is gone (pinned by |
| 19 | + `packages/opencode/test/dag/dag-replay-idempotency.test.ts`), so residue can neither be |
| 20 | + replayed nor re-materialized — it can only be deleted. |
| 21 | +- `database.ts` initialized WAL and pragmas after driver open, so an application-level |
| 22 | + `PRAGMA auto_vacuum` could not take effect: after WAL initialization the pragma silently |
| 23 | + yields NONE even on an empty database. |
| 24 | + |
| 25 | +The decision checkpoint was approved on 2026-09-04 with the scope locked below. |
| 26 | + |
| 27 | +## Decisions |
| 28 | + |
| 29 | +1. **Explicit session + dag scrub.** `Session.remove` captures every related dag aggregate ID |
| 30 | + before the `Deleted` publish (the projector's session-row delete FK-cascades the workflow |
| 31 | + rows inside the publish transaction, so a post-publish lookup would see nothing) and removes |
| 32 | + each dag event aggregate after the session aggregate — terminal workflows included. The |
| 33 | + per-dag scrub is soft-degrading (a failure is logged and the aggregate is left for the |
| 34 | + startup sweep) but preserves interruption (`Cause.hasInterrupts` re-raise, the |
| 35 | + `EventResidueSweep` sibling discipline). |
| 36 | +2. **Guarded default-on startup sweep.** `EventResidueSweep` runs one pass per process start, |
| 37 | + forked into the layer scope so it can neither block nor fail startup. Eligibility is the |
| 38 | + zero-live-read-model rule: an aggregate in `event_sequence` with neither a `session` nor a |
| 39 | + `workflow` row. Removal is a single atomic guarded `DELETE` that re-evaluates both |
| 40 | + NOT EXISTS guards inside the statement (no select-then-delete TOCTOU window), so an |
| 41 | + aggregate recreated concurrently survives. Wired into `AppLayer` and the HttpApiApp node |
| 42 | + graph, so every serving process sweeps; the pass is idempotent. |
| 43 | +3. **New databases: FULL before WAL.** Both SQLite drivers (`sqlite.bun.ts`, `sqlite.node.ts`) |
| 44 | + set `auto_vacuum=FULL` at the driver layer, before `journal_mode=WAL`, and only on a |
| 45 | + genuinely empty (0-page) file. An immediate SQLITE_BUSY from a second opener racing the |
| 46 | + first is tolerated: the pragma is a persistent header property and runs before any |
| 47 | + WAL/migration write, so the first-write winner sets FULL for the database. |
| 48 | +4. **Existing databases: explicit conversion only.** Legacy `auto_vacuum=NONE` databases are |
| 49 | + never converted at startup — startup is detect-only (a warning pointing at the command). The |
| 50 | + only conversion path is `opencode db vacuum --db <path>`: the target must be named |
| 51 | + explicitly and must already exist as a regular file (vacuum never creates a database), runs |
| 52 | + FULL → VACUUM → `wal_checkpoint(TRUNCATE)` outside any startup path, and fails nonzero |
| 53 | + unless the `PRAGMA auto_vacuum` readback is exactly FULL. Exclusive access is a hard |
| 54 | + requirement (a concurrent writer fails VACUUM with SQLITE_BUSY). |
| 55 | +5. **Archived-session retention: off and deferred.** No retention policy for archived sessions |
| 56 | + ships in this decision (Phase 3). |
| 57 | +6. **Active truncation: rejected.** Truncating active/retained session event history and event |
| 58 | + snapshot folding are rejected; incremental replay (`seq > after`, ascending) and sync |
| 59 | + cursors must keep observing unbroken per-aggregate histories. |
| 60 | +7. **`incremental_vacuum` is forbidden.** A disposable bun:sqlite prototype reproduced an |
| 61 | + exit-139 crash under the incremental mode; no code path may enable it. |
| 62 | + |
| 63 | +## Consequences and risks |
| 64 | + |
| 65 | +- Deleting events on legacy NONE databases still does not shrink the file until an operator |
| 66 | + runs the explicit conversion; disk usage grows until then. |
| 67 | +- `auto_vacuum=FULL` pays its known SQLite overhead (pointer-map pages, per-update mapping) on |
| 68 | + every new database in exchange for automatic page reclamation. |
| 69 | +- The sweep runs once per process start: residue created and abandoned within a single process |
| 70 | + lifetime waits for the next start. This is accepted because the shapes it targets are |
| 71 | + crash/in-flight zombies. |
| 72 | +- The conversion command requires exclusive access; the error guidance says to close running |
| 73 | + opencode processes and retry. |
| 74 | +- Replay and sync contracts are preserved by construction: only whole aggregates with no live |
| 75 | + read model are ever removed, and such aggregates are unreplayable anyway (FK death), so no |
| 76 | + consumer can observe the removal as a gap in a replayable history. |
| 77 | + |
| 78 | +## Alternatives considered |
| 79 | + |
| 80 | +- **Rely on replay instead of scrubbing** — rejected: a wiped dag aggregate whose session row |
| 81 | + is gone dies on the workflow foreign key during re-materialization, so replay cannot replace |
| 82 | + deletion. |
| 83 | +- **Silent startup conversion of legacy databases** — rejected: converting requires a blocking |
| 84 | + full VACUUM; startup stays non-blocking and detect-only. |
| 85 | +- **`PRAGMA incremental_vacuum`** — rejected (decision 7). |
| 86 | +- **A recurring background reaper** — rejected in favor of one idempotent guarded pass per |
| 87 | + process start; residue is crash-shaped, not steady-state throughput. |
| 88 | +- **Truncate or fold active event histories** — rejected (decision 6). |
| 89 | + |
| 90 | +## Rollout and rollback |
| 91 | + |
| 92 | +Rollout lands as ordinary PRs through `dev` per the release train; no operator action is |
| 93 | +required — new databases get FULL automatically, legacy databases keep working unchanged (with |
| 94 | +a detect-only warning), and the sweep is default-on. Rollback is removing the sweep from the |
| 95 | +app graphs and reverting the driver pragma: the sweep is additive and idempotent, and legacy |
| 96 | +databases were never written by any of this. A database created with FULL keeps its header |
| 97 | +mode; reverting one is itself an explicit operator VACUUM and is not automated. |
| 98 | + |
| 99 | +## Acceptance |
| 100 | + |
| 101 | +- Active/retained session replay is unchanged; only zero-live-read-model aggregates are |
| 102 | + removed (guarded delete re-checked inside the statement). |
| 103 | +- Cleanup failures never block the application path; interruption is preserved, not logged as |
| 104 | + failure. |
| 105 | +- Disposable-file tests demonstrate page reclamation and the new/existing database behavior; |
| 106 | + no startup-time full VACUUM exists. |
| 107 | +- `bun run test:dag-core`, focused event/session tests, package typecheck, and migration |
| 108 | + freshness checks pass in CI. |
| 109 | + |
| 110 | +## Non-goals |
| 111 | + |
| 112 | +- **No global bounded-retention claim.** Live and retained sessions keep their full event |
| 113 | + history indefinitely; this decision bounds nothing by age, size, or count. |
| 114 | +- **No tombstones, unarchive, or sync changes.** Offline deletion tombstones, unarchive |
| 115 | + semantics, and sync cursor/protocol changes stay out of scope. |
| 116 | +- **No authorization for #531 or live-database work.** This decision does not authorize running |
| 117 | + VACUUM or any cleanup against a live local database; the destructive operator procedure |
| 118 | + remains the human-only issue #531. |
0 commit comments