fix(db): stop 'set sqlite pragmas' lock flake on concurrent connects (#157) #187

Merged
cameron merged 1 commits from fix/157-sqlite-pragma-flake into master 2026-10-21 14:31:19 +00:00
Owner

Summary

Fixes #157. When several connections opened a new DB file at the same moment, connect() could panic with set sqlite pragmas: database is locked.

 connect_to(url)
   establish(url)
-  PRAGMA journal_mode = WAL; busy_timeout = 5000; ...   // first lock waits 0 ms
+  loop until ok or 5 s:
+    PRAGMA busy_timeout = 5000; journal_mode = WAL; ...
+    on "database is locked": sleep 10 ms, retry

Setting busy_timeout first wasn't enough. While the file is still in rollback mode, a connection that holds a shared lock and asks for the WAL switch gets SQLITE_BUSY immediately, without waiting (SQLite does this to avoid a deadlock). WAL mode stays set in the file, so only the first switch can race.

Also removes the DB_INIT workaround mutex in the ai::handlers tests, and splits connect() into connect() → connect_to(url) so the test can point at its own temp file.

Evidence

New test database::connect_tests::concurrent_connects_to_fresh_file_all_succeed: 16 threads open a fresh temp file at once, 20 rounds.

  • Before: fails on every run: panicked at src/database/mod.rs: set sqlite pragmas: DatabaseError(Unknown, "database is locked"). Moving busy_timeout first alone made failures rarer but still failed 3/3.
    After: 5/5 runs pass.
  • Full cargo test --no-fail-fast, 10 consecutive runs, each on a freshly deleted DATABASE_URL file: 10/10 pass (22 test binaries each). Clippy clean.

Merge Danger

Door: two-way

Blast Radius: startup-only

Only affects opening a connection. In the worst case, a connection that can't get the lock now waits up to 5 s before panicking, instead of panicking at once.

🤖 Generated with Claude Code

## Summary Fixes #157. When several connections opened a new DB file at the same moment, `connect()` could panic with `set sqlite pragmas: database is locked`. ```diff connect_to(url) establish(url) - PRAGMA journal_mode = WAL; busy_timeout = 5000; ... // first lock waits 0 ms + loop until ok or 5 s: + PRAGMA busy_timeout = 5000; journal_mode = WAL; ... + on "database is locked": sleep 10 ms, retry ``` Setting `busy_timeout` first wasn't enough. While the file is still in rollback mode, a connection that holds a shared lock and asks for the WAL switch gets SQLITE_BUSY immediately, without waiting (SQLite does this to avoid a deadlock). WAL mode stays set in the file, so only the first switch can race. Also removes the `DB_INIT` workaround mutex in the `ai::handlers` tests, and splits `connect()` into `connect()` → `connect_to(url)` so the test can point at its own temp file. ## Evidence New test `database::connect_tests::concurrent_connects_to_fresh_file_all_succeed`: 16 threads open a fresh temp file at once, 20 rounds. - **Before:** fails on every run: `panicked at src/database/mod.rs: set sqlite pragmas: DatabaseError(Unknown, "database is locked")`. Moving `busy_timeout` first alone made failures rarer but still failed 3/3. **After:** 5/5 runs pass. - Full `cargo test --no-fail-fast`, 10 consecutive runs, each on a freshly deleted `DATABASE_URL` file: 10/10 pass (22 test binaries each). Clippy clean. ## Merge Danger **Door:** two-way **Blast Radius:** startup-only Only affects opening a connection. In the worst case, a connection that can't get the lock now waits up to 5 s before panicking, instead of panicking at once. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
cameron added 1 commit 2026-10-21 14:28:30 +00:00
Set busy_timeout before the WAL switch and retry the pragma batch on
'database is locked' within the same 5 s budget: while a file is still in
rollback mode, a shared-lock holder upgrading for the journal_mode change
gets SQLITE_BUSY without the busy handler running, so busy_timeout alone
doesn't cover parallel opens of a fresh file. Adds a 16-thread stress test
and drops the per-module DB_INIT workaround in ai::handlers tests.

Closes #157

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cameron merged commit 3e3276b8a3 into master 2026-10-21 14:31:19 +00:00
cameron deleted branch fix/157-sqlite-pragma-flake 2026-10-21 14:31:19 +00:00
Sign in to join this conversation.