fix: prevent SQLite B-tree corruption with WAL tuning and batched agent logs

The agentLogEntries table was doing individual auto-committed INSERTs,
creating extreme WAL pressure that caused recurring B-tree corruption.
This adds three WAL PRAGMAs (aggressive autocheckpoint, size limit,
synchronous=NORMAL), a write-behind buffer for agent log entries, and
a startup integrity check with auto-recovery attempt.

Refs: Runfusion/Fusion#24

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Timothy Laurent
2026-05-03 08:17:48 -07:00
parent abeeec53bd
commit af2bc0e341
4 changed files with 277 additions and 18 deletions

View File

@@ -703,6 +703,7 @@ export class Database {
private transactionDepth = 0;
private readonly _fts5Available: boolean;
constructor(fusionDir: string, options?: { inMemory?: boolean }) {
// In-memory mode is a test-only fast path that swaps the on-disk
// SQLite file for SQLite's `:memory:` connection. Schema + data live
@@ -755,9 +756,10 @@ export class Database {
this.db.exec("PRAGMA busy_timeout = 5000");
// In WAL mode NORMAL is nearly as durable as FULL with much lower fsync cost.
this.db.exec("PRAGMA synchronous = NORMAL");
// Let WAL grow to roughly the journal size limit before auto-checkpointing.
// This avoids frequent synchronous checkpoints on log-heavy workloads.
this.db.exec("PRAGMA wal_autocheckpoint = 1000");
// Checkpoint every 100 pages (~400 KB) to keep WAL small and reduce
// corruption risk. More aggressive than the default 1000, but paired
// with journal_size_limit to prevent WAL bloat.
this.db.exec("PRAGMA wal_autocheckpoint = 100");
// Bound WAL growth between checkpoints/maintenance cycles.
this.db.exec("PRAGMA journal_size_limit = 4194304");
} else {
@@ -916,6 +918,35 @@ export class Database {
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('lastModified', '${Date.now()}')`,
);
// Startup integrity check — run BEFORE migrations and seeds to avoid
// writing to a corrupted database and making it worse. Uses the
// structured integrityCheck() method and attempts WAL checkpoint
// recovery on failure.
const integrity = this.integrityCheck();
if (!integrity.ok) {
this.corruptionDetected = true;
console.warn("[fusion:db] Database integrity check FAILED — corruption detected");
// Attempt WAL checkpoint recovery
try {
this.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
const recheck = this.integrityCheck();
if (recheck.ok) {
this.corruptionDetected = false;
console.warn("[fusion:db] Database recovered via WAL checkpoint.");
} else {
console.error(
`[fusion:db] Database is corrupted and could not be auto-recovered. ` +
`Run: sqlite3 ${this.dbPath} ".recover" | sqlite3 ${this.dbPath}.recovered`,
);
}
} catch {
console.error(
"[fusion:db] Database corruption detected and checkpoint recovery failed. " +
"Manual recovery required.",
);
}
}
// Run schema migrations
this.migrate();
@@ -927,12 +958,6 @@ export class Database {
this.db.exec(
`INSERT OR IGNORE INTO config (id, nextId, nextWorkflowStepId, settings, workflowSteps, updatedAt) VALUES (1, 1, 1, '${JSON.stringify(DEFAULT_PROJECT_SETTINGS)}', '[]', '${configNow}')`,
);
const integrity = this.integrityCheck();
if (!integrity.ok) {
this.corruptionDetected = true;
console.warn("[fusion:db] Database integrity check FAILED — corruption detected");
}
}
/**