import type { Database } from 'bun:sqlite'; /** Total time `ensureWalMode` will wait for a competing opener to finish. */ const WAL_SWITCH_ATTEMPTS = 20; const WAL_SWITCH_DELAY_MS = 10; /** * How long a connection waits for a lock before giving up. Covers ordinary * contention and concurrent DDL; it does NOT cover the WAL switch itself — * that is what `ensureWalMode` is for. */ export const BUSY_TIMEOUT_MS = 5000; /** * Put the connection in WAL mode, tolerating a concurrent opener (#552). * * Two processes opening the SAME BRAND-NEW database race here: converting a * rollback-journal db to WAL needs an exclusive lock, so whichever arrives * second while the first still holds the file gets SQLITE_BUSY and the open * throws. Once any connection completes the conversion every later opener is * fine, so the window is tiny — which made it a rare CI flake that reddened * unrelated PRs and passed on re-run. * * Two things that look like fixes and are not: * * 1. Moving `PRAGMA busy_timeout` above this call. `busy_timeout` does not * apply to journal_mode transitions. Measured: with the timeout set FIRST * and at 2000ms, the conversion still throws SQLITE_BUSY. The reorder is a * plausible-looking no-op. * 2. Pre-creating the db in whichever test happened to expose it. That hides * the race for one caller and leaves the next opener unsafe. * * So the retry is by hand. Re-reading the mode on failure is the point: the * competing process converting the db is the *expected* outcome, not an error — * if it won, we are already in WAL and there is nothing left to do. * * The reads can hit SQLITE_BUSY too (#1145): CI run 5154 died on the first one, * before the loop. A BUSY read means another opener holds the file, the same * evidence as a BUSY write, so it reads as "not WAL yet" and falls into the * retry. busy_timeout does cover an ordinary locked read (measured: a 5 s * timeout waited out a 1.5 s exclusive hold), so each read in the loop already * waits that long. The 20 x 10 ms sleeps only pace the BUSY answers SQLite gives * without calling the busy handler, which is what 5154 hit. */ export function ensureWalMode(db: Database): void { const readMode = () => { try { return db .query<{ journal_mode: string }, []>('PRAGMA journal_mode') .get() ?.journal_mode?.toLowerCase(); } catch (error) { if ((error as { code?: string }).code?.startsWith('SQLITE_BUSY')) return undefined; throw error; } }; if (readMode() === 'wal') return; let lastError: unknown; for (let attempt = 0; attempt < WAL_SWITCH_ATTEMPTS; attempt++) { try { db.exec('PRAGMA journal_mode = WAL'); return; } catch (error) { lastError = error; if (readMode() === 'wal') return; // the other opener won the race — that is success Bun.sleepSync(WAL_SWITCH_DELAY_MS); } } throw lastError; }