diff --git a/src/db-base.ts b/src/db-base.ts index 8a5c2ba..e55e71a 100644 --- a/src/db-base.ts +++ b/src/db-base.ts @@ -127,7 +127,14 @@ export function loadDatabase(): typeof DatabaseConstructor { readonly: opts?.readonly, create: true, }); - return new BunSQLiteAdapter(raw); + const adapter = new BunSQLiteAdapter(raw); + // Propagate busy_timeout to match better-sqlite3's timeout option. + // Without this, concurrent writes under Bun fail immediately with + // SQLITE_BUSY instead of retrying (better-sqlite3 sets this automatically). + if (opts?.timeout) { + adapter.pragma(`busy_timeout = ${Number(opts.timeout)}`); + } + return adapter; } as any; } else { // Node.js — use better-sqlite3. diff --git a/src/session/db.ts b/src/session/db.ts index 72b4a35..2b361a6 100644 --- a/src/session/db.ts +++ b/src/session/db.ts @@ -343,7 +343,12 @@ export class SessionDB extends SQLiteBase { this.stmt(S.updateMetaLastEvent).run(sessionId); }); - transaction(); + // Use withRetry to handle SQLITE_BUSY under concurrent PostToolUse hooks. + // When many tool calls complete in parallel (e.g., batch get_issue), multiple + // hook processes compete for the write lock on the same SessionDB file. + // better-sqlite3's busy_timeout handles most cases, but withRetry provides + // defense-in-depth for edge cases like lock escalation during transactions. + this.withRetry(() => transaction()); } /** diff --git a/stats.json b/stats.json index cd06b59..4d909ff 100644 --- a/stats.json +++ b/stats.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "label": "users", - "message": "66.4k+", + "message": "75.8k+", "color": "brightgreen", - "npm": "56.1k+", - "marketplace": "10.3k+" + "npm": "65.1k+", + "marketplace": "10.7k+" } diff --git a/tests/session/session-db.test.ts b/tests/session/session-db.test.ts index 5f83578..5b12cda 100644 --- a/tests/session/session-db.test.ts +++ b/tests/session/session-db.test.ts @@ -531,3 +531,41 @@ describe("Limit", () => { assert.equal(limited[2].data, "file-2.ts"); }); }); + +// ════════════════════════════════════════════ +// ADDITIONAL: Concurrent insert resilience +// ════════════════════════════════════════════ + +describe("Concurrent Insert Resilience", () => { + test("multiple DB instances can write to the same file without data loss", () => { + // Simulates concurrent PostToolUse hooks writing to the same SessionDB. + // When many tool calls complete in parallel (e.g., batch get_issue), + // multiple hook processes open the same DB and insert events concurrently. + const dbPath = join(tmpdir(), `session-concurrent-${randomUUID()}.db`); + const instances = Array.from({ length: 5 }, () => { + const db = new SessionDB({ dbPath }); + cleanups.push(() => db.cleanup()); + return db; + }); + + const sid = "sess-concurrent"; + instances[0].ensureSession(sid, "/project"); + + // Each instance inserts unique events + for (let i = 0; i < instances.length; i++) { + instances[i].insertEvent(sid, makeEvent({ + type: "mcp", + data: `get_issue: UXF-${i}`, + })); + } + + // All events should be present — no SQLITE_BUSY failures + const events = instances[0].getEvents(sid); + assert.equal(events.length, 5, `Expected 5 events from concurrent inserts, got ${events.length}`); + + // Clean up all instances + for (const db of instances) { + db.close(); + } + }); +});