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:
2026-08-30 18:11:23 +02:00
parent 3a176c5cc8
commit 2910672b5a
7 changed files with 317 additions and 28 deletions
+139
View File
@@ -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();
});
});