feat(FN-4035): dedupe integrity checks per database path

- Deduplicate concurrent integrity-check requests by sharing one in-flight check per database path
- Refactor core DB integrity-check flow to prevent redundant work while preserving safety behavior
- Add core DB tests covering deduped execution and expected integrity-check outcomes
- Document integrity-check dedup behavior in architecture docs
- Add a changeset for @runfusion/fusion patch release
This commit is contained in:
Fusion
2026-05-11 17:59:26 -07:00
committed by gsxdsm
parent 1d6c85e900
commit c501e00fdf
4 changed files with 140 additions and 20 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Deduplicate background SQLite integrity checks per database path so multi-project dashboard startup no longer stacks repeated `PRAGMA integrity_check(100)` runs against the same `fusion.db`. Health state fanout is preserved for all participating database instances (`integrityCheckPending`, `integrityCheckLastRunAt`, `corruptionDetected`).

View File

@@ -846,7 +846,8 @@ A `prefetchLazyViews()` function runs once on mount via `requestIdleCallback` to
- **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" | "degraded", version: string, uptime: number, database: { corruptionDetected: boolean, integrityCheckPending: boolean, integrityCheckLastRunAt: string | null } }` - 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 - Startup does not block on full `PRAGMA integrity_check(100)`; Fusion schedules it in the background shortly after boot.
- Background integrity checks are deduplicated process-wide per on-disk SQLite path: multiple `Database` instances sharing the same `fusion.db` join one shared run, and each instance still updates `database.integrityCheckPending`, `database.integrityCheckLastRunAt`, and `database.corruptionDetected` from the shared result.
- No authentication required - No authentication required
### Custom Provider endpoints ### Custom Provider endpoints

View File

@@ -317,6 +317,72 @@ describe("Database", () => {
vi.useRealTimers(); vi.useRealTimers();
} }
}); });
it("deduplicates background integrity check across multiple instances sharing a db path", () => {
vi.useFakeTimers();
const integritySpy = vi.spyOn(Database.prototype, "integrityCheck");
const freshDir = makeTmpDir();
const freshFusionDir = join(freshDir, ".fusion");
const dbA = new Database(freshFusionDir);
const dbB = new Database(freshFusionDir);
try {
dbA.init();
dbB.init();
expect(dbA.integrityCheckPending).toBe(true);
expect(dbB.integrityCheckPending).toBe(true);
vi.advanceTimersByTime(3000);
expect(integritySpy).toHaveBeenCalledTimes(1);
expect(dbA.integrityCheckPending).toBe(false);
expect(dbB.integrityCheckPending).toBe(false);
expect(dbA.integrityCheckLastRunAt).toBeTruthy();
expect(dbB.integrityCheckLastRunAt).toBeTruthy();
expect(dbA.corruptionDetected).toBe(false);
expect(dbB.corruptionDetected).toBe(false);
} finally {
dbA.close();
dbB.close();
rmSync(freshDir, { recursive: true, force: true });
integritySpy.mockRestore();
vi.useRealTimers();
}
});
it("fans out corruption detection to all instances participating in shared background check", () => {
vi.useFakeTimers();
const integritySpy = vi.spyOn(Database.prototype, "integrityCheck").mockReturnValue({
ok: false,
errors: ["malformed database"],
});
const freshDir = makeTmpDir();
const freshFusionDir = join(freshDir, ".fusion");
const dbA = new Database(freshFusionDir);
const dbB = new Database(freshFusionDir);
try {
dbA.init();
dbB.init();
vi.advanceTimersByTime(3000);
expect(integritySpy).toHaveBeenCalledTimes(1);
expect(dbA.integrityCheckPending).toBe(false);
expect(dbB.integrityCheckPending).toBe(false);
expect(dbA.integrityCheckLastRunAt).toBeTruthy();
expect(dbB.integrityCheckLastRunAt).toBeTruthy();
expect(dbA.corruptionDetected).toBe(true);
expect(dbB.corruptionDetected).toBe(true);
} finally {
dbA.close();
dbB.close();
rmSync(freshDir, { recursive: true, force: true });
integritySpy.mockRestore();
vi.useRealTimers();
}
});
}); });
describe("change detection", () => { describe("change detection", () => {

View File

@@ -1074,7 +1074,15 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>
// ── Database Class ─────────────────────────────────────────────────── // ── Database Class ───────────────────────────────────────────────────
type SharedIntegrityCheckState = {
timer: ReturnType<typeof setTimeout> | null;
subscribers: Set<Database>;
running: boolean;
};
export class Database { export class Database {
private static readonly sharedIntegrityChecks = new Map<string, SharedIntegrityCheckState>();
private db: DatabaseSync; private db: DatabaseSync;
private readonly dbPath: string; private readonly dbPath: string;
private readonly inMemory: boolean; private readonly inMemory: boolean;
@@ -1086,8 +1094,8 @@ export class Database {
/** 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; private integrityCheckScheduled = false;
private closed = false;
constructor(fusionDir: string, options?: { inMemory?: boolean }) { constructor(fusionDir: string, options?: { inMemory?: boolean }) {
@@ -3089,40 +3097,80 @@ export class Database {
} }
private scheduleBackgroundIntegrityCheck(): void { private scheduleBackgroundIntegrityCheck(): void {
if (this.inMemory || this.integrityCheckScheduled) { if (this.inMemory || this.integrityCheckScheduled || this.closed) {
return; return;
} }
this.integrityCheckScheduled = true; this.integrityCheckScheduled = true;
this.integrityCheckPending = 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) { const existing = Database.sharedIntegrityChecks.get(this.dbPath);
this.corruptionDetected = false; if (existing) {
return; existing.subscribers.add(this);
return;
}
const shared: SharedIntegrityCheckState = {
timer: null,
subscribers: new Set([this]),
running: false,
};
shared.timer = setTimeout(() => {
shared.timer = null;
shared.running = true;
const participants = [...shared.subscribers].filter((instance) => !instance.closed);
const primary = participants[0];
const startedAt = new Date().toISOString();
let integrity: ReturnType<Database["integrityCheck"]> = { ok: true };
if (primary) {
integrity = primary.integrityCheck();
} }
this.corruptionDetected = true; for (const participant of participants) {
const errorSummary = integrity.errors.slice(0, 3).join(" | "); participant.integrityCheckPending = false;
console.error( participant.integrityCheckLastRunAt = startedAt;
`[fusion:db] Background integrity check detected corruption for ${this.dbPath}: ${errorSummary}`, participant.corruptionDetected = !integrity.ok;
); }
if (!integrity.ok) {
const errorSummary = integrity.errors.slice(0, 3).join(" | ");
console.error(
`[fusion:db] Background integrity check detected corruption for ${this.dbPath}: ${errorSummary}`,
);
}
Database.sharedIntegrityChecks.delete(this.dbPath);
}, 3000); }, 3000);
Database.sharedIntegrityChecks.set(this.dbPath, shared);
} }
/** /**
* Close the database connection. * Close the database connection.
*/ */
close(): void { close(): void {
if (this.backgroundIntegrityTimer) { if (this.closed) {
clearTimeout(this.backgroundIntegrityTimer); return;
this.backgroundIntegrityTimer = null;
this.integrityCheckPending = false;
} }
this.closed = true;
const shared = Database.sharedIntegrityChecks.get(this.dbPath);
if (shared) {
shared.subscribers.delete(this);
if (!shared.running && shared.subscribers.size === 0) {
if (shared.timer) {
clearTimeout(shared.timer);
shared.timer = null;
}
Database.sharedIntegrityChecks.delete(this.dbPath);
}
}
this.integrityCheckPending = false;
this.db.close(); this.db.close();
} }