Skip to content

Commit 0283af0

Browse files
stack72claude
andauthored
fix: add application-level retry for SQLite init under concurrent access (#1154)
## Summary - Adds exponential backoff retry (up to 5 attempts) around `PRAGMA journal_mode=WAL` and schema creation in both `CatalogStore` and `ExtensionCatalogStore` - Fixes "database is locked" crashes when multiple `swamp workflow run` processes start concurrently and race to initialize the same SQLite database - The existing `busy_timeout=5000` pragma doesn't reliably cover the journal mode switch in Deno's `node:sqlite`, so application-level retry fills the gap ## Test Plan - [x] Existing `CatalogStore: constructor retries under write lock contention` test passes - [x] Full test suite passes (4249 tests) - [x] `deno check`, `deno lint`, `deno fmt` all clean - [ ] UAT adversarial test `swamp workflow run concurrently produces distinct data versions` should now pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fe91dbe commit 0283af0

2 files changed

Lines changed: 77 additions & 15 deletions

File tree

src/infrastructure/persistence/catalog_store.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,50 @@ export class CatalogStore {
7575
ensureDirSync(dirname(dbPath));
7676
this.db = new DatabaseSync(dbPath);
7777
this.db.exec("PRAGMA busy_timeout=5000");
78-
this.db.exec("PRAGMA journal_mode=WAL");
78+
this.initializeWithRetry();
79+
}
7980

80-
// Ensure catalog_meta exists so migrateIfNeeded() can read schema_version.
81-
// Must run before createSchema() because v2-only indexes fail on a v1 table.
82-
this.db.exec(`
83-
CREATE TABLE IF NOT EXISTS catalog_meta (
84-
key TEXT PRIMARY KEY,
85-
value TEXT NOT NULL
86-
);
87-
`);
88-
this.migrateIfNeeded();
89-
this.createSchema();
81+
/**
82+
* Initializes WAL mode and schema with retry logic.
83+
* PRAGMA journal_mode=WAL requires an exclusive lock to switch modes.
84+
* When multiple processes open the database simultaneously, the SQLite
85+
* busy handler may not cover the mode switch reliably, so we retry at
86+
* the application level with exponential backoff.
87+
*/
88+
private initializeWithRetry(): void {
89+
const MAX_RETRIES = 5;
90+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
91+
try {
92+
this.db.exec("PRAGMA journal_mode=WAL");
93+
94+
// Ensure catalog_meta exists so migrateIfNeeded() can read schema_version.
95+
// Must run before createSchema() because v2-only indexes fail on a v1 table.
96+
this.db.exec(`
97+
CREATE TABLE IF NOT EXISTS catalog_meta (
98+
key TEXT PRIMARY KEY,
99+
value TEXT NOT NULL
100+
);
101+
`);
102+
this.migrateIfNeeded();
103+
this.createSchema();
104+
return;
105+
} catch (error: unknown) {
106+
const isLock = error instanceof Error &&
107+
/database is (locked|busy)/i.test(error.message);
108+
if (isLock && attempt < MAX_RETRIES) {
109+
const delay = 100 * Math.pow(2, attempt) +
110+
Math.floor(Math.random() * 50);
111+
Atomics.wait(
112+
new Int32Array(new SharedArrayBuffer(4)),
113+
0,
114+
0,
115+
delay,
116+
);
117+
continue;
118+
}
119+
throw error;
120+
}
121+
}
90122
}
91123

92124
private createSchema(): void {

src/infrastructure/persistence/extension_catalog_store.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,41 @@ export class ExtensionCatalogStore {
6767
constructor(dbPath: string) {
6868
ensureDirSync(dirname(dbPath));
6969
this.db = new DatabaseSync(dbPath);
70-
// busy_timeout must be set BEFORE journal_mode=WAL so SQLite retries
71-
// instead of failing immediately when another process holds the lock.
7270
this.db.exec("PRAGMA busy_timeout=5000");
73-
this.db.exec("PRAGMA journal_mode=WAL");
74-
this.createSchema();
71+
this.initializeWithRetry();
72+
}
73+
74+
/**
75+
* Initializes WAL mode and schema with retry logic.
76+
* PRAGMA journal_mode=WAL requires an exclusive lock to switch modes.
77+
* When multiple processes open the database simultaneously, the SQLite
78+
* busy handler may not cover the mode switch reliably, so we retry at
79+
* the application level with exponential backoff.
80+
*/
81+
private initializeWithRetry(): void {
82+
const MAX_RETRIES = 5;
83+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
84+
try {
85+
this.db.exec("PRAGMA journal_mode=WAL");
86+
this.createSchema();
87+
return;
88+
} catch (error: unknown) {
89+
const isLock = error instanceof Error &&
90+
/database is (locked|busy)/i.test(error.message);
91+
if (isLock && attempt < MAX_RETRIES) {
92+
const delay = 100 * Math.pow(2, attempt) +
93+
Math.floor(Math.random() * 50);
94+
Atomics.wait(
95+
new Int32Array(new SharedArrayBuffer(4)),
96+
0,
97+
0,
98+
delay,
99+
);
100+
continue;
101+
}
102+
throw error;
103+
}
104+
}
75105
}
76106

77107
private createSchema(): void {

0 commit comments

Comments
 (0)