diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ace379a..8881b758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ ### Fixes +- Configure SQLite connection pragmas before probing `sqlite-vec`, avoid + resetting `PRAGMA journal_mode = WAL` on every query startup, and tolerate + another process winning the WAL transition race so parallel readers don't + fail during initialization with transient `database is locked` errors. - Sync stale `bun.lock` (`better-sqlite3` 11.x → 12.x). CI and release script now use `--frozen-lockfile` to prevent recurrence. #386 (thanks @Mic92) diff --git a/src/store.ts b/src/store.ts index f17404d8..fe6a9f03 100644 --- a/src/store.ts +++ b/src/store.ts @@ -638,7 +638,41 @@ export function verifySqliteVecLoaded(db: Database): void { let _sqliteVecAvailable: boolean | null = null; +const DEFAULT_BUSY_TIMEOUT_MS = 5_000; + +function isBusyLockError(err: unknown): boolean { + const message = getErrorMessage(err).toLowerCase(); + return message.includes("database is locked") || message.includes("sqlite_busy"); +} + +export function configureConnectionPragmas(db: Database): void { + db.exec(`PRAGMA busy_timeout = ${DEFAULT_BUSY_TIMEOUT_MS}`); + + let journalModeRow = db.prepare("PRAGMA journal_mode").get() as { journal_mode?: string } | null; + if (journalModeRow?.journal_mode?.toLowerCase() !== "wal") { + try { + db.exec("PRAGMA journal_mode = WAL"); + } catch (err) { + if (!isBusyLockError(err)) { + throw err; + } + + // Two qmd processes can both observe a non-WAL database and then race to + // become the one that flips the shared file into WAL mode. Losing that + // race should not abort startup; re-probe for observability, then proceed. + journalModeRow = db.prepare("PRAGMA journal_mode").get() as { journal_mode?: string } | null; + } + } + + db.exec("PRAGMA foreign_keys = ON"); +} + function initializeDatabase(db: Database): void { + // Configure the connection before any probe queries. Parallel qmd processes + // can race during startup; without a busy timeout even read-only probe work + // like `SELECT vec_version()` can fail immediately with SQLITE_BUSY. + configureConnectionPragmas(db); + try { loadSqliteVec(db); verifySqliteVecLoaded(db); @@ -648,8 +682,6 @@ function initializeDatabase(db: Database): void { _sqliteVecAvailable = false; console.warn(getErrorMessage(err)); } - db.exec("PRAGMA journal_mode = WAL"); - db.exec("PRAGMA foreign_keys = ON"); // Drop legacy tables that are now managed in YAML db.exec(`DROP TABLE IF EXISTS path_contexts`); diff --git a/test/cli.test.ts b/test/cli.test.ts index 7d6f5267..b76ac4c9 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -11,7 +11,7 @@ import { existsSync, lstatSync, readFileSync, symlinkSync, writeFileSync, unlink import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; -import { spawn } from "child_process"; +import { spawn, spawnSync } from "child_process"; import { setTimeout as sleep } from "timers/promises"; // Test fixtures directory and database path @@ -25,6 +25,7 @@ let testCounter = 0; // Unique counter for each test run const thisDir = dirname(fileURLToPath(import.meta.url)); const projectRoot = join(thisDir, ".."); const qmdScript = join(projectRoot, "src", "cli", "qmd.ts"); +const parallelStartupHarness = join(projectRoot, "test", "parallel-startup-harness.ts"); // Resolve tsx binary from project's node_modules (not cwd-dependent) const tsxBin = (() => { const candidate = join(projectRoot, "node_modules", ".bin", "tsx"); @@ -33,16 +34,19 @@ const tsxBin = (() => { } return join(process.cwd(), "node_modules", ".bin", "tsx"); })(); +const bunBin = "bun"; +const bunAvailable = spawnSync(bunBin, ["--version"], { stdio: "ignore" }).status === 0; // Helper to run qmd command with test database -async function runQmd( - args: string[], +async function runQmdCommand( + command: string, + commandArgs: string[], options: { cwd?: string; env?: Record; dbPath?: string; configDir?: string } = {} ): Promise<{ stdout: string; stderr: string; exitCode: number }> { const workingDir = options.cwd || fixturesDir; const dbPath = options.dbPath || testDbPath; const configDir = options.configDir || testConfigDir; - const proc = spawn(tsxBin, [qmdScript, ...args], { + const proc = spawn(command, commandArgs, { cwd: workingDir, env: { ...process.env, @@ -76,6 +80,26 @@ async function runQmd( return { stdout, stderr, exitCode }; } +async function runQmd( + args: string[], + options: { cwd?: string; env?: Record; dbPath?: string; configDir?: string } = {} +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return runQmdCommand(tsxBin, [qmdScript, ...args], options); +} + +async function runQmdWithBun( + args: string[], + options: { cwd?: string; env?: Record; dbPath?: string; configDir?: string } = {} +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return runQmdCommand(bunBin, [qmdScript, ...args], options); +} + +async function runParallelStartupHarness( + dbPath: string +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return runQmdCommand(bunBin, [parallelStartupHarness, dbPath], { cwd: projectRoot, dbPath }); +} + // Get a fresh database path for isolated tests function getFreshDbPath(): string { testCounter++; @@ -224,6 +248,45 @@ beforeEach(async () => { ); }); +describe("CLI parallel startup regression", () => { + const parallelStartupTest = bunAvailable ? test : test.skip; + + function expectSuccessfulStartup(result: { stdout: string; stderr: string; exitCode: number }): void { + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("database is locked"); + expect(result.stderr).not.toContain("SQLITE_BUSY"); + expect(result.stderr).not.toContain("sqlite-vec probe failed"); + expect(result.stdout).toContain("startup-ok"); + } + + parallelStartupTest("allows two Bun qmd processes to initialize the same fresh DB concurrently", async () => { + const dbPath = getFreshDbPath(); + + const [first, second] = await Promise.all([ + runParallelStartupHarness(dbPath), + runParallelStartupHarness(dbPath), + ]); + + expectSuccessfulStartup(first); + expectSuccessfulStartup(second); + }, 15000); + + parallelStartupTest("allows two Bun qmd processes to initialize the same existing DB concurrently", async () => { + const dbPath = getFreshDbPath(); + + const warmup = await runParallelStartupHarness(dbPath); + expectSuccessfulStartup(warmup); + + const [first, second] = await Promise.all([ + runParallelStartupHarness(dbPath), + runParallelStartupHarness(dbPath), + ]); + + expectSuccessfulStartup(first); + expectSuccessfulStartup(second); + }, 15000); +}); + describe("CLI Help", () => { test("shows help with --help flag", async () => { const { stdout, exitCode } = await runQmd(["--help"]); diff --git a/test/parallel-startup-harness.ts b/test/parallel-startup-harness.ts new file mode 100644 index 00000000..279b112c --- /dev/null +++ b/test/parallel-startup-harness.ts @@ -0,0 +1,21 @@ +import { createStore } from "../src/store.js"; + +const dbPath = process.argv[2]; + +if (!dbPath) { + console.error("Usage: bun test/parallel-startup-harness.ts "); + process.exit(1); +} + +let store: ReturnType | undefined; + +try { + store = createStore(dbPath); + store.getStatus(); + console.log("startup-ok"); +} catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +} finally { + store?.close(); +} diff --git a/test/store.helpers.unit.test.ts b/test/store.helpers.unit.test.ts index eb7f8a63..135012e8 100644 --- a/test/store.helpers.unit.test.ts +++ b/test/store.helpers.unit.test.ts @@ -16,6 +16,7 @@ import { isDocid, handelize, cleanupOrphanedVectors, + configureConnectionPragmas, } from "../src/store"; // ============================================================================= @@ -109,6 +110,116 @@ describe("cleanupOrphanedVectors", () => { }); }); +// ============================================================================= +// Connection pragma tests +// ============================================================================= + +describe("configureConnectionPragmas", () => { + test("skips resetting journal mode when database is already in WAL mode", () => { + const execCalls: string[] = []; + const db = { + exec: (sql: string) => execCalls.push(sql), + prepare: (sql: string) => { + expect(sql).toBe("PRAGMA journal_mode"); + return { get: () => ({ journal_mode: "wal" }) }; + }, + } as any; + + configureConnectionPragmas(db); + + expect(execCalls).toEqual([ + "PRAGMA busy_timeout = 5000", + "PRAGMA foreign_keys = ON", + ]); + }); + + test("enables WAL once when database is not already in WAL mode", () => { + const execCalls: string[] = []; + const db = { + exec: (sql: string) => execCalls.push(sql), + prepare: (sql: string) => { + expect(sql).toBe("PRAGMA journal_mode"); + return { get: () => ({ journal_mode: "delete" }) }; + }, + } as any; + + configureConnectionPragmas(db); + + expect(execCalls).toEqual([ + "PRAGMA busy_timeout = 5000", + "PRAGMA journal_mode = WAL", + "PRAGMA foreign_keys = ON", + ]); + }); + + test("tolerates a busy WAL switch when another process wins the race", () => { + const execCalls: string[] = []; + let journalModeReads = 0; + const db = { + exec: (sql: string) => { + execCalls.push(sql); + if (sql === "PRAGMA journal_mode = WAL") { + throw new Error("database is locked"); + } + }, + prepare: (sql: string) => { + expect(sql).toBe("PRAGMA journal_mode"); + return { + get: () => ({ journal_mode: journalModeReads++ === 0 ? "delete" : "wal" }), + }; + }, + } as any; + + expect(() => configureConnectionPragmas(db)).not.toThrow(); + expect(execCalls).toEqual([ + "PRAGMA busy_timeout = 5000", + "PRAGMA journal_mode = WAL", + "PRAGMA foreign_keys = ON", + ]); + }); + + test("continues when WAL switch is busy and follow-up probe still reports non-WAL", () => { + const execCalls: string[] = []; + const db = { + exec: (sql: string) => { + execCalls.push(sql); + if (sql === "PRAGMA journal_mode = WAL") { + throw new Error("SQLITE_BUSY_RECOVERY: database is locked"); + } + }, + prepare: (sql: string) => { + expect(sql).toBe("PRAGMA journal_mode"); + return { + get: () => ({ journal_mode: "delete" }), + }; + }, + } as any; + + expect(() => configureConnectionPragmas(db)).not.toThrow(); + expect(execCalls).toEqual([ + "PRAGMA busy_timeout = 5000", + "PRAGMA journal_mode = WAL", + "PRAGMA foreign_keys = ON", + ]); + }); + + test("rethrows non-lock WAL errors", () => { + const db = { + exec: (sql: string) => { + if (sql === "PRAGMA journal_mode = WAL") { + throw new Error("disk I/O error"); + } + }, + prepare: (sql: string) => { + expect(sql).toBe("PRAGMA journal_mode"); + return { get: () => ({ journal_mode: "delete" }) }; + }, + } as any; + + expect(() => configureConnectionPragmas(db)).toThrow("disk I/O error"); + }); +}); + // ============================================================================= // Handelize Tests // =============================================================================