fix(db): install busy_timeout before the connection's first statement (#12394)

getDbInstance() ran PRAGMA journal_mode = WAL as the connection's first statement, before PRAGMA busy_timeout, and openSqliteDatabase() passes no driver-level timeout. A process opening the database while another closed its WAL connection — checkpoint plus WAL delete hold an EXCLUSIVE lock for a few hundred microseconds — therefore died with `database is locked` instead of waiting. That is the flake behind exclusive-connection-leases.test.ts on release/v3.8.51 runs 33525300898 and 33493797519 and on unrelated PR runs.

The second half is worse than the flake: isTransientProbeError matched /SQLITE_BUSY/ against error.message, but both drivers report the plain text `database is locked` and put the code in .code / .errcode. A transient lock during the corruption probe therefore took the corrupt-database path and renamed the file to storage.sqlite.probe-failed-… with "Manual recovery required". The probe now recognises the drivers' real BUSY/PROTOCOL/IOERR signals.

Validated in a combined worktree with all 25 PRs of this batch boarded together: typecheck:core clean, 443/443 node-runner tests plus 14/14 vitest across every test file the batch touches, and check-changelog-integrity, check:cycles (418 files), check:provider-consistency (272 REGISTRY entries, 355 canonical providers), check:docs-counts, check:docs-sync (42 locales) and check-file-size all green.

Thanks @pacocartones.
This commit is contained in:
Paco Cartones
2026-09-02 08:13:41 +02:00
committed by GitHub
parent bb5c6d148e
commit c8e2cb3ffc
4 changed files with 138 additions and 2 deletions

View File

@@ -1277,13 +1277,18 @@ export function getDbInstance(): SqliteDatabase {
// selected on the server's primary DB path too, not only the backup-import
// route.
console.log(`[DB] Driver: ${db.driver} | file: ${sqliteFile}`);
db.pragma("journal_mode = WAL");
// better-sqlite3 is synchronous, so a contended write parks the Node event loop for up to
// busy_timeout ms (a 0-CPU freeze that stacks under load → /health stops responding). The
// hot-path writers here (usage_history, call_logs) are best-effort and the WinUI host opens
// the same DB, so cap the block at 2s instead of 5s: normal writes complete in <1ms, and a
// contended op can no longer freeze the loop past the host watchdog's 6s liveness probe.
//
// Install the busy handler before the connection's first statement. `journal_mode = WAL`
// needs a SHARED lock, and another process closing its WAL connection briefly holds the
// file EXCLUSIVE (checkpoint + WAL delete); node:sqlite opens with busy timeout 0, so with
// the pragmas in the other order that window surfaced as `database is locked` at startup.
db.pragma("busy_timeout = 2000");
db.pragma("journal_mode = WAL");
db.pragma("synchronous = NORMAL");
db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`);
db.pragma("temp_store = MEMORY");

View File

@@ -22,7 +22,19 @@ import path from "node:path";
*/
export function isTransientProbeError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return /SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT/i.test(message);
if (/SQLITE_BUSY|SQLITE_PROTOCOL|SQLITE_IOERR|ENOENT|database is locked/i.test(message)) {
return true;
}
// The real drivers do not put the result-code name in the message: both
// report plain "database is locked" for SQLITE_BUSY. better-sqlite3 carries
// the name in `code`, node:sqlite the numeric primary code in `errcode`
// (5 BUSY, 10 IOERR, 15 PROTOCOL; extended codes live in the high bits).
// Without this, a transient lock during the probe was classified as
// corruption and the database was renamed away.
if (typeof error !== "object" || error === null) return false;
const { code, errcode } = error as { code?: unknown; errcode?: unknown };
if (typeof code === "string" && /^SQLITE_(BUSY|PROTOCOL|IOERR)/.test(code)) return true;
return typeof errcode === "number" && [5, 10, 15].includes(errcode & 0xff);
}
/**