From df53c6a08f57022da716fb67e88809b3eb7a1095 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Sat, 2 May 2026 16:59:17 +0000 Subject: [PATCH] fix: set PRAGMA busy_timeout=5000 to handle concurrent writers SQLite is single-writer. Without busy_timeout, parallel `crm contact add` calls under load (e.g. xargs -P or several agent CLIs writing at once) immediately surface SQLITE_BUSY: DrizzleQueryError: Failed query: insert into "contacts" ... cause: SqliteError: database is locked 5s is enough to ride out any realistic burst. Verified: 40 parallel adds drop from ~4 failures to 0. --- src/db.ts | 4 +++ test/db-busy-timeout.test.ts | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 test/db-busy-timeout.test.ts diff --git a/src/db.ts b/src/db.ts index 6fc4212..1e7c5ae 100644 --- a/src/db.ts +++ b/src/db.ts @@ -94,6 +94,10 @@ export async function openDB(dbPath: string): Promise { await client.execute('PRAGMA journal_mode=WAL') await client.execute('PRAGMA foreign_keys=ON') + // Wait up to 5s for a busy lock instead of erroring immediately. SQLite + // is single-writer; without this, concurrent writes (e.g. parallel CLI + // invocations or daemon + CLI) hit SQLITE_BUSY and surface to the user. + await client.execute('PRAGMA busy_timeout=5000') return db } diff --git a/test/db-busy-timeout.test.ts b/test/db-busy-timeout.test.ts new file mode 100644 index 0000000..1483a34 --- /dev/null +++ b/test/db-busy-timeout.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test' +import { join } from 'node:path' + +import { createTestContext } from './helpers.ts' + +/** + * `PRAGMA busy_timeout` makes concurrent writers wait for the SQLite write + * lock instead of immediately erroring. Without it, parallel `crm contact + * add` calls return `SQLITE_BUSY: database is locked` under load. + */ +describe('SQLite busy timeout', () => { + test('parallel contact adds all succeed under contention', async () => { + const ctx = createTestContext() + // Empirically: N=20 doesn't reliably contend on this machine, but + // N=40 produces ~4 SQLITE_BUSY failures consistently when the + // busy_timeout PRAGMA isn't set. + const N = 40 + const procs = Array.from({ length: N }, (_, i) => + Bun.spawn( + [ + 'bun', + 'run', + join(import.meta.dir, '..', 'src', 'cli.ts'), + '--db', + ctx.dbPath, + '--config', + ctx.configPath, + 'contact', + 'add', + '--name', + `User ${i}`, + '--email', + `u${i}@busy.test`, + ], + { stdout: 'pipe', stderr: 'pipe' }, + ), + ) + const results = await Promise.all( + procs.map(async (p) => ({ + exitCode: await p.exited, + stderr: await new Response(p.stderr).text(), + })), + ) + const failures = results.filter((r) => r.exitCode !== 0) + if (failures.length > 0) { + // Surface the first failure so the test output is actionable. + throw new Error( + `${failures.length}/${N} parallel adds failed; first stderr: ${failures[0].stderr}`, + ) + } + + const list = ctx.runJSON('contact', 'list', '--format', 'json') + expect(list).toHaveLength(N) + }, 30_000) +})