Skip to content

fix(db): WAL, because watching a fetch was killing it - #470

Merged
eaitbrahim merged 1 commit into
mainfrom
fix/wal-concurrent-reads
Aug 20, 2026
Merged

fix(db): WAL, because watching a fetch was killing it#470
eaitbrahim merged 1 commit into
mainfrom
fix/wal-concurrent-reads

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

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:

market_data · failed · OperationalError: disk I/O error
                       (31,709 candles written)

Not disk space — 362 GiB free. Not corruption — PRAGMA integrity_checkok.

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

polling journal outcome
every 5 s (the page) rollback FAILED at 45 s, 31,709 candles
none rollback ran 150 s, 108,202 candles
every 0.2 s (25× harder) WAL ran 150 s, 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 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.
  • 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. Now pinned by test, because a -wal counted 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, 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:

market_data · running · 102s elapsed · 74,714 candles · no error

Verification

4092 passed, 3 skipped (5 new). ruff check clean repo-wide, mypy clean.

The operator runbook now explains why a deployment folder has three files per database, and says not to hand-copy the .db while keel is running.

🤖 Generated with Claude Code

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>
@eaitbrahim
eaitbrahim merged commit 481524c into main Aug 20, 2026
5 checks passed
@eaitbrahim
eaitbrahim deleted the fix/wal-concurrent-reads branch August 20, 2026 22:43
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.

1 participant