Skip to content

fix(core): retry the WAL mode flip a concurrent opener wins (GH #111) - #115

Merged
samkeen merged 4 commits into
mainfrom
claude/pr-113-review-yfswdx
Jul 26, 2026
Merged

fix(core): retry the WAL mode flip a concurrent opener wins (GH #111)#115
samkeen merged 4 commits into
mainfrom
claude/pr-113-review-yfswdx

Conversation

@samkeen

@samkeen samkeen commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

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.

Fixes #111. Supersedes #113 (same first commit, plus the review follow-ups below).

The lock busy_timeout cannot cover

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.

The flip is verified, not assumed

PRAGMA journal_mode = WAL reports 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 and SQLITE_OK. A row-discarding execute_batch would report success having changed nothing, so enter_wal_mode queries 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_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 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 would assert nothing.) It also asserts the resulting mode really is wal, so the wait bought the real thing.

concurrent_openers_of_a_fresh_index_coexist is 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 unfixed open, 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 if open ever took a lock it holds rather than one it waits out.

Scope: where open's concurrency safety stops

This closes the SQLITE_BUSY half 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 with no such table, and one round in 40 left four tables missing while every opener returned Ok. That is a different error class, so this PR's retry neither catches it nor should. The substrate.rs module header records the boundary explicitly so the suite doesn't read as covering ground it doesn't.

Summary by CodeRabbit

  • Bug Fixes

    • Improved database opening reliability when multiple processes or threads access a database simultaneously.
    • Added automatic retries for temporary lock contention when enabling WAL mode.
    • Prevented concurrent first-time opens from failing, deadlocking, or starving.
    • Databases now gracefully continue when WAL mode is unavailable due to filesystem limitations.
  • Tests

    • Added coverage for concurrent database initialization and lock contention scenarios.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Lcfs1nfA17zSqQGwRn2NeF

claude added 3 commits July 26, 2026 17:56
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
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

open() now activates WAL separately from connection pragmas and retries lock-contention failures with exponential backoff. New tests cover a held rollback-journal lock and eight simultaneous fresh-index opens.

Changes

WAL initialization

Layer / File(s) Summary
WAL activation and retry flow
crates/b2-core/src/db.rs
open() applies connection pragmas before invoking enter_wal_mode, which retries SQLite lock contention with bounded exponential backoff and handles unsupported WAL mode.
Contention regression coverage
crates/b2-core/tests/substrate.rs
Tests verify that a contended first open succeeds and that eight simultaneous fresh-index opens complete successfully.

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
Loading

Possibly related issues

Possibly related PRs

  • AlteredCraft/B2#39 — Previously modified the same database opening and SQLite pragma setup.

Suggested reviewers: claude

Poem

I’m a rabbit guarding WAL,
Through locks and threads, we hop, not fall.
Backoff softly, try once more,
Eight fresh openers reach the door.
SQLite settles, calm and bright.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: retrying the SQLite WAL mode flip under concurrent open contention.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/pr-113-review-yfswdx

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/b2-core/tests/substrate.rs (1)

177-187: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Bind 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

📥 Commits

Reviewing files that changed from the base of the PR and between 488f035 and 5ab2816.

📒 Files selected for processing (2)
  • crates/b2-core/src/db.rs
  • crates/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
@samkeen
samkeen merged commit 276475f into main Jul 26, 2026
2 checks passed
@samkeen
samkeen deleted the claude/pr-113-review-yfswdx branch July 26, 2026 21:29
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.

Concurrent open of a *fresh* index fails with "database is locked" — journal_mode = WAL runs before busy_timeout is set

2 participants