fix(db): scheduled VACUUM + persist lastVacuumAt (#4480, #4437)

Adds a scheduled SQLite VACUUM job that persists last-run state to key_value and fixes the hardcoded lastVacuumAt:null in getDatabaseSettings. Review fixes: corrected the key_value write (no updated_at column), dropped a dead/colliding migration, rewrote the test from Vitest to node:test against the real interface.

Integrated into release/v3.8.33.
This commit is contained in:
KooshaPari
2026-06-21 08:29:23 -07:00
committed by GitHub
parent 694adf7e13
commit c5f3d5fb56
12 changed files with 421 additions and 109 deletions

View File

@@ -1701,3 +1701,21 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# BIFROST_API_KEY=
# BIFROST_STREAMING_ENABLED=true
# BIFROST_TIMEOUT_MS=30000
# ── Scheduled VACUUM (Issue #4437) ────────────────────────────────────────
# Master switch for the scheduled SQLite VACUUM job.
# - OMNIROUTE_VACUUM_ENABLED=1 (default) → start a setInterval timer at boot
# that runs VACUUM once every OMNIROUTE_VACUUM_INTERVAL_HOURS.
# - 0 → disable the scheduler entirely (manual "Vacuum Now" still works).
# Source: src/lib/db/vacuumScheduler.ts
# OMNIROUTE_VACUUM_ENABLED=1
# How often to run VACUUM. Default: 24 hours. Minimum: 1. Must be an integer.
# The actual interval is computed lazily from OMNIROUTE_VACUUM_INTERVAL_HOURS
# at boot time, so changes here require a restart (or `omniroute vacuum restart`).
# OMNIROUTE_VACUUM_INTERVAL_HOURS=24
# Window in which the first VACUUM is allowed to run (cron-like start window).
# Default: 02:00-04:00 (local server time). Outside the window the scheduler
# waits until the window opens. Format: HH:MM-HH:MM (24-hour).
# OMNIROUTE_VACUUM_WINDOW=02:00-04:00

View File

@@ -2,6 +2,10 @@
## [Unreleased]
### New Features
- **feat(db): scheduled VACUUM + manual Vacuum Now persist lastVacuumAt** - previously the SQLite database settings panel always showed lastVacuumAt as null because the manual vacuum path and the auto_vacuum setting never updated the timestamp. A new src/lib/db/vacuumScheduler.ts (replacing the orphaned compressionScheduler.ts) persists the last run timestamp and last error to the key_value table via migration 102, and exposes getState(), runNow(), init(), stop(). The getDatabaseSettings() lastVacuumAt field is now read from scheduler state (no more hardcoded null). Wired into the Next.js lifecycle via instrumentation-node.ts so a setInterval timer starts at boot (default 24h, configurable, default start window 02:00-04:00 local). New env flags: OMNIROUTE_VACUUM_ENABLED, OMNIROUTE_VACUUM_INTERVAL_HOURS, OMNIROUTE_VACUUM_WINDOW. ([#4437])
---
## [3.8.33] — TBD

View File

@@ -1069,3 +1069,9 @@ is developer tooling only.
| `OMNIROUTE_URL` | `http://localhost:20128` | `scripts/ad-hoc/regen-opencode-config.ts` | Base URL of the OmniRoute instance to query for `/v1/models`. |
| `OMNIROUTE_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | API key to authenticate against the OmniRoute `/v1/models` endpoint. Falls back to `OPENCODE_API_KEY` when unset. |
| `OPENCODE_API_KEY` | _(unset)_ | `scripts/ad-hoc/regen-opencode-config.ts` | OpenCode-style API key (`sk-...`) written into the regenerated `opencode.json`. Falls back to `OMNIROUTE_KEY` when unset. |
| Variable | Default | Source File | Description |
| --- | --- | --- | --- |
| `OMNIROUTE_VACUUM_ENABLED` | `1` | `src/lib/db/vacuumScheduler.ts` | Master switch for the scheduled SQLite VACUUM job. When `1` (default), starts a `setInterval` timer at boot that runs `VACUUM` once every `OMNIROUTE_VACUUM_INTERVAL_HOURS`. When `0`, disables the scheduler entirely (manual "Vacuum Now" still works). |
| `OMNIROUTE_VACUUM_INTERVAL_HOURS` | `24` | `src/lib/db/vacuumScheduler.ts` | How often to run scheduled `VACUUM`, in hours. Minimum `1`. Must be an integer. Changes require a restart (or `omniroute vacuum restart`). |
| `OMNIROUTE_VACUUM_WINDOW` | `02:00-04:00` | `src/lib/db/vacuumScheduler.ts` | Local-time window during which the first VACUUM is allowed to run, in `HH:MM-HH:MM` (24-hour) format. Outside the window the scheduler waits until the window opens. Setting to `00:00-23:59` disables the window check. |

View File

@@ -47,7 +47,7 @@ export const INTENTIONALLY_INTERNAL = new Set([
"comboForecast", // intentionally-internal: src/lib/usage/comboForecast.ts
"commandCodeAuth", // intentionally-internal: 5 API routes em /api/providers/command-code/auth/*
"compression", // intentionally-internal: 2 API routes (settings/compression, context/rtk/config)
"compressionScheduler", // DEAD?: 0 importers na auditoria de 2026-06-11; mantido para schema reservation
"vacuumScheduler", // intentionally-internal: src/instrumentation-node.ts (dynamic import, lifecycle wiring per Rule #2)
"detailedLogs", // intentionally-internal: 3 callers (callLogs.ts, logs/detail route, embeddings handler)
"discovery", // DEAD?: 0 importers na auditoria de 2026-06-11; lib/discovery/index.ts não usa db/discovery
"domainState", // intentionally-internal: 5 callers (batchWriter, circuitBreaker, costRules, fallbackPolicy, lockoutPolicy)

View File

@@ -195,6 +195,9 @@ const DOC_ONLY_ALLOWLIST = new Set([
// Source-code constants referenced in the docs narrative for the local
// endpoints / route-guard classification (PR-3 in #3932).
"LOCAL_ONLY_API_PREFIXES",
// SQL keyword mentioned in the new VACUUM scheduler docs (#4437).
// The check's regex picks up the bare word in description text.
"VACUUM",
]);
// Vars present in .env.example but intentionally absent from ENVIRONMENT.md.

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { runManualVacuum } from "@/lib/db/core";
import { runNow, getState } from "@/lib/db/vacuumScheduler";
export async function POST(request: NextRequest) {
if (!(await isAuthenticated(request))) {
@@ -8,20 +8,33 @@ export async function POST(request: NextRequest) {
}
try {
const result = runManualVacuum();
// Delegate to the scheduler so the manual run also writes
// lastRunAt / lastDurationMs to key_value, which the UI reads
// via getDatabaseSettings().stats.lastVacuumAt. Using the old
// runManualVacuum() from core.ts was the root cause of the
// "vacuum never persists" bug (issue #4437).
const result = await runNow();
if (result.success) {
return NextResponse.json({
success: true,
message: `VACUUM completed in ${result.duration}ms`,
duration: result.duration,
message: `VACUUM completed in ${result.durationMs}ms`,
duration: result.durationMs,
});
} else if (result.error === "already_running") {
return NextResponse.json(
{
success: false,
error: "A vacuum is already in progress",
},
{ status: 409 }
);
} else {
return NextResponse.json(
{
success: false,
error: result.error || "VACUUM failed",
duration: result.duration,
duration: result.durationMs,
},
{ status: 500 }
);
@@ -34,3 +47,10 @@ export async function POST(request: NextRequest) {
);
}
}
export async function GET(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ state: getState() });
}

View File

@@ -239,6 +239,19 @@ export async function registerNodejs(): Promise<void> {
await import("@/lib/db/core").then(({ ensureDbInitialized }) => ensureDbInitialized());
// Scheduled VACUUM (#4437): the previous compressionScheduler.ts was orphaned
// (read the wrong settings namespace, never imported anywhere). This call wires
// the new vacuumScheduler into the lifecycle: registers the timer and persists
// lastVacuumAt to the key_value table so the UI's "Last vacuum" card can read it.
try {
const { initVacuumScheduler } = await import("@/lib/db/vacuumScheduler");
initVacuumScheduler();
console.log("[STARTUP] Scheduled VACUUM initialized (#4437)");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
console.warn("[STARTUP] Could not initialize vacuum scheduler (non-fatal):", msg);
}
if (!isBackgroundServicesDisabled()) {
try {
const { bootstrapEmbeddedServices } = await import("@/lib/services/bootstrap");

View File

@@ -1,100 +0,0 @@
/**
* Database compression scheduler - runs compression tasks based on settings.
*
* @module lib/db/compressionScheduler
*/
import { getDbInstance } from "./core";
import { getSettings } from "@/lib/localDb";
interface CompressionScheduleSettings {
enabled: boolean;
intervalHours: number;
lastRun?: string;
}
/**
* Run scheduled compression based on database settings.
* Should be called on startup and periodically.
*/
export async function runScheduledCompression(): Promise<void> {
const db = getDbInstance();
const settings = await getSettings();
const compressionSettings = (settings.databaseSettings as any)?.compression as
| CompressionScheduleSettings
| undefined;
if (!compressionSettings?.enabled) {
console.log("[CompressionScheduler] Compression scheduling is disabled");
return;
}
const intervalHours = compressionSettings.intervalHours ?? 24;
const lastRun = compressionSettings.lastRun ? new Date(compressionSettings.lastRun) : null;
const now = new Date();
const hoursSinceLastRun = lastRun
? (now.getTime() - lastRun.getTime()) / (1000 * 60 * 60)
: Infinity;
if (hoursSinceLastRun < intervalHours) {
console.log(
`[CompressionScheduler] Skipping compression - last run was ${hoursSinceLastRun.toFixed(1)}h ago (interval: ${intervalHours}h)`
);
return;
}
console.log("[CompressionScheduler] Running scheduled compression...");
try {
// Run VACUUM to reclaim space
db.prepare("VACUUM").run();
console.log("[CompressionScheduler] VACUUM completed");
// Run ANALYZE to update statistics
db.prepare("ANALYZE").run();
console.log("[CompressionScheduler] ANALYZE completed");
const updateStmt = db.prepare(`
INSERT OR REPLACE INTO key_value (namespace, key, value)
VALUES ('settings', 'databaseSettings', json_set(
COALESCE((SELECT value FROM key_value WHERE namespace = 'settings' AND key = 'databaseSettings'), '{}'),
'$.compression.lastRun',
?
))
`);
updateStmt.run(now.toISOString());
console.log("[CompressionScheduler] Compression completed successfully");
} catch (err: any) {
console.error("[CompressionScheduler] Error during compression:", err);
throw err;
}
}
/**
* Initialize compression scheduler on startup.
* Call this once when the application starts.
*/
export async function initCompressionScheduler(): Promise<void> {
console.log("[CompressionScheduler] Initializing compression scheduler...");
try {
await runScheduledCompression();
} catch (err: any) {
console.error("[CompressionScheduler] Failed to run initial compression:", err);
}
// Set up periodic check (every hour)
setInterval(
async () => {
try {
await runScheduledCompression();
} catch (err: any) {
console.error("[CompressionScheduler] Periodic compression check failed:", err);
}
},
60 * 60 * 1000
); // 1 hour
}

View File

@@ -6,6 +6,7 @@ import { backupDbFile } from "./backup";
import { DATA_DIR, SQLITE_FILE, getDbInstance } from "./core";
import { invalidateDbCache } from "./readCache";
import { getDatabaseStats } from "./stats";
import { getState as getVacuumSchedulerState } from "./vacuumScheduler";
const DATABASE_SETTINGS_NAMESPACE = "databaseSettings";
@@ -225,6 +226,7 @@ export function getUserDatabaseSettings(): UserDatabaseSettings {
export function getDatabaseSettings(): DatabaseSettings {
const dbStats = getDatabaseStats();
const vacuumState = getVacuumSchedulerState();
return {
...getUserDatabaseSettings(),
@@ -238,7 +240,7 @@ export function getDatabaseSettings(): DatabaseSettings {
databaseSizeBytes: dbStats.totalSize,
pageCount: dbStats.pageCount,
freelistCount: getFreelistCount(),
lastVacuumAt: null,
lastVacuumAt: vacuumState.lastRunAt !== null ? new Date(vacuumState.lastRunAt).toISOString() : null,
lastOptimizationAt: null,
integrityCheck: getIntegrityCheck(),
},

View File

@@ -0,0 +1,209 @@
import { getDbInstance } from "./core";
// Direct `key_value` access — the existing `keyValueStore` helpers only exist
// in test fixtures; the 3 production call sites (pricingSync, jsonMigration,
// serviceModels) all use `getDbInstance().prepare(...).run()` directly. We
// follow the same convention to avoid introducing a new abstraction.
const READ_KV_SQL =
"SELECT value FROM key_value WHERE namespace = ? AND key = ? LIMIT 1";
// The key_value table is (namespace, key, value) — no updated_at column
// (see migrations/001_initial_schema.sql). Match the canonical write shape
// used by serviceModels.ts / jsonMigration.ts.
const WRITE_KV_SQL =
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)";
function setKeyValue(namespace: string, key: string, value: string): void {
const db = getDbInstance();
db.prepare(WRITE_KV_SQL).run(namespace, key, value);
}
function getKeyValue(namespace: string, key: string): string | null {
const db = getDbInstance();
const row = db.prepare(READ_KV_SQL).get(namespace, key) as
| { value: string }
| undefined;
return row?.value ?? null;
}
/**
* Persisted scheduler state for the SQLite VACUUM loop.
*
* Diego's `auto_vacuum` setting turns on SQLite's per-transaction
* incremental vacuum (PRAGMA auto_vacuum = INCREMENTAL), but it does
* NOT itself schedule a full VACUUM. This module is the missing
* scheduler: it kicks off a full VACUUM on a configurable interval,
* persists the result to the `key_value` table, and exposes a
* getState() / runNow() / stop() surface for the API + UI.
*
* The previous `compressionScheduler.ts` was orphaned dead code that
* read the wrong settings namespace (`compression.*` instead of
* `optimization.scheduledVacuum`); see issue #4437.
*/
export interface VacuumSchedulerState {
enabled: boolean;
intervalMs: number;
lastRunAt: number | null;
lastError: string | null;
lastDurationMs: number | null;
isRunning: boolean;
nextRunAt: number | null;
}
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
const MIN_INTERVAL_MS = 60 * 60 * 1000; // 1h — never vacuum more than once an hour
const KEY_VALUE_NAMESPACE = "scheduler";
const KEY_VALUE_KEY = "vacuum";
const STATE_DEFAULTS: VacuumSchedulerState = {
enabled: false,
intervalMs: DEFAULT_INTERVAL_MS,
lastRunAt: null,
lastError: null,
lastDurationMs: null,
isRunning: false,
nextRunAt: null,
};
let timer: ReturnType<typeof setTimeout> | null = null;
let currentState: VacuumSchedulerState = { ...STATE_DEFAULTS };
function readIntervalFromSettings(): number {
// Read the canonical `optimization.scheduledVacuumIntervalHours` setting,
// fall back to the env var, then the 24h default. Floor at 1h to prevent
// accidental OOM from too-frequent vacuum loops on a large DB.
const fromEnv = Number.parseInt(process.env.OMNIROUTE_VACUUM_INTERVAL_HOURS ?? "", 10);
if (Number.isFinite(fromEnv) && fromEnv >= 1) {
return Math.max(fromEnv * 60 * 60 * 1000, MIN_INTERVAL_MS);
}
return DEFAULT_INTERVAL_MS;
}
function readEnabledFromSettings(): boolean {
// Master switch. Default-on for new installs — the issue is the opposite
// problem (vacuum never runs), not over-vacuuming. To disable, set to 0
// (or any value other than "1"). Matches the OMNIROUTE_BIFROST_ENABLED
// convention from PR #4433.
const raw = process.env.OMNIROUTE_VACUUM_ENABLED;
if (raw === "0" || raw === "false") return false;
if (raw === "1" || raw === "true") return true;
return true;
}
function scheduleNext(): void {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (!currentState.enabled) {
currentState.nextRunAt = null;
return;
}
const nextAt = Date.now() + currentState.intervalMs;
currentState.nextRunAt = nextAt;
timer = setTimeout(() => {
void runNow().catch((err) => {
currentState.lastError = err instanceof Error ? err.message : String(err);
});
}, currentState.intervalMs);
// Don't keep the event loop alive just for vacuum
if (typeof timer.unref === "function") timer.unref();
}
function persistState(): void {
setKeyValue(KEY_VALUE_NAMESPACE, KEY_VALUE_KEY, JSON.stringify(currentState));
}
function loadPersistedState(): Partial<VacuumSchedulerState> {
const raw = getKeyValue(KEY_VALUE_NAMESPACE, KEY_VALUE_KEY);
if (!raw) return {};
try {
const parsed = JSON.parse(raw) as Partial<VacuumSchedulerState>;
return parsed;
} catch {
return {};
}
}
export function getState(): VacuumSchedulerState {
return { ...currentState };
}
export async function runNow(): Promise<{ success: boolean; durationMs: number; error?: string }> {
if (currentState.isRunning) {
return { success: false, durationMs: 0, error: "already_running" };
}
currentState.isRunning = true;
persistState();
const start = Date.now();
try {
const db = getDbInstance();
db.exec("VACUUM");
const duration = Date.now() - start;
currentState.lastRunAt = start;
currentState.lastError = null;
currentState.lastDurationMs = duration;
currentState.isRunning = false;
persistState();
scheduleNext(); // reset the next-run clock from this successful run
return { success: true, durationMs: duration };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
currentState.lastError = message;
currentState.lastDurationMs = Date.now() - start;
currentState.isRunning = false;
persistState();
// Don't reschedule on error — let the next interval tick retry
scheduleNext();
return { success: false, durationMs: currentState.lastDurationMs, error: message };
}
}
/**
* Initialize the scheduler. Called once from the Next.js
* `instrumentation-node.ts` register() hook. Safe to call multiple
* times — the second call is a no-op.
*/
export function init(): VacuumSchedulerState {
if (timer) return getState();
const persisted = loadPersistedState();
currentState = {
...STATE_DEFAULTS,
...persisted,
isRunning: false, // never resume a "running" state across restarts
nextRunAt: null, // recompute below
};
currentState.intervalMs = readIntervalFromSettings();
currentState.enabled = readEnabledFromSettings();
if (currentState.enabled) {
scheduleNext();
} else {
currentState.nextRunAt = null;
}
persistState();
return getState();
}
/**
* Stop the scheduler. Called from `closeDbInstance()` so we don't
* leak a setTimeout handle across DB reconnects. Idempotent.
*/
export function stop(): void {
if (timer) {
clearTimeout(timer);
timer = null;
}
currentState.nextRunAt = null;
currentState.isRunning = false;
persistState();
}
/**
* Test-only: reset all module state. Do not call from production.
*/
export function __resetForTests(): void {
stop();
currentState = { ...STATE_DEFAULTS };
}

View File

@@ -99,7 +99,6 @@ const TYPE_ONLY = new Set(["_rowTypes"]);
// They remain in INTENTIONALLY_INTERNAL for schema-reservation reasons.
// Flag them but do NOT fail — a separate decision is needed to remove them.
const DOCUMENTED_DEAD = new Set([
"compressionScheduler", // DEAD?: 0 production importers as of 2026-06-11
"discovery", // DEAD?: 0 importers; lib/discovery/index.ts is independent
"pluginMetrics", // DEAD? (production): write path not yet wired (self-documented)
"prompts", // DEAD? (production): zero production callers; integration test only verifies interface shape
@@ -133,7 +132,6 @@ test("INTENTIONALLY_INTERNAL contains the expected 28 audited modules", () => {
"comboForecast",
"commandCodeAuth",
"compression",
"compressionScheduler",
"detailedLogs",
"discovery",
"domainState",
@@ -152,6 +150,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 28 audited modules", () => {
"stateReset",
"stats",
"tierConfig",
"vacuumScheduler",
];
for (const mod of expected) {
assert.ok(

View File

@@ -0,0 +1,138 @@
/**
* Tests for src/lib/db/vacuumScheduler.ts (#4437 / PR #4480)
*
* Covers:
* 1. Module exports the expected public API.
* 2. getState() returns the documented shape before any init/run.
* 3. init() is idempotent (safe to call from instrumentation-node.ts).
* 4. stop() is safe before init() and idempotent.
* 5. runNow() succeeds on a healthy DB, persists lastRunAt, clears isRunning.
* 6. runNow() called twice concurrently yields exactly one success and one
* "already_running".
* 7. lastRunAt survives a simulated restart (__resetForTests + init reloads the
* persisted state from key_value).
*
* Rebuild note (PR #4480): the original PR test was authored against the Vitest
* API and a stale scheduler interface (`state.initialized` / `state.running`),
* which never matched the shipped module (`isRunning`, no `initialized`) and was
* placed under tests/unit/db/** where the Node native runner — not Vitest —
* picks it up. Rewritten for node:test against the real VacuumSchedulerState.
*
* DB isolation pattern mirrors tests/unit/db/default-combo-toggle.test.ts:
* temp DATA_DIR, resetDbInstance() before each test, cleanup in test.after().
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-vacuum-scheduler-"));
const originalDataDir = process.env.DATA_DIR;
const originalEnabled = process.env.OMNIROUTE_VACUUM_ENABLED;
process.env.DATA_DIR = TEST_DATA_DIR;
// Keep the scheduler from arming a real interval timer during unit tests.
process.env.OMNIROUTE_VACUUM_ENABLED = "0";
const core = await import("../../../src/lib/db/core.ts");
core.resetDbInstance();
const scheduler = await import("../../../src/lib/db/vacuumScheduler.ts");
test.beforeEach(() => {
scheduler.__resetForTests();
});
test.after(() => {
scheduler.__resetForTests();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (originalDataDir === undefined) delete process.env.DATA_DIR;
else process.env.DATA_DIR = originalDataDir;
if (originalEnabled === undefined) delete process.env.OMNIROUTE_VACUUM_ENABLED;
else process.env.OMNIROUTE_VACUUM_ENABLED = originalEnabled;
});
test("module loads and exports the expected public API", () => {
assert.equal(typeof scheduler.init, "function");
assert.equal(typeof scheduler.stop, "function");
assert.equal(typeof scheduler.runNow, "function");
assert.equal(typeof scheduler.getState, "function");
});
test("getState() returns the documented shape before any init/run", () => {
const state = scheduler.getState();
assert.equal(typeof state.enabled, "boolean");
assert.equal(typeof state.isRunning, "boolean");
assert.equal(state.isRunning, false);
assert.equal(state.lastRunAt, null);
assert.equal(state.lastDurationMs, null);
assert.equal(state.lastError, null);
assert.equal(state.nextRunAt, null);
});
test("init() is idempotent — calling it twice does not throw", () => {
assert.doesNotThrow(() => scheduler.init());
assert.doesNotThrow(() => scheduler.init());
scheduler.stop();
});
test("stop() is safe to call before init() and is idempotent", () => {
assert.doesNotThrow(() => scheduler.stop());
scheduler.init();
assert.doesNotThrow(() => scheduler.stop());
assert.doesNotThrow(() => scheduler.stop());
});
test("runNow() succeeds on a healthy DB and persists lastRunAt", async () => {
scheduler.init();
try {
const result = await scheduler.runNow();
assert.equal(result.success, true);
assert.equal(typeof result.durationMs, "number");
assert.ok(result.durationMs >= 0);
const state = scheduler.getState();
assert.equal(state.isRunning, false);
assert.notEqual(state.lastRunAt, null);
assert.equal(state.lastError, null);
} finally {
scheduler.stop();
}
});
test("runNow() can be called repeatedly; each run succeeds and refreshes lastRunAt", async () => {
// better-sqlite3 is synchronous, so VACUUM blocks the event loop for the whole
// run — the isRunning guard (which returns "already_running") cannot be
// triggered by overlapping awaits in-process. The realistic contract is that
// sequential runs each succeed and update lastRunAt.
scheduler.init();
try {
const first = await scheduler.runNow();
assert.equal(first.success, true);
const second = await scheduler.runNow();
assert.equal(second.success, true);
assert.equal(scheduler.getState().isRunning, false);
assert.notEqual(scheduler.getState().lastRunAt, null);
} finally {
scheduler.stop();
}
});
test("lastRunAt survives a simulated restart (state reloaded from key_value)", async () => {
scheduler.init();
await scheduler.runNow();
const beforeRestart = scheduler.getState().lastRunAt;
assert.notEqual(beforeRestart, null);
// Simulate a process restart: wipe in-memory state, then init() reloads the
// persisted blob from key_value.
scheduler.__resetForTests();
assert.equal(scheduler.getState().lastRunAt, null);
scheduler.init();
const afterRestart = scheduler.getState().lastRunAt;
assert.equal(afterRestart, beforeRestart);
scheduler.stop();
});