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:
5
.changeset/async-db-integrity-health.md
Normal file
5
.changeset/async-db-integrity-health.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -64,6 +64,14 @@ class MockStore extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
getDatabaseHealth() {
|
||||
return {
|
||||
corruptionDetected: false,
|
||||
integrityCheckPending: false,
|
||||
integrityCheckLastRunAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
getMissionStore() {
|
||||
return {
|
||||
listMissions: vi.fn().mockResolvedValue([]),
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user