Repository navigation
fix(ui-server): take the WAL checkpoint off the journal write path - #2812
Merged
Merged
Conversation
Every accounted journal write truncated the WAL and walked every b-tree in the database before it began. Under fsync load those waits blocked the hosted event loop for up to 600 ms during turn admission (#2416). A ledger now truncates once, when it starts. Later writes charge the WAL file as it stands, whoever wrote it, on top of the measurement's one-commit WAL headroom, and truncate and retry only when that would refuse admission, as every write used to. The measurement walks only the journal's own b-trees. A formerly owned page outside them stays charged while it can still be free, capped at SQLite's unallocated pages, so the charge never falls below the page-identity measurement. Connection-local triggers refuse a journal row changed outside an accounted write. A store over its cap still constructs, so recovery can release abandoned reservations. Commits stay synchronous=FULL. The server keeps each maintenance temp-budget ledger open, revalidating the path and its access on every use and reopening a replaced file. Its commits use NORMAL: a reservation binds live processes of one boot only. Refs #2789 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This was referenced Oct 24, 2026
Closed
schlessera
marked this pull request as ready for review
October 24, 2026 10:23
This was referenced Oct 24, 2026
Closed
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.
Refs #2789
This is a partial delivery of the option 1 ruling, so it uses
Refs. It does not meet the issue's fsync-load acceptance criterion; see "Anything left open". The issue is handed back withneeds: decision.What changed
A journal write no longer truncates the SQLite WAL before it begins. Every write used to run
wal_checkpoint(TRUNCATE); now a ledger truncates once, when it is constructed at server start. Later writes charge the whole WAL file as it stands, whoever wrote it, on top of the measurement's existing one-commit WAL headroom. A write truncates and retries only when that charge would refuse admission, as every write used to. The capacity measurement now walks only the journal's own b-trees (dbstat WHERE name = ?), not every b-tree in the database. A formerly owned page outside them stays charged while it can still be free, capped at SQLite's unallocated page count. The charge therefore never falls below #2172's page-identity measurement; it can only exceed it, and only when another component reuses a page the journal released. Connection-local TEMP triggers refuse aturn_journal_*row changed outside an accounted write (migration063adds thewritingflag they read). A store already over its cap still constructs, sorecover()can release abandoned reservations. Journal commits staysynchronous=FULL. The server keeps each maintenance temp-budget ledger open instead of opening, committing and closing it per reserve/release. It revalidates the path, inode and R/W access on every use and reopens a replaced file. Those commits useNORMAL: a reservation binds only live processes of one boot (alive()checks the boot id), so a power loss cannot strand a live one.Why this shape
Three shapes were built and measured; the reasons for each are below.
dbstat. Owned pages moved by Δ(page_count−freelist_count), running totals came from triggers. It measured best: no journal windows in two runs. An independent gpt-6-astra review then reproduced three soundness failures, and I confirmed them in source. F1: free-list deltas discharge journal pages that are still free when the journal reuses another component's pages; one probe lost 244 pages. F2:recordGovernanceEvent()runs inside journal transactions and deletes expired governance rows, which drives owned pages negative. F3: legacy conversion lost freed pages. Counts cannot tell page owners apart, so this shape cannot stay exact. It was dropped.measure()verbatim plus WAL charging. This is exact by construction. Under fsync load it was worse for the journal (4 and 3 windows ≥ 250 ms). The full-databasedbstatwalk itself waits on I/O when the page cache is under write pressure.A second review round on shape 2 found two more problems: a failed commit's WAL tail could escape tracked debt, and construction could refuse on capacity before recovery. Both are fixed here. Charging the whole current WAL replaced the debt bookkeeping, which removes that whole class: abandoned tails, other processes, resets,
journal_size_limit. The cost is that the journal may be charged for non-journal WAL frames. In steady state SQLite's 1000-frame autocheckpoint bounds those to about 4 MiB.Proof
Before/after, the #2416 method. The #2416 diagnostic patch (20 ms timer-drift probe,
bun --cpu-prof) ran on a scratch checkout only; it is not in this PR. The cell was native pi UID 1000, with threedd if=/dev/zero bs=1M count=1024 conv=fsyncwriters on the app database's filesystem, run under the fixture flock. The table shows windows ≥ 250 ms afterready, assigned by the dominant categorized CPU frames: count, then max.origin/main6492291* In each starred window, one sample out of 9 or 11 sat in the budget's procfs liveness read; native-output publication dominated. The final head differs from 8b5934bc7 only in charging arithmetic (whole WAL instead of tracked debt) and the deferral at construction; neither changes I/O.
The windows that remain hold 2–16 ms of CPU samples in 230–590 ms. The blocked time is unsampled, which is consistent with each
synchronous=FULLcommit's fsync: journal, Activity, and the native lease DB. A vote picks up the journal whenmeasure()samples land in the same window as its commit.#2172 accounting tests, unchanged:
packages/ui-server/tests/journal-accounting.test.tspassed 10/10 on every shape, including this head.New tests in
packages/ui-server/tests/journal-ledger.test.ts(7) and one inpackages/ui-sdk/tests/maintenance-temp-budget.test.ts. Failing-first againstorigin/mainsources:JOURNAL_WRITE_RUNS_NO_CHECKPOINT(main ran 12wal_checkpoint(TRUNCATE)for 12 writes),JOURNAL_GUARD_turn_journal_objects_INSERT,JOURNAL_COMMITS_ACCUMULATE_WAL,MAINTENANCE_LEDGER_HELD_OPEN(main closed the ledger 6 times).Mutation receipts. Each row breaks the guard, runs the named suite, and restores the guard; all fail as stated.
JOURNAL_GUARD_*finish()writes outsideaccounting.writeturn-journal.test.tscases,journal storage write outside capacity accounting(a write escaping accounting fails)JOURNAL_WRITE_RUNS_NO_CHECKPOINTJOURNAL_LARGE_WRITE_TRUNCATES_A_LARGE_WALJournalCapacityErrorin the "never refuses" testMAINTENANCE_LEDGER_HELD_OPENMAINTENANCE_REPLACED_LEDGER_REOPENEDMAINTENANCE_REVOKED_LEDGER_NOT_REUSEDJOURNAL_CHARGE_COVERS_RELEASED_PAGES_RELEASED(page-identity oracle)JOURNAL_OVER_CAP_STARTUP_RECOVERSSuites:
bun run test packages/ui-server packages/ui-sdkran 4847 tests: 4842 passed, 5 failed.inbox-runtime.test.ts"mounted internal poke" 30 s timeouts. They fail identically onorigin/main69151ca.session-start-reliability.test.ts"wire dispatch echoes the correct draft ids", an ordering race. On rerun it failed 1/3 on this branch and passed 6/6 on main. The test touches onlyWsHostand a fake backend, with no SQLite, journal or budget code.Typecheck, lint: clean. No package export or entry changed and no
bun:import was added, so the pack smoke is not required.Native hosted proof, real harness (no diagnostic patch), under
flock …/native.lock, headf13dccd68on basedc5d3c732:dd … conv=fsyncwriters: passed. All four cells passed, includingHOSTED_ADMISSION_HEALTH_WITHIN_1_SECONDwith the bound unchanged; the retained-index reproduction failed as expected; exit 0.invalid worker mount recordrefusal (#2701). That code is not touched here.HOSTED_ADMISSION_HEALTH_WITHIN_1_SECOND. One warm-phase probe hit the 1 s timeout and one ping took 1,136 ms: the tests: attribute recurring one-second native hosted health timeouts #2416 symptom, while four other workers' suites shared the host. That run had no instrumentation. Two instrumented runs of that cell on the same head passed (health max 253 and 360 ms); the only window over 250 ms was one Activity SQLite window. Claude's start-turn probe and spawn are still synchronous (#2790). The cause was not attributed and the failed run was not relabelled.bun run check:pr --base origin/mainonf13dccd68: passed. 624 tests across 23 files, "Local preflight passed."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
synchronous=FULLcommit is a blocking fsync on the event loop, whatever the accounting does. Options and a recommendation are on #2789, which now carriesneeds: decision.dbstatstill runs per write, scoped to the journal's own b-trees. The review showed that exact page accounting with no per-write walk cannot be done from counts.observe()'s++turn.sequence) predates this PR throughretain(). This PR adds the same replay for a WAL-forced truncation.synchronous=FULL, pi transcript writes, native output publication): #2807 (needs scoping).🤖 Generated with Claude Code