Skip to content

fix(ui-server): give the turn journal its own database file - #2843

Merged
schlessera merged 1 commit into
mainfrom
fix/2789-journal-database-044
Oct 24, 2026
Merged

schlessera merged 1 commit into
mainfrom
fix/2789-journal-database-044

Conversation

@schlessera

@schlessera schlessera commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Closes #2789

What changed

The turn journal now keeps its tables in its own SQLite file, DB_PATH.journal.db. The file sits beside the UI database's physical file and is attached to every UI connection as journal. Every page in it is the journal's, so the 10 GiB cap now comes from that file's page_count, freelist_count and its own WAL. No b-tree walk runs on any write; #2812's journal-scoped walk is gone.

The move:

  • createUiDb attaches the file before migrations. It moves every turn_journal_* table, with rows, indexes and triggers, before the first migration after 063, and again after the last.
  • The copy writes only the journal file and the drop writes only the UI file. A crash between them leaves both copies, and the next start rebuilds the copy without firing a copied trigger before dropping.
  • A later migration that changes journal tables is named *.journal.sql, creates its objects as journal.<name> and records its receipt in the journal file. The runner refuses one that changes the UI schema, and a test holds every migration after 063 to this rule.

Around the move:

  • Journal commits set journal.synchronous=FULL.
  • On start, a ledger returns released pages to the file system inside its first admitted write (incremental auto-vacuum).
  • A ledger attaches the file and enforces foreign keys on any connection, such as ui-server: journal and Activity SQLite commits block the hosted event loop on fsync #2814's write worker. It refuses to start inside a transaction.
  • On start, replaySteeringEvents restores a steering_change event whose UI-file commit was lost.
  • The operational backup (brain-ui-inbox export) folds the journal tables back into its UI-database image, where earlier releases kept them, so its format is unchanged. A restore moves them out again on first start. Export also refuses the journal file, or the target of a link to it, as a destination.
  • The file is masked from workers, refused inside the brain like the UI database, and inventoried as retained file history.

Why this shape

This is the maintainer's option-A ruling on #2789. A design without a walk was built and broken inside the shared file (evidence on the issue): a commit's new pages cannot be known before it commits, and other tables' frees in the same transaction feed pages to the journal. In its own file, ownership is exact by construction.

The interface with #2814 is agreed with that branch:

Decision record: docs/decisions/journal-database.md.

Proof

Head 88688b3c9 on 15a5a3b4f. Package suites, the pack smoke, check:pr and both native proofs ran on the head. The mutation batch ran on 552545b14. The head differs from it by the native-proof script fix and a rebase onto #2840, whose turn-journal.ts queries are unqualified journal SQL.

  • bun run test packages/ui-server packages/ui-sdk on the head: 5010 pass, 1 fail. The failure is http-supported-mounts.test.ts "a dirty failed mount discards its resources before the next independent case": 6 timers pending after an injected mount failure. It ran 3/3 green alone (109/109 each). This change adds no timers. On the previous base the same suites passed 5005/5005.
  • packages/ui-react/tests/turn-journal-runtime.test.ts, whose fault-injection trigger is now qualified into the journal schema: 24/24 (on 479a0eba8).
  • Pack smoke bun scripts/check-packages.ts on the head: every publishable package packed, installed and passed its probes.
  • bun run check:pr --base origin/main on the head: passed, 1163 tests.
  • The CONTRACT: feat(storage): deliver owner inventory, recovery and capacity guidance #2172 tests, packages/ui-server/tests/journal-accounting.test.ts, are unchanged and pass 10/10. The VACUUM case passes because a ledger returns released pages when it starts.
  • The page-identity oracle walks both files and asserts three things: no journal b-tree is left in the UI database, the journal file holds only journal b-trees, and the charge covers every page the journal owns or released. Activity churn does not move the charge.

Native hosted proof, real harness, under flock …/native.lock. Only the proof command holds the lock; the three dd if=/dev/zero bs=1M count=1024 conv=fsync writers run outside it, started once the lock is free.

  • With the fsync load, on the head: passed. All four cells, including HOSTED_ADMISSION_HEALTH_WITHIN_1_SECOND with the bound unchanged, plus the schedule-publication test; the retained-index reproduction failed as expected; exit 0.
  • Without load, on the head: passed, same shape.
  • Earlier attempts that did not pass:
    • On ae67c59d3, one no-load run hit the known intermittent invalid worker mount record refusal in the schedule-publication test (#2701), whose code this PR does not touch.
    • On the head, one fsync-load attempt timed out (180 s) waiting for the workspace output lease, which a host bun run test of mine held at the same time. It was rerun alone.
  • The first attempts on this branch failed with no such table: turn_journal_reservations. The proof script itself opened ui.db raw at scripts/native-proof/hosted-app.test.ts:325, and now attaches the journal file there. A search of scripts/, every package src/ and every test fixture found no other raw UI connection that reads journal tables; the operational export attaches the file itself.

Mutations. Each row breaks one guard, runs the named suite and restores it. Every one fails.

Mutation Failing assertion
J1 free pages uncharged JOURNAL_FREED_METADATA_PAGES_REMAIN_CHARGED (#2172), oracle
J2 measure reads the UI file JOURNAL_CHARGE_IGNORES_OTHER_COMPONENTS
J3 WAL of the UI file JOURNAL_WAL_DEBT_STAYS_RESERVED
J4 journal commit not FULL JOURNAL_COMMIT_FULL_ON_JOURNAL_FILE
J5 move keeps UI copies legacy fixture: table … already exists
J6 rerun keeps the stale copy interrupted-copy test
J7 indexes left behind JOURNAL_MOVE_KEEPS_INDEXES
J8 ledger does not attach bare-connection test
J9 no page return at start JOURNAL_VACUUM_DROPS_NONEXISTENT_PAGE_DEBT (#2172)
J10 startup replay missing JOURNAL_STARTUP_REPLAYS_STEERING
J11 replay under another key JOURNAL_STEERING_REPLAY_IDEMPOTENT
J12 journal file not masked OPERATIONAL_STATE_BOOT_REFUSAL (hardlinked journal layout)
J13 no escape guard JOURNAL_GUARD_*
J14 backup without the journal export refuses: inbox_snapshot_schema
J15 recovery command sees no journal export refuses: inbox_snapshot_schema
J16 later migration before the move no such table: journal.turn_journal_fixture
J17 journal receipt in the UI file JOURNAL_MIGRATION_RECEIPT_IN_JOURNAL_FILE
J18 references unenforced JOURNAL_LEDGER_ENFORCES_REFERENCES
J19 alias names its own journal JOURNAL_ALIAS_SAME_FILE
J20 export overwrites a linked journal JOURNAL_EXPORT_REFUSES_PHYSICAL
J21 journal migration may change the UI file JOURNAL_MIGRATION_STAYS_IN_JOURNAL_FILE
J22 ledger starts inside a transaction JOURNAL_LEDGER_REFUSES_INSIDE_TRANSACTION
J23 measurement walks a b-tree JOURNAL_WRITE_WALKS_NO_BTREE

A migration that violates the convention (a probe 099_*.sql altering turn_journal_turns) fails UI_MIGRATION_TOUCHES_NO_JOURNAL_099_mutation_probe.sql.

Two independent gpt-6-astra review rounds ran.

  • Round 1 found these problems, each now fixed and tested:
    • the backup omitted the journal, leaving a restore unbootable;
    • future migrations could commit apart from their receipts;
    • a rerun fired copied triggers;
    • symlinked paths broke WAL accounting;
    • the startup vacuum ran outside admission;
    • a bare connection had no FK enforcement.
  • Round 2 found three more, also fixed and tested:
    • export could overwrite a journal file reached through a link;
    • unqualified creates in journal migrations;
    • construction inside a transaction.
  • Accepted and documented in the decision record:
    • every writer must be stopped for the upgrade;
    • a crash between the two files' commits can keep an event without its change (SQLite commits the UI file first).

Checklist

  • Focused local preflight/behavioral receipts and complete selected hosted proof passed; actual head/base/job checkout evidence is in Proof.
  • Acceptance criteria from the issue are all met, or the gap is named below.
  • The PR and completed/advanced issues have verified release milestones under docs/process/github.md.
  • A changeset is included, or this changes nothing a consumer can see.
  • No personal data anywhere in the diff — the leakage gate covers the whole tree.
  • The integration contract index or relevant component is updated in this same commit, or nothing in the machine surface moved. The backup format, CLI and HTTP shapes are unchanged.
  • Docs under docs/ reflect the change, or nothing documented moved.
  • Provider history stays append-only (docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.

Anything left open

🤖 Generated with Claude Code

@schlessera schlessera added this to the 0.44.0 milestone Oct 24, 2026
@schlessera schlessera added type: fix Something is broken and must behave the way it is documented. area: ui-server Hono app factory: turn coordinator, auth, session catalog, routes, cron. labels Oct 24, 2026
The journal's 10 GiB cap charges the SQLite pages it owns, the pages it
released, its WAL headroom, its objects and its reservations. Sharing the
UI database meant walking b-trees on every accounted write to tell the
journal's pages from everyone else's, and under fsync load that walk
waited on I/O on the event loop (#2416).

The journal's tables now live in `DB_PATH.journal.db`, beside the UI
database's physical file and attached to every UI connection as
`journal`. Every page in that file is the journal's, so the measurement
is its page count, its free list and its own WAL, read without walking.
Journal commits set FULL on the journal file. A ledger attaches the file
itself and enforces its references, so a write worker's own connection
needs no setup. On start a ledger returns released pages to the file
system in one admitted write.

createUiDb moves every turn_journal_* table, with its rows, indexes and
triggers, into the journal file before any migration after 063 and again
after the last. The copy writes only the journal file and the drop only
the UI file, so a crash between them leaves both copies, and the next
start rebuilds the copy without running a copied trigger. A later
migration that changes journal tables is named *.journal.sql, touches
nothing else and records its receipt in the journal file, so each change
commits with its receipt; a test holds every later migration to this.

A commit spanning both files is atomic per file only. On start the turn
journal records any steering_change event a journal steering change
lacks, rebuilt from the journal row under the same dedupe key. The
operational backup folds the journal's tables back into its UI database
image, where earlier releases kept them, so its format is unchanged and a
restore moves them out again. The file is masked from workers, refused
inside the brain like the UI database, and inventoried as retained file
history.

Closes #2789

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@schlessera
schlessera force-pushed the fix/2789-journal-database-044 branch from ae67c59 to 88688b3 Compare October 24, 2026 17:40
@schlessera
schlessera marked this pull request as ready for review October 24, 2026 19:44
@schlessera
schlessera merged commit c696f99 into main Oct 24, 2026
14 checks passed
schlessera added a commit that referenced this pull request Oct 24, 2026
Since #2814 the journal's startup recovery and steering replay run behind
`ready`. Two tests from #2843 assumed they finished during construction:
the steering replay assertion ran before the replay, and the SIGKILL
storage-removal children raced the parent fixture's startup write and
refused their first checkpoint as `journal_storage_unavailable`.

The decision record now names #2850 for the app-connection writers that
stay on the loop, and says that they also wait for a worker commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
schlessera added a commit that referenced this pull request Oct 24, 2026
…QLite off the event loop (#2830)

* CONTRACT: fix(ui-server): commit journal, Activity and native-lease SQLite off the event loop

Every turn-journal, Activity and native-lease write now commits on a
per-database write worker thread with its own connection at
synchronous=FULL. Calls run one at a time in submission order, and the
event loop awaits each commit instead of blocking in its fsync (#2814,
ruling 1 on #2789).

- db/write-worker.ts and write-worker-thread.ts: the writer, inline for
  in-memory databases. FULL is enforced before every commit on main and
  on attached files (for #2789's journal.db). Refusals keep their class.
  App-connection transactions take the write lock up front, so a worker
  commit cannot fail their snapshot upgrade with SQLITE_BUSY.
- brain/journal-store.ts: the journal's transaction bodies, run through
  the unchanged createJournalAccounting on the worker connection.
- activity/store-writes.ts: the off-loop Activity store and orderedWrites.
  A write inside the caller's transaction stays inline and atomic.
- brain/native-lease-store.ts: durable lease intents.
- Callers that need a commit's outcome await it: journal bindSession,
  writeOperation (now statements), Claude memory persistence, revert
  reservation, retained-version leases and streams; turn revert start;
  storage removal start/cancel. createTurnJournal exposes ready.
- StartTurnRequest.persistClaudeMemory may return a promise; the Claude
  backend awaits it (additive, docs/integration-contract/package-api.md).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ui-server): retry an app-connection BEGIN refused beside a committing write worker (#2814)

* fix(ui-server): app transactions and write-worker procedures take turns (#2814)

* docs: the turn lock in the write-worker decision record (#2814)

* docs: refresh code citations after merging main (#2814)

* fix(ui-server): app autocommit writes take their turn beside the write worker (#2814)

* refactor(ui-server): journalCheck builds refusal checks (#2814)

* CONTRACT: docs: journal and Activity write surfaces under ruling 1 on #2789 (#2814)

* fix(ui-server): release turn authority before awaiting its Activity writes (#2814)

* fix(ui-server): the write worker owns journal.db beside the UI database (#2814, #2789)

* test(ui-server): await journal startup before asserting on it (#2814)

Since #2814 the journal's startup recovery and steering replay run behind
`ready`. Two tests from #2843 assumed they finished during construction:
the steering replay assertion ran before the replay, and the SIGKILL
storage-removal children raced the parent fixture's startup write and
refused their first checkpoint as `journal_storage_unavailable`.

The decision record now names #2850 for the app-connection writers that
stay on the loop, and says that they also wait for a worker commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(ui-server): a queued turn waits for the previous turn's Activity writes (#2814)

The session slot awaits the recorder's off-loop writes before the next
queued turn starts. No test failed when that wait was removed: every
store write committed before the next turn could read it. The new case
holds the writes the way a slow worker commit would, and requires the
second turn to start only after they commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(ui-server): index outcomes commit on the write worker before the terminal (#2814, #2835)

#2851 records each deferred index outcome in Activity at the end of a
turn. Its tests ran on a synchronous in-memory store, so none reached the
write worker. A hosted session now runs the same flush on a file database
with the worker-backed store and reads the rows after the session returns.
An autonomous turn holds the flush's Activity write and requires the
terminal transaction to wait for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: ui-server Hono app factory: turn coordinator, auth, session catalog, routes, cron. type: fix Something is broken and must behave the way it is documented.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui-server: synchronous SQLite fsync commits stall the hosted event loop during turn admission

1 participant