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.
## 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)
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 master2026-10-21 14:31:19 +00:00
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.
Summary
Fixes #157. When several connections opened a new DB file at the same moment,
connect()could panic withset sqlite pragmas: database is locked.Setting
busy_timeoutfirst 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_INITworkaround mutex in theai::handlerstests, and splitsconnect()intoconnect()→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.panicked at src/database/mod.rs: set sqlite pragmas: DatabaseError(Unknown, "database is locked"). Movingbusy_timeoutfirst alone made failures rarer but still failed 3/3.After: 5/5 runs pass.
cargo test --no-fail-fast, 10 consecutive runs, each on a freshly deletedDATABASE_URLfile: 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