From 40062cd084e657d1c960b00e8c294f5a9f448858 Mon Sep 17 00:00:00 2001 From: stack72 Date: Thu, 9 Apr 2026 14:32:22 +0100 Subject: [PATCH] fix: add application-level retry for SQLite init under concurrent access PRAGMA journal_mode=WAL requires an exclusive lock to switch modes. When multiple processes (e.g. concurrent workflow runs) open the same database simultaneously, the SQLite busy handler does not reliably cover the mode switch, causing "database is locked" crashes. Add exponential backoff retry around the WAL pragma and schema creation in both CatalogStore and ExtensionCatalogStore. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../persistence/catalog_store.ts | 54 +++++++++++++++---- .../persistence/extension_catalog_store.ts | 38 +++++++++++-- 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/src/infrastructure/persistence/catalog_store.ts b/src/infrastructure/persistence/catalog_store.ts index d26ff873..f795b7a5 100644 --- a/src/infrastructure/persistence/catalog_store.ts +++ b/src/infrastructure/persistence/catalog_store.ts @@ -75,18 +75,50 @@ export class CatalogStore { ensureDirSync(dirname(dbPath)); this.db = new DatabaseSync(dbPath); this.db.exec("PRAGMA busy_timeout=5000"); - this.db.exec("PRAGMA journal_mode=WAL"); + this.initializeWithRetry(); + } - // Ensure catalog_meta exists so migrateIfNeeded() can read schema_version. - // Must run before createSchema() because v2-only indexes fail on a v1 table. - this.db.exec(` - CREATE TABLE IF NOT EXISTS catalog_meta ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - `); - this.migrateIfNeeded(); - this.createSchema(); + /** + * Initializes WAL mode and schema with retry logic. + * PRAGMA journal_mode=WAL requires an exclusive lock to switch modes. + * When multiple processes open the database simultaneously, the SQLite + * busy handler may not cover the mode switch reliably, so we retry at + * the application level with exponential backoff. + */ + private initializeWithRetry(): void { + const MAX_RETRIES = 5; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + this.db.exec("PRAGMA journal_mode=WAL"); + + // Ensure catalog_meta exists so migrateIfNeeded() can read schema_version. + // Must run before createSchema() because v2-only indexes fail on a v1 table. + this.db.exec(` + CREATE TABLE IF NOT EXISTS catalog_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + `); + this.migrateIfNeeded(); + this.createSchema(); + return; + } catch (error: unknown) { + const isLock = error instanceof Error && + /database is (locked|busy)/i.test(error.message); + if (isLock && attempt < MAX_RETRIES) { + const delay = 100 * Math.pow(2, attempt) + + Math.floor(Math.random() * 50); + Atomics.wait( + new Int32Array(new SharedArrayBuffer(4)), + 0, + 0, + delay, + ); + continue; + } + throw error; + } + } } private createSchema(): void { diff --git a/src/infrastructure/persistence/extension_catalog_store.ts b/src/infrastructure/persistence/extension_catalog_store.ts index f7f68918..0801a181 100644 --- a/src/infrastructure/persistence/extension_catalog_store.ts +++ b/src/infrastructure/persistence/extension_catalog_store.ts @@ -67,11 +67,41 @@ export class ExtensionCatalogStore { constructor(dbPath: string) { ensureDirSync(dirname(dbPath)); this.db = new DatabaseSync(dbPath); - // busy_timeout must be set BEFORE journal_mode=WAL so SQLite retries - // instead of failing immediately when another process holds the lock. this.db.exec("PRAGMA busy_timeout=5000"); - this.db.exec("PRAGMA journal_mode=WAL"); - this.createSchema(); + this.initializeWithRetry(); + } + + /** + * Initializes WAL mode and schema with retry logic. + * PRAGMA journal_mode=WAL requires an exclusive lock to switch modes. + * When multiple processes open the database simultaneously, the SQLite + * busy handler may not cover the mode switch reliably, so we retry at + * the application level with exponential backoff. + */ + private initializeWithRetry(): void { + const MAX_RETRIES = 5; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + this.db.exec("PRAGMA journal_mode=WAL"); + this.createSchema(); + return; + } catch (error: unknown) { + const isLock = error instanceof Error && + /database is (locked|busy)/i.test(error.message); + if (isLock && attempt < MAX_RETRIES) { + const delay = 100 * Math.pow(2, attempt) + + Math.floor(Math.random() * 50); + Atomics.wait( + new Int32Array(new SharedArrayBuffer(4)), + 0, + 0, + delay, + ); + continue; + } + throw error; + } + } } private createSchema(): void {