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
4 changes: 4 additions & 0 deletions src/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@ export async function openDB(dbPath: string): Promise<DB> {

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
}
Expand Down
55 changes: 55 additions & 0 deletions test/db-busy-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 47 in test/db-busy-timeout.test.ts

View workflow job for this annotation

GitHub Actions / test

error: 4/40 parallel adds failed; first stderr: 429 | }

431 | if (e instanceof Database.SqliteError) { 432 | const extendedCode = e.code; 433 | const code = mapToBaseCode(e.rawCode); 434 | return new LibsqlError(e.message, code, extendedCode, e.rawCode, e); ^ LibsqlError: SQLITE_BUSY: database is locked extendedCode: "SQLITE_BUSY", rawCode: 5, code: "SQLITE_BUSY" at mapSqliteError (/home/runner/work/crm.cli/crm.cli/node_modules/@libsql/client/lib-esm/sqlite3.js:434:16) at executeStmt (/home/runner/work/crm.cli/crm.cli/node_modules/@libsql/client/lib-esm/sqlite3.js:337:15) at execute (/home/runner/work/crm.cli/crm.cli/node_modules/@libsql/client/lib-esm/sqlite3.js:82:16) at openDB (/home/runner/work/crm.cli/crm.cli/src/db.ts:92:18) at getCtx (/home/runner/work/crm.cli/crm.cli/src/lib/helpers.ts:39:20) at <anonymous> (/home/runner/work/crm.cli/crm.cli/src/commands/contact.ts:49:36) at _parseCommand (/home/runner/work/crm.cli/crm.cli/node_modules/commander/lib/command.js:1585:27) at _dispatchSubcommand (/home/runner/work/crm.cli/crm.cli/node_modules/commander/lib/command.js:1345:25) at _dispatchSubcommand (/home/runner/work/crm.cli/crm.cli/node_modules/commander/lib/command.js:1345:25) at parse (/home/runner/work/crm.cli/crm.cli/node_modules/commander/lib/command.js:1075:10) Bun v1.3.13 (Linux x64) at <anonymous> (/home/runner/work/crm.cli/crm.cli/test/db-busy-timeout.test.ts:47:17)
`${failures.length}/${N} parallel adds failed; first stderr: ${failures[0].stderr}`,
)
}

const list = ctx.runJSON<unknown[]>('contact', 'list', '--format', 'json')
expect(list).toHaveLength(N)
}, 30_000)
})
Loading