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`
|
- **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
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user