Found during the verification run for #3: about 43,000 database is locked errors in 27 minutes on next at 658aadb, from batch-flush write paths such as internal/routewatch/ashandler.go:168. Every pooled connection has had busy_timeout=5000 since #8, so a missing timeout is not the cause. Each failure drops a batch, so data is lost; memory is not affected.
Requirements
Find the cause by reading the write paths and reproducing against the live feed: which statements fail, under which lock (Database.mu, SQLite write lock, checkpoint), and why the 5 s busy wait does not cover it. A likely candidate to check first: transactions that start as readers and upgrade to writers fail at once with SQLITE_BUSY regardless of the busy timeout; go-sqlite3's _txlock=immediate DSN parameter makes them take the write lock up front.
Post the cause on this issue as ONE comment, then fix it with the smallest change, as one PR against next.
Experiments use your own container names and 127.0.0.1 ports, no --memory, removed by name afterwards. Never touch containers whose names start with routewatch-verify.
Definition of done
A 15-minute live run logs no database is locked errors, or only a small stated number with the reason; stated on the PR as a disclosure with the method.
script/cibuild green. Commit title ends (closes #N) with this issue's number.
Model: fable-5-1
Found during the verification run for https://git.eeqj.de/sneak/routewatch/issues/3: about 43,000 `database is locked` errors in 27 minutes on `next` at `658aadb`, from batch-flush write paths such as `internal/routewatch/ashandler.go:168`. Every pooled connection has had `busy_timeout=5000` since https://git.eeqj.de/sneak/routewatch/issues/8, so a missing timeout is not the cause. Each failure drops a batch, so data is lost; memory is not affected.
## Requirements
- Find the cause by reading the write paths and reproducing against the live feed: which statements fail, under which lock (`Database.mu`, SQLite write lock, checkpoint), and why the 5 s busy wait does not cover it. A likely candidate to check first: transactions that start as readers and upgrade to writers fail at once with `SQLITE_BUSY` regardless of the busy timeout; go-sqlite3's `_txlock=immediate` DSN parameter makes them take the write lock up front.
- Post the cause on this issue as ONE comment, then fix it with the smallest change, as one PR against `next`.
- Experiments use your own container names and 127.0.0.1 ports, no `--memory`, removed by name afterwards. Never touch containers whose names start with `routewatch-verify`.
## Definition of done
- A 15-minute live run logs no `database is locked` errors, or only a small stated number with the reason; stated on the PR as a disclosure with the method.
- `script/cibuild` green. Commit title ends ` (closes #N)` with this issue's number.
Model: fable-5-1
Every batch-flush write path opens a transaction with a plain deferred BEGIN, then runs a SELECT before its INSERT/UPDATE (for example GetOrCreateASNBatch in internal/database/database.go, called from the flush in internal/routewatch/ashandler.go). A deferred transaction that reads first takes only a read lock at BEGIN; when it later writes it must upgrade to the write lock. If another connection holds the write lock at that moment, SQLite returns database is locked (SQLITE_BUSY) immediately and does not wait for busy_timeout — the busy handler is deliberately skipped on a lock upgrade, because sleeping while already holding a read lock could deadlock. So the 5 s busy_timeout added in #8 never covers these failures.
The other writer is the background maintainer (internal/routewatch/dbmaintainer.go): it runs PRAGMA wal_checkpoint(TRUNCATE) every 5 seconds, plus incremental vacuum and ANALYZE, on pooled connections that do not hold the process-level Database.mu. While a checkpoint holds the write lock, any batch transaction that reaches its first write in that window fails at once and drops its batch. That is the source of the database is locked errors; the peering handler, which writes almost continuously, is hit hardest.
Confirmed by a test that runs batch writes against a running checkpoint loop: on the current code it reports database is locked within a fraction of a second.
Fix (in the PR against next): add _txlock=immediate to the connection DSN so every transaction takes the write lock at BEGIN. When the lock is held, BEGIN IMMEDIATE waits up to busy_timeout instead of failing, so batches are no longer dropped.
Model: opus-4-8
## Cause
Every batch-flush write path opens a transaction with a plain deferred `BEGIN`, then runs a `SELECT` before its `INSERT`/`UPDATE` (for example `GetOrCreateASNBatch` in `internal/database/database.go`, called from the flush in `internal/routewatch/ashandler.go`). A deferred transaction that reads first takes only a read lock at `BEGIN`; when it later writes it must upgrade to the write lock. If another connection holds the write lock at that moment, SQLite returns `database is locked` (`SQLITE_BUSY`) immediately and does not wait for `busy_timeout` — the busy handler is deliberately skipped on a lock upgrade, because sleeping while already holding a read lock could deadlock. So the 5 s `busy_timeout` added in https://git.eeqj.de/sneak/routewatch/issues/8 never covers these failures.
The other writer is the background maintainer (`internal/routewatch/dbmaintainer.go`): it runs `PRAGMA wal_checkpoint(TRUNCATE)` every 5 seconds, plus incremental vacuum and ANALYZE, on pooled connections that do not hold the process-level `Database.mu`. While a checkpoint holds the write lock, any batch transaction that reaches its first write in that window fails at once and drops its batch. That is the source of the `database is locked` errors; the peering handler, which writes almost continuously, is hit hardest.
Confirmed by a test that runs batch writes against a running checkpoint loop: on the current code it reports `database is locked` within a fraction of a second.
Fix (in the PR against `next`): add `_txlock=immediate` to the connection DSN so every transaction takes the write lock at `BEGIN`. When the lock is held, `BEGIN IMMEDIATE` waits up to `busy_timeout` instead of failing, so batches are no longer dropped.
Model: opus-4-8
Added _txlock=immediate to the SQLite connection DSN so every transaction takes the write lock at BEGIN and waits up to busy_timeout when it is held, instead of a deferred read-then-write transaction failing its upgrade at once. A regression test drives batch writes against a running checkpoint loop and reproduces database is locked without the change.
A 15-minute live run against the RIS feed from an empty database logged 0 database is locked errors (database grew to ~1.0 GB, 1.9M live routes over 5.1M messages). script/cibuild is green.
Model: opus-4-8
Fix in https://git.eeqj.de/sneak/routewatch/pulls/26 (base `next`).
Added `_txlock=immediate` to the SQLite connection DSN so every transaction takes the write lock at `BEGIN` and waits up to `busy_timeout` when it is held, instead of a deferred read-then-write transaction failing its upgrade at once. A regression test drives batch writes against a running checkpoint loop and reproduces `database is locked` without the change.
A 15-minute live run against the RIS feed from an empty database logged 0 `database is locked` errors (database grew to ~1.0 GB, 1.9M live routes over 5.1M messages). `script/cibuild` is green.
Model: opus-4-8
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found during the verification run for #3: about 43,000
database is lockederrors in 27 minutes onnextat658aadb, from batch-flush write paths such asinternal/routewatch/ashandler.go:168. Every pooled connection has hadbusy_timeout=5000since #8, so a missing timeout is not the cause. Each failure drops a batch, so data is lost; memory is not affected.Requirements
Database.mu, SQLite write lock, checkpoint), and why the 5 s busy wait does not cover it. A likely candidate to check first: transactions that start as readers and upgrade to writers fail at once withSQLITE_BUSYregardless of the busy timeout; go-sqlite3's_txlock=immediateDSN parameter makes them take the write lock up front.next.--memory, removed by name afterwards. Never touch containers whose names start withroutewatch-verify.Definition of done
database is lockederrors, or only a small stated number with the reason; stated on the PR as a disclosure with the method.script/cibuildgreen. Commit title ends(closes #N)with this issue's number.Model: fable-5-1
Cause
Every batch-flush write path opens a transaction with a plain deferred
BEGIN, then runs aSELECTbefore itsINSERT/UPDATE(for exampleGetOrCreateASNBatchininternal/database/database.go, called from the flush ininternal/routewatch/ashandler.go). A deferred transaction that reads first takes only a read lock atBEGIN; when it later writes it must upgrade to the write lock. If another connection holds the write lock at that moment, SQLite returnsdatabase is locked(SQLITE_BUSY) immediately and does not wait forbusy_timeout— the busy handler is deliberately skipped on a lock upgrade, because sleeping while already holding a read lock could deadlock. So the 5 sbusy_timeoutadded in #8 never covers these failures.The other writer is the background maintainer (
internal/routewatch/dbmaintainer.go): it runsPRAGMA wal_checkpoint(TRUNCATE)every 5 seconds, plus incremental vacuum and ANALYZE, on pooled connections that do not hold the process-levelDatabase.mu. While a checkpoint holds the write lock, any batch transaction that reaches its first write in that window fails at once and drops its batch. That is the source of thedatabase is lockederrors; the peering handler, which writes almost continuously, is hit hardest.Confirmed by a test that runs batch writes against a running checkpoint loop: on the current code it reports
database is lockedwithin a fraction of a second.Fix (in the PR against
next): add_txlock=immediateto the connection DSN so every transaction takes the write lock atBEGIN. When the lock is held,BEGIN IMMEDIATEwaits up tobusy_timeoutinstead of failing, so batches are no longer dropped.Model: opus-4-8
clawbot referenced this issue2026-09-21 19:29:44 +02:00
Fix in #26 (base
next).Added
_txlock=immediateto the SQLite connection DSN so every transaction takes the write lock atBEGINand waits up tobusy_timeoutwhen it is held, instead of a deferred read-then-write transaction failing its upgrade at once. A regression test drives batch writes against a running checkpoint loop and reproducesdatabase is lockedwithout the change.A 15-minute live run against the RIS feed from an empty database logged 0
database is lockederrors (database grew to ~1.0 GB, 1.9M live routes over 5.1M messages).script/cibuildis green.Model: opus-4-8