Skip to content

fix(ui-server): commit storage history and storage notices on the write worker - #3022

Merged
schlessera merged 13 commits into
mainfrom
fix/2850-storage-ticks-offloop-044
Oct 26, 2026
Merged

schlessera merged 13 commits into
mainfrom
fix/2850-storage-ticks-offloop-044

Conversation

@schlessera

Copy link
Copy Markdown
Owner

Closes #2997
Refs #2850

Ninth slice of #2850: storage history and storage notices, the 20-second storage tick's writes.

What changed

The hosted server no longer commits these on its app connection. They now commit on the ui.db write worker at synchronous=FULL, each the transaction that ran inline, moved verbatim:

  • the storage history's first row and its bounded flush (packages/ui-server/src/storage/history-writes.ts);
  • the notice sweep, a delivery's begin and its finish (packages/ui-server/src/storage/notify-writes.ts).

The history's types, schemas and reads moved to history-data.ts, so the worker module can load them without the store. history-store.ts re-exports them.

What callers wait for:

  • History flush: observations and owner-write reports stay buffered in the app until a flush commits them. A flush requested while one is committing runs once after it. A failure or overflow raised during a commit survives that commit's result.
  • Sampling tick: submits its flush and sweep without waiting, and never overlaps one still committing. close() awaits the final flush, and so does app shutdown.
  • Delivery begin: takes the process's buffered history state (history.status()) from where it was submitted. It reads the durable history under its own transaction, so a newer observation another process committed while it waited still counts.
  • Storage views: /api/storage/history and /api/storage/guidance read once every flush submitted before them has committed (StorageHistory.settled()).
  • Push transport: deliverStorage awaits begin and finish.

Without a writer (the cron runner, tests, fixtures) the same transactions run inline and synchronously, as before. Operational restore still writes inside its caller's transaction. The storage coordinator, maintenance, removal and the shared scan still commit on the app connection.

Why this shape

A flush reads the stored history, merges the buffered evidence and writes it back. That has to be one transaction where it commits, so the buffers travel to the worker as data, and the result is applied back only if no newer evidence arrived meanwhile. A delivery's begin decides from durable state that another process may change. So the durable read stays inside its transaction, and only what lives in this process is captured at submission.

The first implementation was drafted by gpt-6-astra; I reviewed it and added two fixes it missed:

  • begin on the worker had decided from the history snapshot taken at submission.
  • The storage views read before the asynchronous flush had committed. storage-guidance.test.ts's mounted history case caught that one.

Proof

Head aadfc2ca4. The change (drafted by gpt-6-astra, reviewed and fixed here; see "Why this shape"), then merges of origin/main up to fd51cadf4 (#3015), with doc conflicts only. The full suites and native proof ran on d37185754, which contains main b99489b3c (#3014). The last merge brought in #3015 (schedules: app.ts wiring and this decision doc); on this head I reran check:pr, typecheck, lint, citations and the focused tests. Every heavy run went through the host slot wrapper.

Gate Result
bun run check:pr --base origin/main on aadfc2ca4 (and on d37185754) passed (local preflight)
bun run test ./tests on d37185754 1966 pass, 0 fail
bun run test packages/ui-server on d37185754 4118 pass, 2 fail. Both failures are close-cancels-shared-runs timing out waiting for the fixture's stats read to start, the open #2866. The app there uses an in-memory database. Alone the file failed once in 7 runs on this branch and 0 in 6 on main, all under host load.
focused on aadfc2ca4: every storage-* file, storage-offloop, app-writes-offloop, push, notify-offloop, inbox-notify, schedules 284/284
tsc --noEmit, bun run lint, decision-citations, api-surface on aadfc2ca4 clean; 96/96
native hosted proof (prove-native-host.ts, all four cells, under flock on the fixture lock, dd … conv=fsync writers outside the lock) on d37185754 no load: passed; 3 fsync writers: passed (both with the deliberate retained-index-namespace failure verified)

Focused tests (packages/ui-server/tests/storage-offloop.test.ts, new; a real file-backed ui.db and write worker). The app connection refuses writes to storage_history and storage_notice_receipts, and the worker refuses any commit below FULL. The cases:

  • Initialization, flush, begin, finish and sweep commit on the worker, in order.
  • A flush held behind a slow commit does not block the loop. A second request is coalesced and commits after it.
  • The sampling tick submits without waiting or overlapping, and close() awaits the final flush.
  • Worker begin returns the same shape as inline begin on the same inputs.
  • A queued flush cannot clear a newer interruption or buffered owner report, and an owner-evidence overflow raised during a flush stays pending until its marker commits.
  • A queued begin decides on the durable history at its commit: another process's newer observation, committed while it waited, refuses it.

Mutations (each applied, its named test run, restored)

# Mutation Fails
S1 the history flush on the app connection STORAGE_INITIALIZE_COMMITS_ON_WORKER: no worker commit. The inline insert hit the app-connection refusal, which the store records as history-write-unavailable
S2 the history flush below FULL STORAGE_FLUSH_COMMITS_ON_WORKER: the worker refused the commit (SQLITE_WRITE_BELOW_FULL), which the store turns into a failed flush
S3 a flush during a flush is not coalesced STORAGE_FLUSH_REQUESTS_COALESCED
S5 notices on the app connection APP_CONNECTION_WRITE storage_notice_receipts
S6 the notice sweep below FULL SQLITE_WRITE_BELOW_FULL
S7 the tick overlaps a flush still committing STORAGE_TICK_NEVER_OVERLAPS_FLUSH
S8 worker begin decides on the submitted history STORAGE_QUEUED_BEGIN_READS_DURABLE_HISTORY_AT_COMMIT
S9 the history view does not wait for a committing flush storage-guidance: observations has length 0, expected 3

S4 (reporting a flush in flight as pending) survived as an equivalent mutant. The buffered observation stays set until its commit applies, so the extra in-flight term never changed the answer, and I removed it. S8's test failed first on the drafted code. S9's test is the existing storage-guidance case, which failed on the drafted code before the views waited.

Lag attribution (#2416 method with the turn wait joined; diagnostic not committed). Native pi UID 1000 cell, 3 fsync writers, 3 runs each.

  • Before (origin/main 71503546f): the only storage app-connection turn was the boot-time history insert (history-store.ts:58 ← app.ts, 26 ms). No window ≥ 250 ms after ready came from storage.
  • After (this change): no app-connection turn from storage at all. The one window ≥ 250 ms after ready (338 ms) was the Activity notifier's tick, on a head before fix(ui-server): commit the Activity notifier and push bookkeeping on the write worker #2994 moved it.

The cell is shorter than a few sampling ticks, so it barely exercises this group. The focused tests carry the evidence.

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. (Met for storage history and notices; the remaining groups are listed 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. (.changeset/storage-ticks-off-event-loop.md)
  • 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. (Nothing moved: the API report is unchanged.)
  • Docs under docs/ reflect the change, or nothing documented moved. (docs/decisions/sqlite-write-worker.md.)
  • 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

The remaining groups are the open sub-issues of epic #2850.

🤖 Generated with Claude Code

…s-offloop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.md
…s-offloop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.md
#	packages/ui-server/src/activity/push-sender.ts
…s-offloop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.md
…s-offloop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.md
…s-offloop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.md
…s-offloop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.md
…s-offloop-044

# Conflicts:
#	docs/decisions/sqlite-write-worker.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ui-server: storage history and storage notices commit on the write worker

1 participant