feat(webui): scheduled backups via instrumentation-started interval
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export async function register(): Promise<void> {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { startBackupScheduler } = await import("./lib/server/backupScheduler");
|
||||
startBackupScheduler();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-sched-"));
|
||||
const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR;
|
||||
process.env.EVOSCIENTIST_DATA_DIR = dataDir;
|
||||
|
||||
const scheduler = await import("./backupScheduler");
|
||||
const { listBackups, backupsDir } = await import("./backup");
|
||||
const { getSystemConfig, saveSystemConfig, resetSystemConfigCacheForTests } =
|
||||
await import("./systemConfig");
|
||||
|
||||
const HOUR = 3600_000;
|
||||
let now = 1_800_000_000_000;
|
||||
|
||||
describe("backupScheduler.tickBackupSchedule", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(backupsDir(), { recursive: true, force: true });
|
||||
scheduler.stopBackupSchedulerForTests();
|
||||
const config = getSystemConfig();
|
||||
config.backup.backendDataDir = "";
|
||||
config.backup.schedule = { enabled: true, intervalHours: 1, keepCount: 2 };
|
||||
saveSystemConfig(config);
|
||||
resetSystemConfigCacheForTests();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR;
|
||||
else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runs when due, skips when not due, and prunes", async () => {
|
||||
await scheduler.tickBackupSchedule(now);
|
||||
expect(listBackups()).toHaveLength(1);
|
||||
|
||||
await scheduler.tickBackupSchedule(now + 30 * 60_000); // 30 min later: not due
|
||||
expect(listBackups()).toHaveLength(1);
|
||||
|
||||
await scheduler.tickBackupSchedule(now + HOUR);
|
||||
expect(listBackups()).toHaveLength(2);
|
||||
|
||||
await scheduler.tickBackupSchedule(now + 2 * HOUR); // creates 3rd, prunes to 2
|
||||
expect(listBackups()).toHaveLength(2);
|
||||
expect(listBackups()[0].origin).toBe("auto");
|
||||
});
|
||||
|
||||
it("does nothing when disabled", async () => {
|
||||
const config = getSystemConfig();
|
||||
config.backup.schedule.enabled = false;
|
||||
saveSystemConfig(config);
|
||||
resetSystemConfigCacheForTests();
|
||||
await scheduler.tickBackupSchedule(now + 10 * HOUR);
|
||||
expect(listBackups()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import "server-only";
|
||||
|
||||
import { createBackup, pruneBackups } from "./backup";
|
||||
import { getSystemConfig } from "./systemConfig";
|
||||
import { appendErrorLog } from "./errorLogStore";
|
||||
|
||||
const TICK_MS = 60_000;
|
||||
|
||||
const globalState = globalThis as {
|
||||
__evoscientistBackupTimer?: NodeJS.Timeout;
|
||||
__evoscientistBackupLastRunAt?: number;
|
||||
};
|
||||
|
||||
/** One scheduler tick: run an auto-backup when the schedule says one is due,
|
||||
* then prune to the configured keep count. Never throws — the interval must
|
||||
* survive individual failures. */
|
||||
export async function tickBackupSchedule(now = Date.now()): Promise<void> {
|
||||
const schedule = getSystemConfig().backup.schedule;
|
||||
if (!schedule.enabled) return;
|
||||
const lastRunAt = globalState.__evoscientistBackupLastRunAt ?? 0;
|
||||
if (now - lastRunAt < schedule.intervalHours * 3_600_000) return;
|
||||
try {
|
||||
await createBackup("auto");
|
||||
pruneBackups(schedule.keepCount);
|
||||
globalState.__evoscientistBackupLastRunAt = now;
|
||||
} catch (error) {
|
||||
try {
|
||||
appendErrorLog("system", {
|
||||
source: "backup",
|
||||
code: null,
|
||||
message:
|
||||
error instanceof Error ? error.message : "Scheduled backup failed.",
|
||||
});
|
||||
} catch {
|
||||
// Logging must never mask scheduler liveness.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startBackupScheduler(): void {
|
||||
if (globalState.__evoscientistBackupTimer) return;
|
||||
const timer = setInterval(() => {
|
||||
void tickBackupSchedule();
|
||||
}, TICK_MS);
|
||||
timer.unref();
|
||||
globalState.__evoscientistBackupTimer = timer;
|
||||
}
|
||||
|
||||
export function stopBackupSchedulerForTests(): void {
|
||||
if (globalState.__evoscientistBackupTimer) {
|
||||
clearInterval(globalState.__evoscientistBackupTimer);
|
||||
}
|
||||
globalState.__evoscientistBackupTimer = undefined;
|
||||
globalState.__evoscientistBackupLastRunAt = undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user