Skip to content

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

Closed

Description

@schlessera

Context

Attribution for #2416 (HOSTED_ADMISSION_HEALTH_WITHIN_1_SECOND) found two classes of synchronous work that block the hosted server's event loop during turn admission and settlement. While either runs, /api/health and WebSocket pings wait. The class covered by this issue is synchronous SQLite commits that wait on fsync, on the event loop. (The other class, synchronous worker subprocesses, is moved off the loop by the PR that Refs #2416.)

The method: the native hosted proof's app child ran under bun --cpu-prof with a 20 ms timer-drift probe. Each lag window of at least 100 ms was attributed to the categorized frames sampled inside it. The same pi UID 1000 cell ran with no load, under CPU quotas (1, 0.5), on pinned contended cores, and with three dd … conv=fsync writers on the same filesystem as the app database.

Condition Journal SQLite (journal-accounting write) Maintenance temp budget DB Activity SQLite
fsync load (3 writers) 4,178 ms, 14 windows, max 604 ms 1,966 ms, 7 windows, max 478 ms 1,578 ms, 7 windows, max 552 ms
1 CPU quota 1,364 ms, 6 windows, max 274 ms — 548 ms, 3 windows, max 239 ms
0.5 CPU quota 904 ms, 6 windows, max 201 ms — 723 ms, 4 windows, max 213 ms

On this host a 4 KiB fsync takes at most 25 ms when idle and up to 850 ms under the same write load. Lag windows in these frames held little CPU (for example 5 ms of samples in a 332 ms window), which is consistent with waiting in fsync.

The retained native failures put about half the real 1–1.5 s stalls in this class:

  • a pi UID 1000 run whose journal.finish took 1,687 ms, with a 1,489 ms gap in WebSocket pongs;
  • a pi UID 0 run with a 1,380 ms gap during journal.begin;
  • the pi UID 0 retained-index control (the tests: attribute recurring one-second native hosted health timeouts #2416 reopen evidence), with a 1,315 ms gap right after the automatic index started. Its maintenance worker preparation opens the temp budget DB.

The other half were worker starts, which the #2416 PR addresses.

Where the waits are:

  • (const write = <T>, packages/ui-server/src/brain/journal-accounting.ts:57). Every journal write sets PRAGMA synchronous = FULL (line 61) and runs PRAGMA wal_checkpoint(TRUNCATE) (line 66). It then runs a full dbstat scan (function measure(), line 30, scan at line 40) at least twice inside an immediate transaction. Turn metadata updates such as (bindSession(turnId, packages/ui-server/src/brain/turn-journal.ts:357) and bindModel go through it, as do begin (line 323) and finish (line 399).
  • (export function reserveMaintenanceTemp, packages/ui-sdk/src/server/maintenance-temp-budget.ts:136) and its release() (line 157). Each one opens the shared budget database next to the UI database (function open(, line 79), commits an immediate transaction and closes it. Every maintenance worker does this twice.
  • Activity lifecycle writes (writeLifecycle(record), packages/ui-server/src/activity/store.ts:654).

Scope

Decide, then implement, one of:

  1. Accounting redesign. Keep the journal's capacity accounting (CONTRACT: feat(storage): deliver owner inventory, recovery and capacity guidance #2172) exact while taking the checkpoint, FULL sync and dbstat scans off the per-write path. For example, account for metadata-only updates without a checkpoint, or batch accounting per observation window. The maintenance budget ledger should hold its database open, or batch, rather than open, commit and close per reservation.
  2. SQLite off the main thread. Run the journal, activity and budget writers in a worker thread or process. The server's event loop then awaits commits instead of blocking on them, keeping the same transaction order and refusal semantics.

Either option keeps today's durability and capacity guarantees. The ruling goes on this issue before implementation.

Out of scope

Acceptance criteria

  • A maintainer ruling selects option 1 or 2 (or another shape), recorded here.
  • Under the same fsync-load condition (three dd … conv=fsync writers on the app database's filesystem), the native pi UID 1000 cell shows no event-loop lag window over 250 ms attributed to journal, budget or activity SQLite. This uses the same timer-drift probe and CPU-profile attribution, and the before and after receipts go in the PR.
  • Journal capacity accounting stays exact: the existing CONTRACT: feat(storage): deliver owner inventory, recovery and capacity guidance #2172 accounting tests pass unchanged, and a new test fails if a write escapes accounting.
  • The native hosted proof passes HOSTED_ADMISSION_HEALTH_WITHIN_1_SECOND with the bound unchanged, under the flock, at least twice.

Verification

bun run test packages/ui-server packages/ui-sdk, plus the native hosted proof under the exclusive fixture lock, with and without the fsync-load condition. The attribution method is described in the #2416 attribution comment.

Contract impact

Option 1 may touch journal capacity accounting, which is covered by the storage contract component. That needs a check against docs/integration-contract.md before implementation. Option 2 has no wire or CLI contract impact.


2026-10-24: Option 1 is partly delivered in draft #2812 (Refs). The fsync-load acceptance criterion is still unmet: the remaining windows are the synchronous=FULL commit fsync itself. needs: decision on how that commit leaves the event loop; the options are in #2789 (comment).

2026-10-24: the maintainer rulings change this issue's scope. (1) The commit fsync leaves the event loop under #2814, which now carries the fsync-load lag criterion. (2) Here: no per-write dbstat walk at all, not even scoped to journal b-trees; journal capacity accounting must keep #2172's page-identity guarantee, proven by the page-identity oracle test and a mutation table, or the issue must show with evidence that this is impossible. #2812 is the first step (Refs); the design without a walk follows in its own PR.

2026-10-24: ruling 2 found no sound walk-free design in the shared UI database; evidence and options (dedicated journal database, a documented capacity-semantics change, or keeping the journal-scoped walk) are in #2789 (comment). needs: decision.

Activity

  1. added this to the 0.44.0 milestone on Oct 24, 2026
  2. added
    type: fixSomething is broken and must behave the way it is documented.
    area: ui-sdkWire protocol, runtime schemas, the server and client SDK surfaces.
    area: ui-serverHono app factory: turn coordinator, auth, session catalog, routes, cron.
    priority: p1Scheduled into the current or next milestone.
    needs: decisionBlocked on a maintainer or designer ruling, not on code.
    on Oct 24, 2026
  3. schlessera commented on Oct 24, 2026

    @schlessera
    OwnerAuthor

    Maintainer ruling (2026-10-24): option 1, accounting redesign. Keep the journal capacity accounting (#2172) exact while taking the wal_checkpoint(TRUNCATE), synchronous=FULL and dbstat scans off the per-write path, for example by accounting metadata-only updates without a checkpoint or batching per observation window. The maintenance budget ledger should hold its database open rather than opening, committing and closing per reserve/release. Durability and capacity guarantees stay as they are. Check the storage contract component in docs/integration-contract.md before implementing and use CONTRACT: for any surface change.

  4. added
    agent-readyContext and acceptance criteria are complete enough for an agent to take this unattended.
    and removed
    needs: decisionBlocked on a maintainer or designer ruling, not on code.
    on Oct 24, 2026
  5. self-assigned this
    on Oct 24, 2026
  6. schlessera commented on Oct 24, 2026

    @schlessera
    OwnerAuthor

    Claim bk044-2789-1792825765: implementing the option 1 accounting redesign on branch fix/2789-sqlite-accounting-off-write-path-044.

  7. schlessera commented on Oct 24, 2026

    @schlessera
    OwnerAuthor

    Handoff (2026-10-24): option 1 is implemented as far as it goes. The fsync-load criterion needs a decision.

    Draft PR: #2812 (Refs).

    What it delivers. A journal write no longer runs wal_checkpoint(TRUNCATE). A ledger truncates once at construction, then charges the WAL file as it stands and truncates only when that would refuse admission. The measurement walks only the journal's own b-trees. The charge never falls below #2172's page-identity measurement; a page-identity oracle test and a mutation table back that. The #2172 tests pass unchanged. A connection-local guard refuses journal rows changed outside accounting. Commits stay synchronous=FULL. The maintenance budget ledger stays open between reservations; its four windows ≥ 250 ms before the change dropped to zero or one weakly attributed window per run.

    What it does not deliver. In each run, the native pi UID 1000 cell, loaded with three dd … conv=fsync writers and measured with the #2416 method, still shows 2–3 windows ≥ 250 ms for the journal and 2–3 for Activity. I measured three accounting shapes, two runs each; the full table is in the PR.

    • Counts-only, no per-write dbstat: 0 journal windows, but unsound. Independent review reproduced journal pages being discharged while still free, governance-expiry deletes inside journal transactions driving owned pages negative, and lost conversion pages.
    • CONTRACT: feat(storage): deliver owner inventory, recovery and capacity guidance #2172's measurement verbatim, no per-write checkpoint: 3–4 journal windows, worse than before. The full-database dbstat walk itself waits on I/O under this load.
    • Journal-only walk (the PR): 2 journal windows per run.

    The remaining windows hold 2–16 ms of CPU samples in 230–590 ms. The blocked time is unsampled, consistent with each synchronous=FULL commit's fsync. That holds for the journal, for Activity (writeLifecycle, packages/ui-server/src/activity/store.ts:654), and for the native lease DB. So the criterion fails on the durable commit itself, not on accounting. Option 1 cannot remove that without changing durability.

    Question for the maintainer: how should the commit fsync leave the event loop?

    1. SQLite commits off the loop for the journal and Activity writers (option 2's shape, narrowed to the commit path). This keeps FULL, transaction order and refusal semantics. It is the only option that meets the criterion with durability unchanged. Cost: createTurnJournal and the Activity store expose synchronous APIs today, so their callers become asynchronous.
    2. Activity commits at synchronous=NORMAL, the journal stays FULL. Activity rows (lifecycle, spans, cost rollups and budget settlement) could lose their last commits on an OS crash or power loss; a process crash loses nothing. The journal's commit fsync stays on the loop, so its windows likely remain.
    3. Amend the criterion to attribute only accounting work, accepting each durable commit's fsync on the loop. HOSTED_ADMISSION_HEALTH_WITHIN_1_SECOND itself is unchanged and passes (receipts in the PR).

    Recommendation (not a ruling): 1, as its own issue. The PR can merge first as an independent step: it removes the per-write checkpoint and full-database walk, and the budget open/close.

    A per-write dbstat walk remains, scoped to journal b-trees. The ruling asked for those scans off the per-write path too; counts alone cannot keep the page-identity rule (above). Whether a journal-scoped walk is acceptable is part of this decision.

    Next action: a maintainer ruling on 1–3. Resume from the PR's branch. Completion evidence: the same before/after runs in the PR body, with no window ≥ 250 ms attributed to journal, budget or Activity SQLite.

  8. added
    needs: decisionBlocked on a maintainer or designer ruling, not on code.
    and removed
    agent-readyContext and acceptance criteria are complete enough for an agent to take this unattended.
    on Oct 24, 2026
  9. removed their assignment
    on Oct 24, 2026
  10. 3 remaining items

  11. self-assigned this
    on Oct 24, 2026
  12. schlessera commented on Oct 24, 2026

    @schlessera
    OwnerAuthor

    Claim bk044-2789-1792835940: ruling 2 (accounting without a per-write walk) on a follow-up branch fix/2789-walkless-journal-ledger-044 stacked on #2812. Ruling 1 is filed as #2814.

  13. schlessera commented on Oct 24, 2026

    @schlessera
    OwnerAuthor

    Ruling 2 report (2026-10-24): I found no sound accounting design without a per-write walk inside the current single-database layout. Evidence and options follow.

    I built the closest design and pushed it as evidence on branch fix/2789-walkless-journal-ledger-044 (e58107654, stacked on #2812). It is not proposed for merge. On an ordinary write it walks no b-tree:

    An independent gpt-6-astra review then broke it; I reproduced the central case myself:

    1. A commit's newly owned pages cannot be bounded before it commits. bun:sqlite exposes no per-transaction dirty-page or allocation count: no sqlite3_db_status, no wal_hook, and a commit's frames exist only after it commits. So admission can only bound the pages a commit will own. The bound must include pages freed earlier in the same transaction by other tables, and those frees are invisible:
      • On the branch, a foreign INSERT OR REPLACE inside an accounted write freed 97 pages. REPLACE's implicit delete bypasses DELETE triggers unless recursive_triggers is on, as SQLite documents.
      • The journal statement after it reused 88 of those pages in the same transaction. None were in the pre-charge (free list 0 at start, no growth).
      • The reviewer also showed plain INSERT balancing in a foreign WITHOUT ROWID b-tree freeing a page that the journal then reused.
      • Governance inserts must stay atomic with the journal change they describe (steering_change, roadblock), so they stay inside journal transactions.
    2. CONTRACT: feat(storage): deliver owner inventory, recovery and capacity guidance #2172's page identity is defined by observing b-tree membership at every write. Frames give page numbers but not owners: SQLite pages carry no parent pointer unless auto_vacuum maintains a pointer map. A page that is free now cannot be classified, after the fact, as formerly journal or formerly foreign. Any walk-free history therefore over-approximates. It charges foreign pages a journal commit happened to dirty, such as governance pages and foreign free-list trunks, that later become free. That moves refusals, and so retain()/collectOne() expiry, earlier than CONTRACT: feat(storage): deliver owner inventory, recovery and capacity guidance #2172.
    3. The one-commit WAL bound never covered foreign dirty pages. Governance inserts dirty existing governance, index and sqlite_sequence pages that walCapacity does not count. The reviewer measured 249 changed pages against a 77-frame budget for 500 journal changes with their governance events. This is inherited from CONTRACT: feat(storage): deliver owner inventory, recovery and capacity guidance #2172; walking only journal ownership does not fix it.

    Two more ordering bugs in the branch are fixable but moot given 1–2. The invalidation walk ran after the callback, so a deleting callback could erase identities first. A thrown post-commit file read did not invalidate tracking.

    Options:

    Recommendation (not a ruling): A, sequenced with #2814: it removes the walk, the ownership question and the shared-WAL attribution together. Until then, C stands.

    Next action: a maintainer ruling on A, B or C. Completion evidence for A or B: #2172's tests unchanged, the page-identity oracle, a mutation table, and the REPLACE/balancing cases above as tests.

  14. added
    needs: decisionBlocked on a maintainer or designer ruling, not on code.
    and removed
    agent-readyContext and acceptance criteria are complete enough for an agent to take this unattended.
    on Oct 24, 2026
  15. removed their assignment
    on Oct 24, 2026
  16. schlessera commented on Oct 24, 2026

    @schlessera
    OwnerAuthor

    Maintainer ruling (2026-10-24): option A, a dedicated journal database. The journal moves into its own journal.db, next to DB_PATH and ATTACHed to the UI connection. Every page in it belongs to the journal, so capacity accounting is exact by construction: page_count, freelist_count and its own WAL size, with no walk at all. Sequence it with #2814: the writer thread or process that #2814 introduces owns journal.db. Moving the journal tables needs a migration, with its proof. Until this lands, #2812's journal-scoped walk stays as it is.

  17. added
    agent-readyContext and acceptance criteria are complete enough for an agent to take this unattended.
    and removed
    needs: decisionBlocked on a maintainer or designer ruling, not on code.
    on Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    agent-readyContext and acceptance criteria are complete enough for an agent to take this unattended.area: ui-sdkWire protocol, runtime schemas, the server and client SDK surfaces.area: ui-serverHono app factory: turn coordinator, auth, session catalog, routes, cron.priority: p1Scheduled into the current or next milestone.type: fixSomething is broken and must behave the way it is documented.

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions