feat(FN-4025): defer database integrity check and expose via health endpoin

Exposes database health status via the API by deferring integrity checks asynchronously, with docs and tests covering the core DB layer, store integration, and dashboard server health endpoint.

Fusion-Task-Id: FN-4025
This commit is contained in:
Fusion
2026-05-11 14:10:23 -07:00
committed by gsxdsm
parent 5c005023c4
commit e6e596ebb7
8 changed files with 142 additions and 51 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Move full SQLite integrity checks off the startup critical path by running `PRAGMA integrity_check(100)` asynchronously after boot. Expose database integrity state on `/api/health` via `database.corruptionDetected`, `database.integrityCheckPending`, and `database.integrityCheckLastRunAt` while preserving existing top-level health fields.

View File

@@ -844,7 +844,8 @@ A `prefetchLazyViews()` function runs once on mount via `requestIdleCallback` to
### Health and monitoring endpoints
- **Health check**: `GET /api/health`
- Returns liveness status for load balancers and monitoring
- Response: `{ status: "ok", version: string, uptime: number }`
- Response: `{ status: "ok" | "degraded", version: string, uptime: number, database: { corruptionDetected: boolean, integrityCheckPending: boolean, integrityCheckLastRunAt: string | null } }`
- Startup does not block on full `PRAGMA integrity_check(100)`; Fusion schedules it in the background shortly after boot and surfaces progress/results via `database.*` fields
- No authentication required
### Custom Provider endpoints

View File

@@ -269,23 +269,52 @@ describe("Database", () => {
});
describe("startup integrity check", () => {
it("passes silently on a healthy database", () => {
// db was already init'd in beforeEach — no warning means pass
const result = db.prepare("PRAGMA integrity_check").get() as { integrity_check: string };
expect(result.integrity_check).toBe("ok");
});
it("schedules full integrity check after init instead of blocking startup", () => {
vi.useFakeTimers();
const integritySpy = vi.spyOn(Database.prototype, "integrityCheck");
it("init completes without throwing even on a fresh database", () => {
const freshDir = makeTmpDir();
const freshFusionDir = join(freshDir, ".fusion");
const freshDb = new Database(freshFusionDir);
try {
// init includes the integrity check — should not throw
expect(() => freshDb.init()).not.toThrow();
expect(freshDb.integrityCheckPending).toBe(true);
expect(integritySpy).not.toHaveBeenCalled();
vi.advanceTimersByTime(3000);
expect(integritySpy).toHaveBeenCalledTimes(1);
expect(freshDb.integrityCheckPending).toBe(false);
expect(freshDb.integrityCheckLastRunAt).toBeTruthy();
} finally {
freshDb.close();
rmSync(freshDir, { recursive: true, force: true });
integritySpy.mockRestore();
vi.useRealTimers();
}
});
it("does not schedule duplicate background integrity checks across repeated init calls", () => {
vi.useFakeTimers();
const integritySpy = vi.spyOn(Database.prototype, "integrityCheck");
const freshDir = makeTmpDir();
const freshFusionDir = join(freshDir, ".fusion");
const freshDb = new Database(freshFusionDir);
try {
freshDb.init();
expect(freshDb.integrityCheckPending).toBe(true);
freshDb.init();
vi.advanceTimersByTime(3000);
expect(integritySpy).toHaveBeenCalledTimes(1);
} finally {
freshDb.close();
rmSync(freshDir, { recursive: true, force: true });
integritySpy.mockRestore();
vi.useRealTimers();
}
});
});
@@ -656,14 +685,17 @@ describe("Database", () => {
const diskDb = new Database(fusionDir);
diskDb.init();
expect(diskDb.corruptionDetected).toBe(false);
expect(diskDb.integrityCheckPending).toBe(true);
diskDb.close();
});
it("skips integrity check side effects for in-memory databases", () => {
it("skips background integrity check scheduling for in-memory databases", () => {
const memDb = new Database(fusionDir, { inMemory: true });
memDb.init();
expect(memDb.integrityCheck()).toEqual({ ok: true });
expect(memDb.corruptionDetected).toBe(false);
expect(memDb.integrityCheckPending).toBe(false);
expect(memDb.integrityCheckLastRunAt).toBeNull();
memDb.close();
});
});

View File

@@ -1081,9 +1081,13 @@ export class Database {
/** Returns the database file path (or ":memory:" for in-memory databases). */
get path(): string { return this.dbPath; }
corruptionDetected = false;
integrityCheckPending = false;
integrityCheckLastRunAt: string | null = null;
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
private transactionDepth = 0;
private readonly _fts5Available: boolean;
private backgroundIntegrityTimer: ReturnType<typeof setTimeout> | null = null;
private integrityCheckScheduled = false;
constructor(fusionDir: string, options?: { inMemory?: boolean }) {
@@ -1291,49 +1295,10 @@ export class Database {
* and seed meta values.
*/
init(): void {
// Startup integrity check — run BEFORE any writes to avoid
// compounding corruption. Attempts WAL checkpoint recovery on failure.
const integrity = this.integrityCheck();
if (!integrity.ok) {
this.corruptionDetected = true;
console.warn(`[fusion:db] Database integrity check FAILED for ${this.dbPath} — 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: ${this.dbPath}`);
} else {
const recheckMsg = ("errors" in recheck && Array.isArray(recheck.errors))
? recheck.errors.slice(0, 3).join(" | ")
: "unknown";
console.error(
`[fusion:db] Database is corrupted and could not be auto-recovered. ` +
`Run: sqlite3 ${this.dbPath} ".recover" | sqlite3 ${this.dbPath}.recovered`,
);
throw new Error(
`[fusion:db] Refusing to initialize corrupted database at ${this.dbPath}. Integrity errors: ${recheckMsg}`,
);
}
} catch (err) {
// Re-throw our own abort error; wrap others
if (err instanceof Error && err.message.startsWith("[fusion:db] Refusing")) {
throw err;
}
const errMsg = err instanceof Error ? err.message : String(err);
console.error(
`[fusion:db] Database corruption detected for ${this.dbPath} and checkpoint recovery failed: ${errMsg}. ` +
"Manual recovery required.",
);
throw new Error(
`[fusion:db] Refusing to initialize corrupted database at ${this.dbPath}. Recovery error: ${errMsg}`,
);
}
}
this.db.exec(SCHEMA_SQL);
this.scheduleBackgroundIntegrityCheck();
// Seed schemaVersion and lastModified idempotently
this.db.exec(
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '1')`,
@@ -3123,10 +3088,41 @@ export class Database {
return { busy: row?.busy ?? 0, log: row?.log ?? 0, checkpointed: row?.checkpointed ?? 0 };
}
private scheduleBackgroundIntegrityCheck(): void {
if (this.inMemory || this.integrityCheckScheduled) {
return;
}
this.integrityCheckScheduled = true;
this.integrityCheckPending = true;
this.backgroundIntegrityTimer = setTimeout(() => {
this.backgroundIntegrityTimer = null;
const integrity = this.integrityCheck();
this.integrityCheckPending = false;
this.integrityCheckLastRunAt = new Date().toISOString();
if (integrity.ok) {
this.corruptionDetected = false;
return;
}
this.corruptionDetected = true;
const errorSummary = integrity.errors.slice(0, 3).join(" | ");
console.error(
`[fusion:db] Background integrity check detected corruption for ${this.dbPath}: ${errorSummary}`,
);
}, 3000);
}
/**
* Close the database connection.
*/
close(): void {
if (this.backgroundIntegrityTimer) {
clearTimeout(this.backgroundIntegrityTimer);
this.backgroundIntegrityTimer = null;
this.integrityCheckPending = false;
}
this.db.close();
}

View File

@@ -6948,6 +6948,18 @@ ${stepsSection}`;
return this.db;
}
getDatabaseHealth(): {
corruptionDetected: boolean;
integrityCheckPending: boolean;
integrityCheckLastRunAt: string | null;
} {
return {
corruptionDetected: this.db.corruptionDetected,
integrityCheckPending: this.db.integrityCheckPending,
integrityCheckLastRunAt: this.db.integrityCheckLastRunAt,
};
}
getDistributedTaskIdAllocator(): DistributedTaskIdAllocator {
if (!this.distributedTaskIdAllocator) {
this.distributedTaskIdAllocator = createDistributedTaskIdAllocator(this.db);

View File

@@ -64,6 +64,14 @@ class MockStore extends EventEmitter {
};
}
getDatabaseHealth() {
return {
corruptionDetected: false,
integrityCheckPending: false,
integrityCheckLastRunAt: null,
};
}
getMissionStore() {
return {
listMissions: vi.fn().mockResolvedValue([]),

View File

@@ -68,6 +68,11 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getDatabaseHealth: vi.fn().mockReturnValue({
corruptionDetected: false,
integrityCheckPending: false,
integrityCheckLastRunAt: null,
}),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
@@ -307,6 +312,36 @@ describe("createServer health and headless mode", () => {
status: "ok",
version: CLI_PACKAGE_VERSION,
uptime: expect.any(Number),
database: {
corruptionDetected: false,
integrityCheckPending: false,
integrityCheckLastRunAt: null,
},
});
});
it("reports degraded status when database corruption is detected", async () => {
const store = createMockStore({
getDatabaseHealth: vi.fn().mockReturnValue({
corruptionDetected: true,
integrityCheckPending: false,
integrityCheckLastRunAt: "2026-05-11T10:00:00.000Z",
}),
});
const app = createServer(store);
const res = await GET(app, "/api/health");
expect(res.status).toBe(200);
expect(res.body).toEqual({
status: "degraded",
version: CLI_PACKAGE_VERSION,
uptime: expect.any(Number),
database: {
corruptionDetected: true,
integrityCheckPending: false,
integrityCheckLastRunAt: "2026-05-11T10:00:00.000Z",
},
});
});

View File

@@ -1052,10 +1052,12 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
}
app.get("/api/health", (_req, res) => {
const database = store.getDatabaseHealth();
res.json({
status: "ok",
status: database.corruptionDetected ? "degraded" : "ok",
version: cliPackageVersion,
uptime: Math.floor(process.uptime()),
database,
});
});