Repository navigation
perf(server): SQLite writes no longer freeze the event loop on a busy disk - #266
Merged
Merged
Conversation
… disk The writer connection runs on a worker thread, like the reader. Each statement is a message to it, so a write waiting on the ext4 journal or a page-cache miss delays writes instead of every RPC and health check. Checkpoint coordination stays on the main thread and reads WAL state from each reply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The writer's reply updates transaction and WAL state in the message callback, which runs even when the caller was interrupted while its statement ran, so a write that grew the WAL still triggers a checkpoint check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Thread transfer impact✅ Thread transfer remains within every enforced ceiling.
Baseline: unavailable · PR result: Scenario and decoded snapshot size10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.
Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed. |
…ing writes A reply that cannot be deserialized now fails the statement waiting on it, for the writer and the reader, instead of holding the writer lock forever. If the writer worker stops on its own, the server logs it and exits non-zero so its service manager restarts it; a normal shutdown closes the worker without that. The writer-worker tests also type-check: their callbacks may use a Scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
Stacked on #265 (the upstream sync). Review only the last three commits.
Problem
The home host shares one NVMe between the T3 server and heavy agent work. Whenever agents load the disk, the server's event loop freezes: 36
event loop stalledwarnings in 30 minutes, worst 15.4 s,/api/healthat 2–10 s, and every client stuck on "Syncing messages…". The kernel showed the main thread indo_get_write_access(the ext4 journal) andfolio_wait_bit_common(page-cache misses on the 10 GB database). Every SQLite statement on the writer connection runs synchronously on the main thread, so a write that waits on the disk freezes every RPC and health check behind it.Upstream's pingdotgg#23902, synced in #265, moves more reads to the reader worker, but writes, and reads inside write transactions, still run on the main thread.
Fix
The writer connection runs on a worker thread, as the reader already does (
writerWorker: trueinpersistence/Sqlite.ts). A busy disk now delays writes, but the event loop stays free.BEGIN … COMMITstays on one connection with nothing interleaved. The event sink's single transaction for events, projections, receipt and outbox is unchanged.errcode, and the main thread classifies it into the sameSqlErrorreasons (UniqueViolation,LockTimeoutError, …) as before.isTransaction, WAL frame counts andjournal_size_limitfrom state the worker reports with each reply, instead of querying SQLite synchronously. The statement authorizer that classifies writes moves into the worker. When the gate is closed, the worker answers "needs admission" without running the statement, and the main thread waits as before and then resends it.Database writer is closed. Restart intent is written by ordinary transactions before the layers close, so its ordering is unchanged.onWriterLost, and the server exits with code 1 so systemd or the launcher restarts it; the database, restart intent and job records are on disk. A normal shutdown closes the worker without this.messageerror, for example an allocation failure on a huge row), the statement waiting on it fails with an error instead of holding the writer lock forever. The reader worker gets the same handling. The worker keeps running.Measurements
Measured in Docker Desktop's Linux VM: kernel 6.12, ext4, 7.75 GB RAM, with Node 24.22.0 and SQLite 3.53.4 as on the host. The database is a 9.9 GB copy of the host's
statev2.sqlite. The copy, taken from the live file, was torn, so it was rebuilt table by table; two unreadable rowid ranges were lost.The harness uses the real
Sqlite.layerFromPath,EventStore,ProjectionStoreandEventSink. Eight agents each stream an assistant-message update every 50 ms, about 150 event writes/s; each write is one 7-statement transaction. A worker thread sends an HTTP request to the main thread every 100 ms ("health"), andmonitorEventLoopDelayruns at 10 ms resolution. For saturation, 32 synced 4 KB writers, 8 bulk writers and 24 small-file churners share the ext4 volume, holding I/O pressure (PSI "some") at 70–80%. The page cache is dropped before each run, and each run measures 60 s after a 5 s warmup.folio_wait_bit_commonThe before-sync build reproduces the host: the main thread blocks in
folio_wait_bit_commonreading pages of the large database. The sync helps on an idle disk but not under saturation. With the worker, the main thread never stalls longer than 14 ms, even while writes wait seconds on the disk.Peak throughput depends on thread wake-ups. Each event write makes seven round trips. In the Docker VM a worker message round trip costs 33 µs, and even a bare futex ping-pong costs 26 µs, so peak ingestion there drops 45%. On bare-metal macOS those figures are 8.6 µs and 4.5 µs, and the worker build is about 70% faster at peak, because SQLite runs alongside the main thread's own work. The home host is bare-metal Linux. At the realistic rate of about 150 writes/s, throughput is unchanged in every build.
Risks
Tests
nodeSqliteClient.writerWorker.test.tscovers, against a real worker and a file database:SqlErrors (unique violation, prepare failure, lock timeout);onWriterLost, a normal shutdown reports nothing, and later writes fail.Mutating the client to run on the main thread, to skip the post-commit checkpoint check, to ignore the admission gate, to execute a held statement twice, or to report a lost writer during shutdown each fails a test.
Verification
nodeSqliteClient.test.ts(17) andnodeSqliteClient.writerWorker.test.ts(11) pass on the head commit.layerFromPath, plusserverRuntimeStartup. The exception is one known flake, below.ProvisionOperationStore.test.ts"two server processes cannot both claim the same allocation effect" fails about once in three parallel batches. Its contender processes run with--input-type=module, so evaluated workers cannotrequireand the writer falls back to the main thread. That fallback runsSELECT sqlite_version()beforebusy_timeoutis set, while the other process recovers the WAL. The worker now setsbusy_timeoutbefore its own startup reads; the main-thread path is unchanged.tsc.Not verified: the real server against the host's database, and the web, desktop and mobile clients.
Follow-ups (not in this PR)
readOnlyso they run on the reader, which upstream is already moving toward.requirewhen the parent process runs with--input-type=module, so in such processes the writer falls back to the main thread. The reader and checkpoint workers have the same limit, and the server, which runs from a file, is unaffected.SELECT sqlite_version()beforebusy_timeoutis set, which makes the multi-processProvisionOperationStoretest flaky on chore: sync upstream through 66ee628e08 #265 as well.🤖 Generated with Claude Code