fix(core): retry the WAL mode flip a concurrent opener wins (GH #111) - #115
Conversation
On a vault with no index yet, a second `b2` command racing the first-ever `b2 reindex` failed one of the two processes with "database is locked" — ~40% of the documented cold-vault flow (`b2 reindex &` then `b2 status`), with the reindex usually the loser, so a backgrounded cold index could silently not happen. `journal_mode = WAL` is the one statement in `db::open` that takes a write lock, and only when it actually *changes* the mode — so exactly once per vault, on the first open. It is not covered by `busy_timeout`: the flip upgrades an already-open read transaction, and SQLite only consults the busy handler for a write lock taken from no transaction at all (`btreeBeginTrans`'s retry loop is guarded on `inTransaction == TRANS_NONE`, and `sqlite3PagerBegin` documents the RESERVED lock as taken without the handler). Reordering the pragmas therefore does not help — measured at 6 failures in 160 racing opens with `busy_timeout` set explicitly first, and rusqlite already arms the same 5 s at `Connection::open` regardless. So the retry is ours: `enter_wal_mode` reattempts the flip on SQLITE_BUSY/SQLITE_LOCKED with a doubling backoff (~4 s total). It converges immediately because it only has to outlast the other opener's flip — on the next attempt the database is already WAL, where the pragma is a no-op needing no lock. Two regression tests. `first_open_waits_out_a_held_lock` is the gate: a `BEGIN IMMEDIATE` holder pins RESERVED — readers in, writers out — which is exactly the lock the flip trips over, so it fails 5/5 in ~200 µs unfixed and passes with the retry. (`BEGIN EXCLUSIVE` would block the flip's *read* half instead, which the busy handler does cover, and assert nothing.) `concurrent_first_opens_all_succeed` races eight barrier-released opens through the whole of `open`, migration included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STbjSU1GAXLAvca7FUHaRc
`enter_wal_mode` used `execute_batch`, which discards the row `PRAGMA journal_mode = WAL` returns. SQLite has one case where it declines the flip with no error at all — a filesystem with no shared-memory support, where it returns the *old* mode and SQLITE_OK — so a function named for putting the connection in WAL mode could report success having changed nothing. For a personal vault that filesystem is a plausible home (a network share, a synced folder on some setups), not a hypothetical. Query the mode back instead. A decline is not an error: B2 is correct in rollback-journal mode, and refusing to open such a vault would be the worse bug. It logs at WARN and returns Ok, and it is deliberately not retried — nothing holds a lock, so waiting cannot change the answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lcfs1nfA17zSqQGwRn2NeF
…(GH #111) `concurrent_first_opens_all_succeed` read as the #111 regression test but isn't one: the flip's race window is ~200 µs, so eight barrier-released threads land inside it only sometimes — measured at 3 failures in 25 runs against the unfixed `open`, i.e. green ~88% of the time on the very bug it appeared to name. That is the silent gap CLAUDE.md warns about, where a test's name claims more than its body asserts. Renamed to `concurrent_openers_of_a_fresh_index_coexist` and the docstring now states plainly that `first_open_waits_out_a_held_lock` is the gate (it holds the contended lock outright and fails 100% of the time when the retry is removed), and what this test does buy that no single-lock test can: that N concurrent openers finish at all — no deadlock, no starved thread, no error escaping the retry. The module header also now records where `open`'s concurrency safety stops: the `schema_version` migration still runs unwrapped DDL (GH #114). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lcfs1nfA17zSqQGwRn2NeF
📝 WalkthroughWalkthrough
ChangesWAL initialization
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Opener
participant enter_wal_mode
participant SQLite
Opener->>SQLite: Configure connection pragmas
Opener->>enter_wal_mode: Activate WAL
enter_wal_mode->>SQLite: Execute journal_mode = WAL
SQLite-->>enter_wal_mode: WAL mode or lock contention
enter_wal_mode->>enter_wal_mode: Wait and retry when locked
enter_wal_mode-->>Opener: Return initialization result
Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/b2-core/tests/substrate.rs (1)
177-187: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueBind the opener count once.
Barrier::new(8)and(0..8)must stay in lockstep; if they ever drift the test hangs on the barrier rather than failing.♻️ Proposed tweak
- let start = Arc::new(Barrier::new(8)); - let openers: Vec<_> = (0..8) + const OPENERS: usize = 8; + let start = Arc::new(Barrier::new(OPENERS)); + let openers: Vec<_> = (0..OPENERS)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/b2-core/tests/substrate.rs` around lines 177 - 187, Bind the concurrent opener count to a single local value and reuse it for both Barrier::new and the opener range in this test, ensuring the synchronization count cannot drift from the number of spawned threads.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@crates/b2-core/tests/substrate.rs`:
- Around line 177-187: Bind the concurrent opener count to a single local value
and reuse it for both Barrier::new and the opener range in this test, ensuring
the synchronization count cannot drift from the number of spawned threads.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 554eb6b4-a875-4006-a326-61f25832875f
📒 Files selected for processing (2)
crates/b2-core/src/db.rscrates/b2-core/tests/substrate.rs
`Barrier::new(8)` and `(0..8)` had to stay in lockstep by hand. Drift between them doesn't fail the test, it hangs it — the barrier never releases, so CI reports an opaque timeout with no assertion to read. One `const OPENERS` makes that unrepresentable. Review feedback on #115. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lcfs1nfA17zSqQGwRn2NeF
On a vault with no index yet, a second
b2command racing the first-everb2 reindexfailed one of the two processes with "database is locked" — ~40% of the documented cold-vault flow (b2 reindex &thenb2 status), with the reindex usually the loser, so a backgrounded cold index could silently not happen.Fixes #111. Supersedes #113 (same first commit, plus the review follow-ups below).
The lock
busy_timeoutcannot coverjournal_mode = WALis the one statement indb::openthat takes a write lock, and only when it actually changes the mode — so exactly once per vault, on the first open. It is not covered bybusy_timeout: the flip upgrades an already-open read transaction, and SQLite only consults the busy handler for a write lock taken from no transaction at all (btreeBeginTrans's retry loop is guarded oninTransaction == TRANS_NONE, andsqlite3PagerBegindocuments the RESERVED lock as taken without the handler).Reordering the pragmas therefore does not help — measured at 6 failures in 160 racing opens with
busy_timeoutset explicitly first, and rusqlite already arms the same 5 s atConnection::openregardless. So the retry is ours:enter_wal_modereattempts the flip onSQLITE_BUSY/SQLITE_LOCKEDwith a doubling backoff (~4 s total). It converges immediately because it only has to outlast the other opener's flip — on the next attempt the database is already WAL, where the pragma is a no-op needing no lock.The flip is verified, not assumed
PRAGMA journal_mode = WALreports the mode it ended on as a row, and there is one case where it declines the flip with no error at all: a filesystem with no shared-memory support (sqlite3PagerWalSupported— a network share, or a synced folder on some setups, both plausible homes for a personal vault), where SQLite returns the old mode andSQLITE_OK. A row-discardingexecute_batchwould report success having changed nothing, soenter_wal_modequeries the mode back instead.A decline is deliberately not an error — B2 is correct in rollback-journal mode, and refusing to open such a vault would be the worse bug — and deliberately not retried, since nothing is holding a lock and no amount of waiting would change the answer. It logs at WARN and returns.
Tests
first_open_waits_out_a_held_lockis the gate. ABEGIN IMMEDIATEholder pins RESERVED — readers in, writers out — which is exactly the lock the flip trips over, so it fails in ~200 µs unfixed and passes with the retry. (BEGIN EXCLUSIVEwould block the flip's read half instead, which the busy handler does cover, and would assert nothing.) It also asserts the resulting mode really iswal, so the wait bought the real thing.concurrent_openers_of_a_fresh_index_coexistis a coexistence smoke test, not a second gate, and its docstring says so — because the name would otherwise promise a regression it does not catch. The flip's race window is ~200 µs wide, so eight barrier-released threads land inside it only sometimes: measured at 3 failures in 25 runs against the unfixedopen, i.e. green ~88% of the time on the very bug it appears to name. What it does buy is the property no deterministic single-lock test can state — that N concurrent openers finish at all, with no deadlock, no starved thread, and no error escaping the retry, which is what would break ifopenever took a lock it holds rather than one it waits out.Scope: where
open's concurrency safety stopsThis closes the
SQLITE_BUSYhalf only.migrate()still runs its schema DDL unwrapped and unserialized, so concurrent openers of a stale-schema index can interleave drop-and-rebuild — filed as #114 with a reproducer: ~2.5% of opens fail withno such table, and one round in 40 left four tables missing while every opener returnedOk. That is a different error class, so this PR's retry neither catches it nor should. Thesubstrate.rsmodule header records the boundary explicitly so the suite doesn't read as covering ground it doesn't.Summary by CodeRabbit
Bug Fixes
Tests
🤖 Generated with Claude Code
https://claude.ai/code/session_01Lcfs1nfA17zSqQGwRn2NeF