Repository navigation
fix(ui-server): give the turn journal its own database file - #2843
Merged
Merged
Conversation
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
force-pushed
the
fix/2789-journal-database-044
branch
from
October 24, 2026 17:40
ae67c59 to
88688b3
Compare
schlessera
marked this pull request as ready for review
October 24, 2026 19:44
7 of 8 tasks
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]>
Merged
8 tasks done
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 asjournal. Every page in it is the journal's, so the 10 GiB cap now comes from that file'spage_count,freelist_countand its own WAL. No b-tree walk runs on any write; #2812's journal-scoped walk is gone.The move:
createUiDbattaches the file before migrations. It moves everyturn_journal_*table, with rows, indexes and triggers, before the first migration after063, and again after the last.*.journal.sql, creates its objects asjournal.<name>and records its receipt in the journal file. The runner refuses one that changes the UI schema, and a test holds every migration after063to this rule.Around the move:
journal.synchronous=FULL.replaySteeringEventsrestores asteering_changeevent whose UI-file commit was lost.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.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:
createJournalAccounting(db, uiDbPath)keeps its signature and attaches the file itself.{ schema: JOURNAL_SCHEMA, path: journalDatabasePath(config.dbPath) }on its own connection; the ledger's attach is then a no-op.attachentry.Decision record:
docs/decisions/journal-database.md.Proof
Head
88688b3c9on15a5a3b4f. Package suites, the pack smoke,check:prand both native proofs ran on the head. The mutation batch ran on552545b14. The head differs from it by the native-proof script fix and a rebase onto #2840, whoseturn-journal.tsqueries are unqualified journal SQL.bun run test packages/ui-server packages/ui-sdkon the head: 5010 pass, 1 fail. The failure ishttp-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 (on479a0eba8).bun scripts/check-packages.tson the head: every publishable package packed, installed and passed its probes.bun run check:pr --base origin/mainon the head: passed, 1163 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.Native hosted proof, real harness, under
flock …/native.lock. Only the proof command holds the lock; the threedd if=/dev/zero bs=1M count=1024 conv=fsyncwriters run outside it, started once the lock is free.HOSTED_ADMISSION_HEALTH_WITHIN_1_SECONDwith the bound unchanged, plus the schedule-publication test; the retained-index reproduction failed as expected; exit 0.ae67c59d3, one no-load run hit the known intermittentinvalid worker mount recordrefusal in the schedule-publication test (#2701), whose code this PR does not touch.bun run testof mine held at the same time. It was rerun alone.no such table: turn_journal_reservations. The proof script itself openedui.dbraw atscripts/native-proof/hosted-app.test.ts:325, and now attaches the journal file there. A search ofscripts/, every packagesrc/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.
JOURNAL_FREED_METADATA_PAGES_REMAIN_CHARGED(#2172), oracleJOURNAL_CHARGE_IGNORES_OTHER_COMPONENTSJOURNAL_WAL_DEBT_STAYS_RESERVEDJOURNAL_COMMIT_FULL_ON_JOURNAL_FILEtable … already existsJOURNAL_MOVE_KEEPS_INDEXESJOURNAL_VACUUM_DROPS_NONEXISTENT_PAGE_DEBT(#2172)JOURNAL_STARTUP_REPLAYS_STEERINGJOURNAL_STEERING_REPLAY_IDEMPOTENTOPERATIONAL_STATE_BOOT_REFUSAL(hardlinked journal layout)JOURNAL_GUARD_*inbox_snapshot_schemainbox_snapshot_schemano such table: journal.turn_journal_fixtureJOURNAL_MIGRATION_RECEIPT_IN_JOURNAL_FILEJOURNAL_LEDGER_ENFORCES_REFERENCESJOURNAL_ALIAS_SAME_FILEJOURNAL_EXPORT_REFUSES_PHYSICALJOURNAL_MIGRATION_STAYS_IN_JOURNAL_FILEJOURNAL_LEDGER_REFUSES_INSIDE_TRANSACTIONJOURNAL_WRITE_WALKS_NO_BTREEA migration that violates the convention (a probe
099_*.sqlalteringturn_journal_turns) failsUI_MIGRATION_TOUCHES_NO_JOURNAL_099_mutation_probe.sql.Two independent gpt-6-astra review rounds ran.
Checklist
docs/reflect the change, or nothing documented moved.docs/decisions/provider-history.md): the provider-history guard passes, or nothing on a turn's path changed.Anything left open
steering_changeis timestamped with its turn's end, because the journal does not keep the original observation time.roadblockevents are not replayed.attachentry atopenSqliteWriteris added by whichever PR lands second.🤖 Generated with Claude Code