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:
5
.changeset/fn-4035-integrity-check-dedup.md
Normal file
5
.changeset/fn-4035-integrity-check-dedup.md
Normal 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`).
|
||||
@@ -846,7 +846,8 @@ A `prefetchLazyViews()` function runs once on mount via `requestIdleCallback` to
|
||||
- **Health check**: `GET /api/health`
|
||||
- Returns liveness status for load balancers and monitoring
|
||||
- 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
|
||||
|
||||
### Custom Provider endpoints
|
||||
|
||||
@@ -317,6 +317,72 @@ describe("Database", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -1074,7 +1074,15 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>
|
||||
|
||||
// ── Database Class ───────────────────────────────────────────────────
|
||||
|
||||
type SharedIntegrityCheckState = {
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
subscribers: Set<Database>;
|
||||
running: boolean;
|
||||
};
|
||||
|
||||
export class Database {
|
||||
private static readonly sharedIntegrityChecks = new Map<string, SharedIntegrityCheckState>();
|
||||
|
||||
private db: DatabaseSync;
|
||||
private readonly dbPath: string;
|
||||
private readonly inMemory: boolean;
|
||||
@@ -1086,8 +1094,8 @@ export class Database {
|
||||
/** 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;
|
||||
private closed = false;
|
||||
|
||||
|
||||
constructor(fusionDir: string, options?: { inMemory?: boolean }) {
|
||||
@@ -3089,40 +3097,80 @@ export class Database {
|
||||
}
|
||||
|
||||
private scheduleBackgroundIntegrityCheck(): void {
|
||||
if (this.inMemory || this.integrityCheckScheduled) {
|
||||
if (this.inMemory || this.integrityCheckScheduled || this.closed) {
|
||||
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;
|
||||
const existing = Database.sharedIntegrityChecks.get(this.dbPath);
|
||||
if (existing) {
|
||||
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;
|
||||
const errorSummary = integrity.errors.slice(0, 3).join(" | ");
|
||||
console.error(
|
||||
`[fusion:db] Background integrity check detected corruption for ${this.dbPath}: ${errorSummary}`,
|
||||
);
|
||||
for (const participant of participants) {
|
||||
participant.integrityCheckPending = false;
|
||||
participant.integrityCheckLastRunAt = startedAt;
|
||||
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);
|
||||
|
||||
Database.sharedIntegrityChecks.set(this.dbPath, shared);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection.
|
||||
*/
|
||||
close(): void {
|
||||
if (this.backgroundIntegrityTimer) {
|
||||
clearTimeout(this.backgroundIntegrityTimer);
|
||||
this.backgroundIntegrityTimer = null;
|
||||
this.integrityCheckPending = false;
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user