fix(db): skip integrity scans during health polling (#13149)

* fix(db): skip integrity scans during health polling

* test(db): update health error fixture for scan-free polling

* docs(changelog): add fragment for health poll integrity skip

* fix(db): keep the #13149 dashboard skip inside the #13717 managed health check

Merge fallout only: runManagedDbHealthCheck moved behind the health
coordinator on the release tip, so the per-call skipIntegrityCheck now
travels through it. A waived integrity scan is part of the job identity,
so it is never replayed from the 60s diagnosis cache to a caller that
asked for the full scan.

Co-authored-by: cryptiklemur <cryptiklemur@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: cryptiklemur <cryptiklemur@users.noreply.github.com>
This commit is contained in:
Aaron Scherer
2026-09-17 16:52:43 -05:00
committed by GitHub
parent 5f9e153971
commit 7d69b02a29
6 changed files with 141 additions and 21 deletions

View File

@@ -9,7 +9,9 @@ export async function GET(request: Request) {
}
try {
return NextResponse.json(await runManagedDbHealthCheck({ autoRepair: false }));
return NextResponse.json(
await runManagedDbHealthCheck({ autoRepair: false, skipIntegrityCheck: true })
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("[API] DB health diagnosis failed:", message);

View File

@@ -943,9 +943,9 @@ function startDbHealthCheckScheduler(db: SqliteDatabase) {
// The scheduler lives in ./walMaintenance (periodic TRUNCATE + busy warn + PASSIVE retry).
const healthShutdown = new AbortController();
const managedHealth = createDbHealthCoordinator(async (autoRepair) => {
const managedHealth = createDbHealthCoordinator(async (autoRepair, skipIntegrity) => {
const db = getDbInstance();
const skipIntegrityCheck = process.env.OMNIROUTE_SKIP_DB_HEALTHCHECK === "1";
const skipIntegrityCheck = skipIntegrity || process.env.OMNIROUTE_SKIP_DB_HEALTHCHECK === "1";
const backupDir = DB_BACKUPS_DIR || path.join(DATA_DIR, "db_backups");
const result =
db.driver === "sql.js" || db.name === ":memory:" || !db.name
@@ -969,10 +969,10 @@ const managedHealth = createDbHealthCoordinator(async (autoRepair) => {
if (result.repairedCount > 0) invalidateDbCache();
return result;
});
export function runManagedDbHealthCheck(options?: { autoRepair?: boolean }) {
type ManagedHealthCheckOptions = { autoRepair?: boolean; skipIntegrityCheck?: boolean };
export function runManagedDbHealthCheck(options?: ManagedHealthCheckOptions) {
if (getPagerCorruption()) managedHealth.invalidate();
return managedHealth.run(options?.autoRepair === true);
return managedHealth.run(options?.autoRepair === true, options?.skipIntegrityCheck === true);
}
export function getDbInstance(): SqliteDatabase {

View File

@@ -12,14 +12,23 @@ export interface DbHealthJob {
pagerCorruption: PagerCorruptionNote | null;
}
/**
* `skipIntegrityCheck` (#13149): a caller may waive the integrity scan — the dashboard
* poll does. It is part of the job identity, so a waived scan is never replayed from the
* cache to a caller that asked for a full one (the reverse direction is safe).
*/
export function createDbHealthCoordinator(
execute: (autoRepair: boolean) => Promise<DbHealthCheckResult>,
execute: (autoRepair: boolean, skipIntegrityCheck: boolean) => Promise<DbHealthCheckResult>,
options: { now?: () => number; cacheMs?: number } = {}
) {
const now = options.now ?? Date.now;
const cacheMs = options.cacheMs ?? 60_000;
let stopping = false;
let active: { autoRepair: boolean; promise: Promise<DbHealthCheckResult> } | null = null;
let active: {
autoRepair: boolean;
skipIntegrityCheck: boolean;
promise: Promise<DbHealthCheckResult>;
} | null = null;
let cached: { result: DbHealthCheckResult; expires: number } | null = null;
return {
get busy(): boolean {
@@ -34,10 +43,13 @@ export function createDbHealthCoordinator(
cancel();
await active?.promise.catch(() => {});
},
run(autoRepair: boolean): Promise<DbHealthCheckResult> {
run(autoRepair: boolean, skipIntegrityCheck = false): Promise<DbHealthCheckResult> {
if (stopping) return Promise.reject(new Error("Database health checks are stopping"));
if (active) {
return active.autoRepair === autoRepair
// A run that DID scan integrity satisfies a caller that was willing to skip it,
// never the reverse (#13149) — so only widen, never narrow, the in-flight job.
return active.autoRepair === autoRepair &&
(active.skipIntegrityCheck === skipIntegrityCheck || !active.skipIntegrityCheck)
? active.promise
: Promise.reject(new Error("Database health check already in progress"));
}
@@ -49,9 +61,11 @@ export function createDbHealthCoordinator(
resolve = yes;
reject = no;
});
active = { autoRepair, promise };
active = { autoRepair, skipIntegrityCheck, promise };
const succeed = (result: DbHealthCheckResult) => {
if (!autoRepair) cached = { result, expires: now() + cacheMs };
// Only a full (integrity-scanning) diagnosis may be replayed from the cache:
// caching a skipped scan would silently downgrade a later full request (#13149).
if (!autoRepair && !skipIntegrityCheck) cached = { result, expires: now() + cacheMs };
active = null;
resolve(result);
};
@@ -60,7 +74,7 @@ export function createDbHealthCoordinator(
reject(error);
};
try {
execute(autoRepair).then(succeed, fail);
execute(autoRepair, skipIntegrityCheck).then(succeed, fail);
} catch (error) {
fail(error);
}