`SetMaxOpenConns(1)` under WAL gives up concurrent reads, and the task asked whether that costs anything. Measured over a fixed two-second window, a paced writer against a read loop, three runs per cap: reads do not queue. Four connections buy 70µs at p50 on a turn that spends 1.19s in the resident model, and write throughput more than halves. A 19ms worst case also cannot be the source of the 2.7s router figure, so that line of enquiry is closed. What the cap cannot survive is a long-lived transaction. It holds the only connection, so a second read never completes: two seconds and `context deadline exceeded`, against 1ms at a cap of four. `Store.DB` handed out exactly that transaction. It had been there since the initial commit with no production caller, and its comment described a loop that never materialised. Its one user was a test helper reading `delivery_attempts` by raw SQL, which `ListDeliveryAttempts` has covered since V-390. So the cap stays and the seam goes, and the hazard is gone by construction rather than by documentation. `internal/store/conncap_test.go` stays as the standing measurement, skipped under -short. The comment at the cap and the one in `internal/ipc/server.go` that leans on it now state the invariant and cite the numbers. Measurement: docs/evals/2026-08-07-store-connection-cap.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3.3 KiB
Does one sqlite connection make reads queue? No (V-642)
Measured 07-08-2026 at 7b507de, on homesrv. The harness is
internal/store/conncap_test.go. It stays in the repo, because this claim gets
re-argued and the numbers should be re-runnable rather than quoted.
internal/store/store.go opens the database with SetMaxOpenConns(1), while
schema.sql sets journal_mode=WAL. WAL exists to let readers run beside one
writer, so the cap gives up the thing the journal mode was chosen for. The
question was whether that costs anything.
What was measured
A fixed two-second window. One writer calling SetValue paced at 2ms, and a
reader loop calling RecentFacts(50) over 500 seeded rows as fast as it can.
Same schema, same modernc driver, same machine, three runs per cap.
The window is wall-clock rather than a read count on purpose. A first version ran a fixed 300 reads. That finished sooner at the higher cap, so it received fewer writes, and two runs that did different work cannot be compared.
| cap | reads | writes | p50 | p95 | max |
|---|---|---|---|---|---|
| 1 | ~3050 | ~760 | 594µs | 900µs | 16-19ms |
| 4 | ~3600 | ~340 | 525µs | 710µs | 1-2ms |
What it says
Reads do not queue behind writes. Four connections buy about 70µs at p50. A turn spends 1.19s in the resident model. The tail does improve, from 19ms to 2ms, and 19ms is still not a figure anyone notices in a spoken reply.
Write throughput more than halves at the higher cap, 760 writes against 340. inference, not measured directly: at one connection the reader and the writer take turns with no lock contention. At four the writer contends for the WAL write lock with a live reader. Whatever the mechanism, the trade runs the opposite way from the one the task expected.
The cap was not the source of the 2.7s router figure. CLAUDE.md records that figure as contention rather than the model. This task was a candidate for where that contention came from. A 19ms worst case cannot produce it. That line of enquiry is closed.
One transaction is what the cap cannot survive. With a read-only transaction
open, a second read at cap 1 never completes. The harness gave it two seconds and
got context deadline exceeded. The same read at cap 4 took 1ms. The transaction
holds the only connection, so this is not a slow read, it is a stalled database.
What was done
The cap stays at 1. The reason is now written where the cap is set, rather than inferred from a four-word comment.
Store.DB was deleted. It handed out exactly the read-only transaction measured
above. It had been there since the initial commit with no production caller, and
its doc comment described a loop that never materialised. Its one user was a test
helper reading delivery_attempts by raw SQL. ListDeliveryAttempts has covered
that since V-390, and the helper now goes through the reader.
So the hazard is gone by construction, not by documentation.
TestConnCap_ReadBlocksBehindOpenSnapshot is the standing measurement of what
re-adding the seam would cost.
Not answered
Whether reads queue on the deployed box under real load, as opposed to a synthetic loop. The harness writes and reads one table. Digestion reads four and embeds while it does. The finding that closes this task is the transaction stall, which is structural and does not depend on load.