Skip to content

fix(sessions): retry a WAL conversion that collides on a fresh database - #1

Merged
bompus merged 1 commit into
pr/session-indexfrom
sessions/wal-conversion-retry
Sep 6, 2026
Merged

fix(sessions): retry a WAL conversion that collides on a fresh database#1
bompus merged 1 commit into
pr/session-indexfrom
sessions/wal-conversion-retry

Conversation

@bompus

@bompus bompus commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Follow-up to colbymchenry#1702targets that branch, not main. It closes the residual first-run race I measured in my comment there.

What's actually broken

colbymchenry#1702's head commit sets busy_timeout before the schema writes, which is right and fixes most of it. But six processes opening the same fresh sessions.db still failed 11 times in 120 runs, and attributing each failure by frame put every single one on one statement — PRAGMA journal_mode = WAL — never the schema write, never refresh().

I first assumed busy_timeout just doesn't apply to that pragma. That was wrong, and I want to be precise about it because it changes the fix. It does apply: a holder takes BEGIN EXCLUSIVE on a fresh database and commits after 400 ms, and a second connection with a 5 s budget waits 429 ms and converts fine. An ordinary lock wait is covered.

What isn't covered is several processes converting the same brand-new file at the same moment. They collide inside the conversion rather than queueing on a lock. Same 5 s budget, six concurrent converters on a fresh file: 2/60 still threw. This is a first-run-only defect — WAL is persistent, so nothing converts the file twice — but the first run is exactly when parallel tool calls all open the index at once.

The fix

Retry the conversion on either transient error inside the budget it already has, with jitter.

Both errors are transient. database is locked is the conversion losing the race; disk I/O error is the -shm file being created underneath a concurrent opener (I saw this on Windows). Retrying only the first is what makes the difference between "mostly fixed" and fixed:

arm failures
colbymchenry#1702 head, unconditional conversion 22 / 300
retry on database is locked only 22 / 300
retry on both 0 / 480 (two independent samples)

Arms were round-robined across trials so no arm ate the cold start.

I also tested just tolerating a failed conversion and staying in the default journal mode. That does not work — the connection doesn't survive the failure and the next statement on it fails too (FAIL mode, FAIL schema in my harness). So retry is the option, not fallback.

Tests

Two, plus one existing behaviour now pinned:

  • enterWalMode retries each transient error and succeeds — driven directly, because the collision only reproduces probabilistically and I'm not shipping you a flaky test.
  • enterWalMode rethrows a non-transient error rather than spinning until the deadline.
  • A held-lock open converts once the holder commits — this documents the part busy_timeout does cover, which is what I originally got wrong.

Being straight about the limits of these: the unit tests exercise the retry contract, they don't reproduce the race. The evidence that the race is closed is the 480-run harness, not the suite.

enterWalMode is exported for that first test; the alternative was a probabilistic test.

Verification

  • tsc --noEmit clean.
  • __tests__/sessions-index.test.ts11/11 pass, and the two new tests fail without the change.
  • The full suite has widespread failures on my host (Windows, no native kernel binary, git-fixture errors). I checked them at the parent commit with the change reverted and they are identical, so they're pre-existing and unrelated — but I haven't run a clean full suite, and you should assume CI is the real check.

Absolute rates are Windows + WAL with six processes hammering one new database, so they're environment-specific. The arm-to-arm comparison is same-host and interleaved, which is the part I'd stand behind.

`busy_timeout` covers an ordinary lock wait on `journal_mode = WAL` — a
connection merely holding the database is waited out and the conversion then
succeeds. What it does not cover is several processes converting the same
brand-new file at the same moment: they collide inside the conversion rather
than queueing on a lock, and one loses.

Only the first run can hit this, since WAL is persistent in the file, but the
first run is exactly when parallel tool calls all open the index at once.

Both errors the collision raises are transient. Retrying either inside the
existing busy budget takes six concurrent openers of one fresh database from
22 failures in 300 runs to 0 in 480. Tolerating a failed conversion instead
does not work: the connection does not survive one, and the next statement on
it fails too.
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.

1 participant