fix(db): WAL, because watching a fetch was killing it - #470
Merged
Conversation
Found by running the thing rather than testing it. A first market-data fetch, started from the
browser and watched on the page that says it will show the progress, DIED at 45 seconds with
`OperationalError: disk I/O error` after writing 31,709 candles.
Not disk space (362 GiB free) and not corruption (`PRAGMA integrity_check` -> ok). The setup page
auto-refreshes every 5 seconds and opens the database to render the checklist; the background job
was writing to it. In SQLite's default rollback journal a writer takes an EXCLUSIVE lock and
readers take SHARED ones, so the two cannot coexist.
Measured, same fetch, same machine, against the real venue:
page polling every 5s, rollback -> FAILED at 45s, 31,709 candles
nobody polling, rollback -> ran 150s, 108,202 candles
polling every 0.2s, WAL -> ran 150s, 108,501 candles, 694 clean reads
The middle row is what makes this worth taking seriously: the fetch was fine, and OBSERVING it was
the bug. Worse, observing it is the encouraged behaviour -- the action's own message says "this
page will show its progress".
In WAL, readers never block the writer and the writer never blocks readers. The hazard was never
specific to the job runner either: an agent writing a cycle while a dashboard refreshes is the
same shape, and the web UI is what made it reachable.
A busy timeout comes with it. SQLite's default is ZERO -- it raises immediately -- which is the
wrong default for a process that now reads and writes one file at the same time.
Journal mode is a property of the FILE, not the connection, so an existing deployment converts on
its next connection and needs nothing from an operator. `:memory:` is excluded: there is no file
to journal, SQLite refuses WAL there, and a shared in-memory database is single-connection anyway.
TWO THINGS CHECKED RATHER THAN ASSUMED. `keel update`'s backups are unaffected -- it uses SQLite's
own online-backup API, precisely because "a plain file copy of a database with a live rollback
journal is not a snapshot", and that API reads committed WAL content too. And the `-wal`/`-shm`
sidecars must not be mistaken for databases: both the backup set and `is_deployment_root` glob
`keel*.db`, which does not match them, and that is now pinned.
Also checked: a read-only URI open (`file:...?mode=ro`, how `keel setup` inspects a deployment)
still works against a WAL database with no writer present.
Verified end to end after the fix: the identical scenario, polled once per SECOND -- five times
harder than the page does -- ran past 102 seconds and 74,714 candles without an error.
4092 passed, 3 skipped (5 new). ruff clean repo-wide; mypy clean over keel + packages.
Refs #437, #435, #18.
Co-Authored-By: Claude Opus 5 (1M context) <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.
Refs #437, #435. Found while verifying D4's acceptance end to end — by running it, not by testing it.
The bug
A first market-data fetch, started from the browser and watched on the page that says "this page will show its progress", died at 45 seconds:
Not disk space — 362 GiB free. Not corruption —
PRAGMA integrity_check→ok.The setup page auto-refreshes every 5 seconds and opens the database to render the checklist. The background job was writing to it. In SQLite's default rollback journal a writer takes an EXCLUSIVE lock and readers take SHARED ones, so the two cannot coexist.
Measured, same fetch, same machine, real venue
The middle row is what makes this worth taking seriously: the fetch was fine, and observing it was the bug. Worse, observing it is the encouraged behaviour — the action's own message invites it.
The fix
In WAL, readers never block the writer and the writer never blocks readers.
The hazard was never specific to the job runner, either: an agent writing a cycle while a dashboard refreshes is the same shape. The web UI is simply what made it reachable.
A busy timeout comes with it — SQLite's default is zero, it raises immediately, which is the wrong default for a process that now reads and writes one file at the same time.
Journal mode is a property of the file, not the connection, so an existing deployment converts on its next connection and needs nothing from an operator.
:memory:is excluded: no file to journal, SQLite refuses WAL there, and a shared in-memory database is single-connection anyway.Two things checked rather than assumed
keel update's backups are unaffected. It uses SQLite's own online-backup API — precisely because "a plain file copy of a database with a live rollback journal is not a snapshot" — and that API reads committed WAL content too.-wal/-shmsidecars must not be mistaken for databases. Both the backup set andis_deployment_rootglobkeel*.db, which does not match them. Now pinned by test, because a-walcounted as a database would be backed up as one and, worse, would make any folder holding one look like a deployment.Also checked: a read-only URI open (
file:…?mode=ro, howkeel setupinspects a deployment) still works against a WAL database with no writer present.Verified end to end after the fix
The identical scenario, polled once per second — five times harder than the page does:
Verification
4092 passed, 3 skipped (5 new).
ruff checkclean repo-wide,mypyclean.The operator runbook now explains why a deployment folder has three files per database, and says not to hand-copy the
.dbwhile keel is running.🤖 Generated with Claude Code