fix(backup): persist last-success/error status; wall-clock-based schedule
BackupService tracked last-success/last-error as plain in-process fields and scheduled the daily backup via setInterval measured from process start — so any server restart (deploy/crash/OOM/reboot, routine under `restart: always`) silently reset the admin UI to "last successful backup: Never" and drifted the actual cadence, independent of whether backups were writing correctly to disk (they were — a real field incident at park-buzi showed 7 valid rotating backups on disk with the status stuck on "Never"). Persist last-success/error to new site_config columns (migration 0025) and add BackupService.isDue(), computed from the persisted timestamp instead of process uptime; server.ts now polls every 15 min and lets isDue() gate the actual run. No API/UI contract change. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { eq, siteConfig } from "@parking/db";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BackupService } from "./backup-service.js";
|
||||
|
||||
// BackupService previously tracked last-success/last-error as plain in-process fields, so a
|
||||
// server restart (a fresh BackupService instance, exactly as happens on every deploy/crash/OOM
|
||||
// reboot under `restart: always`) silently reset the admin UI to "last successful backup:
|
||||
// Never" — even with valid, correctly-rotating backups already on disk (2026-08-30 field
|
||||
// incident, park-buzi). These tests exercise the fix: status is read from site_config, so a new
|
||||
// BackupService instance pointed at the same DB sees the prior instance's last-run outcome, and
|
||||
// the schedule is wall-clock-based (isDue()) rather than time-since-process-start.
|
||||
// See wiki/concepts/backup-recovery.md.
|
||||
|
||||
const KEY = "a-test-backup-key-that-is-long-enough";
|
||||
|
||||
let workDir: string;
|
||||
let target: string;
|
||||
|
||||
beforeEach(() => {
|
||||
workDir = mkdtempSync(join(tmpdir(), "pk-backup-service-test-"));
|
||||
target = join(workDir, "target");
|
||||
process.env.BACKUP_KEY = KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
delete process.env.BACKUP_KEY;
|
||||
});
|
||||
|
||||
function setTargetDir(db: ReturnType<typeof createTestDb>["db"], dir: string): void {
|
||||
const existing = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
if (existing) {
|
||||
db.update(siteConfig).set({ backupTargetDir: dir }).where(eq(siteConfig.id, 1)).run();
|
||||
} else {
|
||||
db.insert(siteConfig).values({ id: 1, backupTargetDir: dir }).run();
|
||||
}
|
||||
}
|
||||
|
||||
describe("BackupService — persisted status survives a restart", () => {
|
||||
it("a fresh instance sees the previous instance's last success", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
expect(first.status().lastSuccessAt).toBeNull();
|
||||
const result = await first.run("manual");
|
||||
|
||||
// Simulate a process restart: a brand-new BackupService over the SAME db handle (in
|
||||
// production this would be a fresh process re-opening the same sqlite file).
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastSuccessAt).not.toBeNull();
|
||||
expect(status.lastResult).toEqual({ path: result.path, bytes: result.bytes, prunedFiles: result.prunedFiles });
|
||||
expect(status.lastError).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("a fresh instance sees the previous instance's last error, and it clears on next success", async () => {
|
||||
const t = createTestDb();
|
||||
// Target dir set, but as a FILE (not a directory) — runBackup's mkdir(recursive) will
|
||||
// throw, giving us a real, deterministic failure without needing to mock anything.
|
||||
const badTarget = join(workDir, "not-a-dir");
|
||||
writeFileSync(badTarget, "x");
|
||||
setTargetDir(t.db, badTarget);
|
||||
|
||||
const first = new BackupService(t.db);
|
||||
await expect(first.run("manual")).rejects.toThrow();
|
||||
|
||||
const second = new BackupService(t.db);
|
||||
const status = second.status();
|
||||
expect(status.lastError).not.toBeNull();
|
||||
expect(status.lastErrorAt).not.toBeNull();
|
||||
expect(status.lastSuccessAt).toBeNull();
|
||||
|
||||
// Now point at a real directory and succeed — the persisted error must clear.
|
||||
setTargetDir(t.db, target);
|
||||
await second.run("manual");
|
||||
const third = new BackupService(t.db);
|
||||
const finalStatus = third.status();
|
||||
expect(finalStatus.lastSuccessAt).not.toBeNull();
|
||||
expect(finalStatus.lastError).toBeNull();
|
||||
expect(finalStatus.lastErrorAt).toBeNull();
|
||||
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupService — isDue() is wall-clock-based, not process-uptime-based", () => {
|
||||
it("is due immediately when no success has ever been recorded", () => {
|
||||
const t = createTestDb();
|
||||
const svc = new BackupService(t.db);
|
||||
expect(svc.isDue()).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is NOT due right after a fresh instance is constructed, if a recent success is persisted", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const first = new BackupService(t.db);
|
||||
await first.run("manual");
|
||||
|
||||
// The whole point of the fix: a brand-new instance (simulating a restart moments after a
|
||||
// real backup completed) must NOT think a backup is due just because ITS OWN uptime is ~0.
|
||||
const second = new BackupService(t.db);
|
||||
expect(second.isDue()).toBe(false);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("is due once the persisted last-success timestamp is old enough", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
|
||||
const almostADayLater = new Date(Date.now() + 23 * 60 * 60 * 1000);
|
||||
expect(svc.isDue(almostADayLater)).toBe(false);
|
||||
|
||||
const overADayLater = new Date(Date.now() + 24 * 60 * 60 * 1000 + 1000);
|
||||
expect(svc.isDue(overADayLater)).toBe(true);
|
||||
t.close();
|
||||
});
|
||||
|
||||
it("runScheduled() is a no-op when not yet due, even if configured", async () => {
|
||||
const t = createTestDb();
|
||||
setTargetDir(t.db, target);
|
||||
const svc = new BackupService(t.db);
|
||||
await svc.run("manual");
|
||||
const afterFirst = svc.status().lastSuccessAt;
|
||||
|
||||
await svc.runScheduled(); // not due yet — must not run again
|
||||
expect(svc.status().lastSuccessAt).toBe(afterFirst);
|
||||
t.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user