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 and monitoring endpoints
|
||||||
- **Health check**: `GET /api/health`
|
- **Health check**: `GET /api/health`
|
||||||
- Returns liveness status for load balancers and monitoring
|
- 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
|
- No authentication required
|
||||||
|
|
||||||
### Custom Provider endpoints
|
### Custom Provider endpoints
|
||||||
|
|||||||
@@ -269,23 +269,52 @@ describe("Database", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("startup integrity check", () => {
|
describe("startup integrity check", () => {
|
||||||
it("passes silently on a healthy database", () => {
|
it("schedules full integrity check after init instead of blocking startup", () => {
|
||||||
// db was already init'd in beforeEach — no warning means pass
|
vi.useFakeTimers();
|
||||||
const result = db.prepare("PRAGMA integrity_check").get() as { integrity_check: string };
|
const integritySpy = vi.spyOn(Database.prototype, "integrityCheck");
|
||||||
expect(result.integrity_check).toBe("ok");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("init completes without throwing even on a fresh database", () => {
|
|
||||||
const freshDir = makeTmpDir();
|
const freshDir = makeTmpDir();
|
||||||
const freshFusionDir = join(freshDir, ".fusion");
|
const freshFusionDir = join(freshDir, ".fusion");
|
||||||
const freshDb = new Database(freshFusionDir);
|
const freshDb = new Database(freshFusionDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// init includes the integrity check — should not throw
|
|
||||||
expect(() => freshDb.init()).not.toThrow();
|
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 {
|
} finally {
|
||||||
freshDb.close();
|
freshDb.close();
|
||||||
rmSync(freshDir, { recursive: true, force: true });
|
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);
|
const diskDb = new Database(fusionDir);
|
||||||
diskDb.init();
|
diskDb.init();
|
||||||
expect(diskDb.corruptionDetected).toBe(false);
|
expect(diskDb.corruptionDetected).toBe(false);
|
||||||
|
expect(diskDb.integrityCheckPending).toBe(true);
|
||||||
diskDb.close();
|
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 });
|
const memDb = new Database(fusionDir, { inMemory: true });
|
||||||
memDb.init();
|
memDb.init();
|
||||||
expect(memDb.integrityCheck()).toEqual({ ok: true });
|
expect(memDb.integrityCheck()).toEqual({ ok: true });
|
||||||
expect(memDb.corruptionDetected).toBe(false);
|
expect(memDb.corruptionDetected).toBe(false);
|
||||||
|
expect(memDb.integrityCheckPending).toBe(false);
|
||||||
|
expect(memDb.integrityCheckLastRunAt).toBeNull();
|
||||||
memDb.close();
|
memDb.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1081,9 +1081,13 @@ export class Database {
|
|||||||
/** Returns the database file path (or ":memory:" for in-memory databases). */
|
/** Returns the database file path (or ":memory:" for in-memory databases). */
|
||||||
get path(): string { return this.dbPath; }
|
get path(): string { return this.dbPath; }
|
||||||
corruptionDetected = false;
|
corruptionDetected = false;
|
||||||
|
integrityCheckPending = false;
|
||||||
|
integrityCheckLastRunAt: string | null = null;
|
||||||
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
|
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
|
||||||
private transactionDepth = 0;
|
private transactionDepth = 0;
|
||||||
private readonly _fts5Available: boolean;
|
private readonly _fts5Available: boolean;
|
||||||
|
private backgroundIntegrityTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
private integrityCheckScheduled = false;
|
||||||
|
|
||||||
|
|
||||||
constructor(fusionDir: string, options?: { inMemory?: boolean }) {
|
constructor(fusionDir: string, options?: { inMemory?: boolean }) {
|
||||||
@@ -1291,49 +1295,10 @@ export class Database {
|
|||||||
* and seed meta values.
|
* and seed meta values.
|
||||||
*/
|
*/
|
||||||
init(): void {
|
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.db.exec(SCHEMA_SQL);
|
||||||
|
|
||||||
|
this.scheduleBackgroundIntegrityCheck();
|
||||||
|
|
||||||
// Seed schemaVersion and lastModified idempotently
|
// Seed schemaVersion and lastModified idempotently
|
||||||
this.db.exec(
|
this.db.exec(
|
||||||
`INSERT OR IGNORE INTO __meta (key, value) VALUES ('schemaVersion', '1')`,
|
`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 };
|
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 the database connection.
|
||||||
*/
|
*/
|
||||||
close(): void {
|
close(): void {
|
||||||
|
if (this.backgroundIntegrityTimer) {
|
||||||
|
clearTimeout(this.backgroundIntegrityTimer);
|
||||||
|
this.backgroundIntegrityTimer = null;
|
||||||
|
this.integrityCheckPending = false;
|
||||||
|
}
|
||||||
this.db.close();
|
this.db.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6948,6 +6948,18 @@ ${stepsSection}`;
|
|||||||
return this.db;
|
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 {
|
getDistributedTaskIdAllocator(): DistributedTaskIdAllocator {
|
||||||
if (!this.distributedTaskIdAllocator) {
|
if (!this.distributedTaskIdAllocator) {
|
||||||
this.distributedTaskIdAllocator = createDistributedTaskIdAllocator(this.db);
|
this.distributedTaskIdAllocator = createDistributedTaskIdAllocator(this.db);
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ class MockStore extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDatabaseHealth() {
|
||||||
|
return {
|
||||||
|
corruptionDetected: false,
|
||||||
|
integrityCheckPending: false,
|
||||||
|
integrityCheckLastRunAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getMissionStore() {
|
getMissionStore() {
|
||||||
return {
|
return {
|
||||||
listMissions: vi.fn().mockResolvedValue([]),
|
listMissions: vi.fn().mockResolvedValue([]),
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
exec: vi.fn(),
|
exec: vi.fn(),
|
||||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
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({
|
getMissionStore: vi.fn().mockReturnValue({
|
||||||
listMissions: vi.fn().mockReturnValue([]),
|
listMissions: vi.fn().mockReturnValue([]),
|
||||||
createMission: vi.fn(),
|
createMission: vi.fn(),
|
||||||
@@ -307,6 +312,36 @@ describe("createServer health and headless mode", () => {
|
|||||||
status: "ok",
|
status: "ok",
|
||||||
version: CLI_PACKAGE_VERSION,
|
version: CLI_PACKAGE_VERSION,
|
||||||
uptime: expect.any(Number),
|
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) => {
|
app.get("/api/health", (_req, res) => {
|
||||||
|
const database = store.getDatabaseHealth();
|
||||||
res.json({
|
res.json({
|
||||||
status: "ok",
|
status: database.corruptionDetected ? "degraded" : "ok",
|
||||||
version: cliPackageVersion,
|
version: cliPackageVersion,
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
|
database,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user