Skip to content
Merged
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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@
- The embed session `maxDuration` is now env-configurable via
`QMD_EMBED_MAX_DURATION_MS` (default: 30 min). This prevents large-corpus
embeddings from being aborted by the hardcoded 30-minute ceiling (#673).
- `qmd query`, `qmd update`, and other commands no longer fail with
`SQLiteError: database is locked` when multiple processes run against the
same index in parallel (e.g. an `update` racing a long `embed`, the
first-open schema migration racing a routine command, or an agent fanning
out searches). `openDatabase` now sets `PRAGMA busy_timeout = 120000` on
every connection, so a writer that loses the race queues at batch
boundaries instead of throwing immediately. WAL handles read/write
concurrency but does not serialise concurrent writers, and `bun:sqlite`
and `better-sqlite3` both default the timeout to 0, so the loser
previously failed on the first DDL statement in `initializeDatabase`.
Override the default with `QMD_SQLITE_BUSY_TIMEOUT` (milliseconds; `0`
restores fail-fast). Two more crashes on the same concurrent-open
path are fixed: `trigger documents_ai already exists` (the FTS sync
triggers were dropped and recreated as separate statements on every
open, so two processes interleaved between the `DROP` and the
`CREATE`; `busy_timeout` serialises individual statements but not the
pair) and `database is locked` while migrating a cold database to WAL
(the `journal_mode` switch needs a brief exclusive lock and does not
invoke the busy handler). FTS trigger setup is now gated behind
`PRAGMA user_version` inside one `IMMEDIATE` transaction, and the WAL
migration retries within the busy-timeout budget.

## [2.5.3] - 2026-05-28

Expand Down
56 changes: 55 additions & 1 deletion src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,65 @@ if (isBun) {
_sqliteVecLoad = (db: LoadableSqliteDatabase) => sqliteVec.load(db as Parameters<typeof sqliteVec.load>[0]);
}

function isBusyError(err: unknown): boolean {
if (typeof err !== "object" || err === null) return false;
const code = (err as { code?: unknown }).code;
if (code === "SQLITE_BUSY" || code === "SQLITE_BUSY_SNAPSHOT") return true;
const message = (err as { message?: unknown }).message;
return typeof message === "string" && /database is locked|database is busy|SQLITE_BUSY/i.test(message);
}

function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

/**
* Switch a connection to WAL, retrying on `SQLITE_BUSY` within the busy-timeout
* budget. Unlike ordinary writes, migrating the journal needs a brief exclusive
* lock and does NOT invoke the busy handler, so concurrent first-time opens of a
* cold database throw "database is locked" even with `busy_timeout` set. Once the
* database is already WAL the pragma is a cheap no-op that does not contend.
*/
function enableWal(db: Database, budgetMs: number): void {
const deadline = Date.now() + Math.max(budgetMs, 0);
for (let attempt = 0; ; attempt++) {
try {
db.exec("PRAGMA journal_mode = WAL");
return;
} catch (err) {
if (!isBusyError(err) || Date.now() >= deadline) throw err;
sleepSync(Math.min(5 + attempt, 25));
}
}
}

/**
* Open a SQLite database. Works with both bun:sqlite and better-sqlite3.
*
* `bun:sqlite` and `better-sqlite3` both default `busy_timeout` to 0, so
* concurrent writers throw `SQLITE_BUSY` instead of waiting. WAL improves
* read-while-write concurrency but does not serialise writers. Setting the
* timeout at connection open makes parallel processes (e.g. an `update` or
* `query` racing a long `embed`, or a first-open schema migration racing any
* routine command) queue at batch boundaries instead of failing on contact.
*
* WAL is enabled here too (with a bounded retry) so connection-level pragmas
* live in one place and the cold-database journal migration survives concurrent
* opens.
*
* Default 120_000 ms outlasts the worst-case batch commit on a multi-GB
* index. Override with `QMD_SQLITE_BUSY_TIMEOUT` (value in milliseconds; `0`
* restores the upstream fail-fast behaviour). See
* https://bun.sh/docs/api/sqlite#busy-timeout.
*/
export function openDatabase(path: string): Database {
return new _Database(path) as Database;
const db = new _Database(path) as Database;
const raw = process.env.QMD_SQLITE_BUSY_TIMEOUT;
const parsed = raw !== undefined && raw !== "" ? Number(raw) : Number.NaN;
const busyTimeoutMs = Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 120_000;
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
enableWal(db, busyTimeoutMs);
return db;
}

/**
Expand Down
118 changes: 75 additions & 43 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,10 @@ const CJK_CHAR_PATTERN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\
const CJK_RUN_PATTERN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/gu;
const FTS_CJK_NORMALIZED_VERSION = "1";

// Bump when any FTS sync trigger body in applyFtsSyncTriggers changes, so the
// new definition is reapplied to existing databases on next open.
const STORE_SCHEMA_VERSION = 1;

/**
* FTS5's unicode61 tokenizer does not segment CJK text into searchable words.
* Normalize CJK runs by spacing every character so exact CJK queries can be
Expand Down Expand Up @@ -818,6 +822,76 @@ function rebuildFTSForCjkNormalization(db: Database): void {
`).run(FTS_CJK_NORMALIZED_VERSION);
}

function getUserVersion(db: Database): number {
const row = db.prepare(`PRAGMA user_version`).get() as Record<string, number> | undefined;
const value = row ? Object.values(row)[0] : 0;
return typeof value === "number" ? value : Number(value) || 0;
}

// FTS sync triggers keep documents_fts current for callers that write directly
// to documents (production indexing rebuilds FTS in TypeScript to normalize CJK
// first). The bodies use DROP+CREATE rather than CREATE IF NOT EXISTS so a
// changed body propagates to existing databases. DROP and CREATE are separate
// autocommit statements, so concurrent opens of one database interleave across
// connections (A drops, B drops, A creates, B creates -> "trigger already
// exists"); busy_timeout serializes individual statements but not the pair.
// Gate the work behind PRAGMA user_version and apply it inside one IMMEDIATE
// transaction: the DROP+CREATE pair is atomic across connections, and a
// double-checked read skips it once any process has stamped the version.
function applyFtsSyncTriggers(db: Database): void {
if (getUserVersion(db) >= STORE_SCHEMA_VERSION) return;
db.exec(`BEGIN IMMEDIATE`);
try {
if (getUserVersion(db) < STORE_SCHEMA_VERSION) {
db.exec(`DROP TRIGGER IF EXISTS documents_ai`);
db.exec(`
CREATE TRIGGER documents_ai AFTER INSERT ON documents
WHEN new.active = 1
BEGIN
INSERT INTO documents_fts(rowid, filepath, title, body)
SELECT
new.id,
new.collection || '/' || new.path,
new.title,
(SELECT doc FROM content WHERE hash = new.hash)
WHERE new.active = 1;
END
`);

db.exec(`DROP TRIGGER IF EXISTS documents_ad`);
db.exec(`
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
DELETE FROM documents_fts WHERE rowid = old.id;
END
`);

db.exec(`DROP TRIGGER IF EXISTS documents_au`);
db.exec(`
CREATE TRIGGER documents_au AFTER UPDATE ON documents
BEGIN
-- Delete from FTS if no longer active
DELETE FROM documents_fts WHERE rowid = old.id AND new.active = 0;

-- Update FTS if still/newly active
INSERT OR REPLACE INTO documents_fts(rowid, filepath, title, body)
SELECT
new.id,
new.collection || '/' || new.path,
new.title,
(SELECT doc FROM content WHERE hash = new.hash)
WHERE new.active = 1;
END
`);

db.exec(`PRAGMA user_version = ${STORE_SCHEMA_VERSION}`);
}
db.exec(`COMMIT`);
} catch (err) {
db.exec(`ROLLBACK`);
throw err;
}
}

function initializeDatabase(db: Database): void {
try {
loadSqliteVec(db);
Expand All @@ -830,7 +904,6 @@ function initializeDatabase(db: Database): void {
_sqliteVecUnavailableReason = getErrorMessage(err);
console.warn(_sqliteVecUnavailableReason);
}
db.exec("PRAGMA journal_mode = WAL");
db.exec("PRAGMA foreign_keys = ON");

// Drop legacy tables that are now managed in YAML
Expand Down Expand Up @@ -920,48 +993,7 @@ function initializeDatabase(db: Database): void {
)
`);

// Triggers keep FTS in sync for callers that write directly to documents.
// Production indexing paths rebuild entries in TypeScript so CJK text can be
// normalized before it reaches the unicode61 tokenizer.
db.exec(`DROP TRIGGER IF EXISTS documents_ai`);
db.exec(`
CREATE TRIGGER documents_ai AFTER INSERT ON documents
WHEN new.active = 1
BEGIN
INSERT INTO documents_fts(rowid, filepath, title, body)
SELECT
new.id,
new.collection || '/' || new.path,
new.title,
(SELECT doc FROM content WHERE hash = new.hash)
WHERE new.active = 1;
END
`);

db.exec(`DROP TRIGGER IF EXISTS documents_ad`);
db.exec(`
CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN
DELETE FROM documents_fts WHERE rowid = old.id;
END
`);

db.exec(`DROP TRIGGER IF EXISTS documents_au`);
db.exec(`
CREATE TRIGGER documents_au AFTER UPDATE ON documents
BEGIN
-- Delete from FTS if no longer active
DELETE FROM documents_fts WHERE rowid = old.id AND new.active = 0;

-- Update FTS if still/newly active
INSERT OR REPLACE INTO documents_fts(rowid, filepath, title, body)
SELECT
new.id,
new.collection || '/' || new.path,
new.title,
(SELECT doc FROM content WHERE hash = new.hash)
WHERE new.active = 1;
END
`);
applyFtsSyncTriggers(db);

rebuildFTSForCjkNormalization(db);
}
Expand Down
30 changes: 30 additions & 0 deletions test/_helpers/store-init-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* store-init-worker - one subprocess that opens a store and exits.
*
* Spawned N-up by store-concurrency.test.ts to reproduce the cross-connection
* schema-init race. Each worker busy-waits until a shared wall-clock start time
* so every process hits initializeDatabase at once, then opens the store. Exits
* 0 on success, 1 if createStore throws (e.g. "trigger already exists").
*/
import { createStore } from "../../src/store.ts";

const dbPath = process.argv[2];
const startAtMs = Number(process.argv[3] ?? "0");

if (!dbPath) {
console.error("usage: store-init-worker <dbPath> [startAtMs]");
process.exit(2);
}

while (Date.now() < startAtMs) {
// Align all workers to the same start so their DROP/CREATE windows overlap.
}

try {
const store = createStore(dbPath);
store.close();
process.exit(0);
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exit(1);
}
Loading