fix: align WAL multi-surface DB safety

- align app and standalone MCP busy-timeout and retry behavior
- guard live DB dev and restore flows
- document WAL maintenance boundaries
This commit is contained in:
“BeeRad”
2026-04-25 16:09:14 +10:00
parent b5ef0191d7
commit c9fb623e02
10 changed files with 226 additions and 48 deletions
@@ -1,6 +1,6 @@
'use strict';
const { query, getDb } = require('./sqlite-client');
const { query, getDb, runWithBusyRetry } = require('./sqlite-client');
/**
* Get all edges.
@@ -74,12 +74,14 @@ function createEdge(edgeData) {
VALUES (?, ?, ?, ?, ?)
`);
const result = stmt.run(
from_node_id,
to_node_id,
JSON.stringify(context),
source,
now
const result = runWithBusyRetry(() => stmt.run(
from_node_id,
to_node_id,
JSON.stringify(context),
source,
now
),
'createEdge'
);
const edgeId = Number(result.lastInsertRowid);
@@ -109,14 +111,14 @@ function updateEdge(id, updates) {
};
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
stmt.run(JSON.stringify(newContext), id);
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
} else if (contextUpdates) {
const newContext = {
...existing.context,
...contextUpdates
};
const stmt = db.prepare('UPDATE edges SET context = ? WHERE id = ?');
stmt.run(JSON.stringify(newContext), id);
runWithBusyRetry(() => stmt.run(JSON.stringify(newContext), id), 'updateEdge');
}
return getEdgeById(id);
@@ -89,11 +89,11 @@ function initDatabase() {
db = new Database(dbPath);
// Configure SQLite for performance
// Configure SQLite for WAL-first app/MCP coexistence.
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('cache_size = 5000');
db.pragma('busy_timeout = 5000');
db.pragma('busy_timeout = 10000');
validateExistingRahSchema(db);
@@ -254,19 +254,21 @@ function getDb() {
* Execute a query and return rows.
*/
function query(sql, params = []) {
const database = getDb();
const stmt = database.prepare(sql);
return runWithBusyRetry(() => {
const database = getDb();
const stmt = database.prepare(sql);
const sqlLower = sql.trim().toLowerCase();
if (sqlLower.startsWith('select') || sqlLower.startsWith('with') || sqlLower.startsWith('pragma') || sqlLower.includes('returning')) {
return params.length > 0 ? stmt.all(...params) : stmt.all();
} else {
const result = params.length > 0 ? stmt.run(...params) : stmt.run();
return {
changes: result.changes,
lastInsertRowid: Number(result.lastInsertRowid)
};
}
const sqlLower = sql.trim().toLowerCase();
if (sqlLower.startsWith('select') || sqlLower.startsWith('with') || sqlLower.startsWith('pragma') || sqlLower.includes('returning')) {
return params.length > 0 ? stmt.all(...params) : stmt.all();
} else {
const result = params.length > 0 ? stmt.run(...params) : stmt.run();
return {
changes: result.changes,
lastInsertRowid: Number(result.lastInsertRowid)
};
}
}, 'query');
}
/**
@@ -275,7 +277,38 @@ function query(sql, params = []) {
function transaction(callback) {
const database = getDb();
const txn = database.transaction(callback);
return txn();
return runWithBusyRetry(() => txn(), 'transaction');
}
function isBusyOrLocked(error) {
const message = error instanceof Error ? error.message : String(error);
const code = error && error.code ? String(error.code) : '';
return code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED' || /database is locked|database busy/i.test(message);
}
function sleepSync(ms) {
const buffer = new SharedArrayBuffer(4);
const view = new Int32Array(buffer);
Atomics.wait(view, 0, 0, ms);
}
function runWithBusyRetry(operation, label) {
const maxAttempts = 4;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return operation();
} catch (error) {
if (!isBusyOrLocked(error) || attempt === maxAttempts) {
throw error;
}
lastError = error;
const delayMs = 100 * attempt * attempt;
console.warn(`[RA-H MCP] SQLite ${label} hit ${error instanceof Error ? error.message : String(error)}; retrying in ${delayMs}ms (${attempt}/${maxAttempts})`);
sleepSync(delayMs);
}
}
throw lastError;
}
/**
@@ -293,6 +326,7 @@ module.exports = {
getDb,
query,
transaction,
runWithBusyRetry,
closeDatabase,
getDatabasePath
};