Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 34 additions & 2 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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`);
Expand Down
71 changes: 67 additions & 4 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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");
Expand All @@ -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<string, string>; 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,
Expand Down Expand Up @@ -76,6 +80,26 @@ async function runQmd(
return { stdout, stderr, exitCode };
}

async function runQmd(
args: string[],
options: { cwd?: string; env?: Record<string, string>; 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<string, string>; 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++;
Expand Down Expand Up @@ -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"]);
Expand Down
21 changes: 21 additions & 0 deletions test/parallel-startup-harness.ts
Original file line number Diff line number Diff line change
@@ -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 <dbPath>");
process.exit(1);
}

let store: ReturnType<typeof createStore> | 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();
}
111 changes: 111 additions & 0 deletions test/store.helpers.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isDocid,
handelize,
cleanupOrphanedVectors,
configureConnectionPragmas,
} from "../src/store";

// =============================================================================
Expand Down Expand Up @@ -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
// =============================================================================
Expand Down
Loading