Run scheduled backups in an isolated subprocess

This commit is contained in:
syntaxbullet
2026-09-08 16:12:53 +02:00
parent 31e4f3b2ef
commit b0950cb4b4
4 changed files with 98 additions and 14 deletions

View File

@@ -0,0 +1,16 @@
import { Database } from 'bun:sqlite';
import { createBackup, latestBackupTime, type BackupConfig } from '../src/ops/backups';
const path = process.argv[2];
if (!path || !process.argv[3]) throw new Error('Expected database path and backup configuration');
const config = JSON.parse(process.argv[3]) as BackupConfig;
const latest = latestBackupTime(config);
if (latest && Date.now() - latest < config.intervalMs) console.log(JSON.stringify({ timestamp: latest, created: false }));
else {
const db = new Database(path, { readonly: true, strict: true });
try {
db.exec('PRAGMA busy_timeout=5000');
createBackup(db, config);
console.log(JSON.stringify({ timestamp: latestBackupTime(config), created: true }));
} finally { db.close(); }
}

View File

@@ -15,6 +15,7 @@ if (process.env.NODE_ENV !== "production") {
} }
const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined; const backups = process.env.NODE_ENV === "production" ? startBackups(sqlite, backupConfig(databasePath)) : undefined;
await backups?.ready;
if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop()); if (import.meta.hot) import.meta.hot.dispose(() => backups?.stop());
const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined; const reminders = process.env.NODE_ENV === "production" ? startReminders(db, { token: readDiscordSharingConfig().token, origin: readAuthConfig().origin, commandsAvailable: /^[a-f\d]{64}$/i.test(process.env.DISCORD_PUBLIC_KEY ?? "") && Boolean(readAuthConfig().clientId) }) : undefined;

View File

@@ -35,6 +35,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and
expect(readdirSync(config.replica!)).toHaveLength(2); expect(readdirSync(config.replica!)).toHaveLength(2);
expect(readdirSync(config.directory)).toContain("keep.txt"); expect(readdirSync(config.directory)).toContain("keep.txt");
const runner = startBackups(f.sqlite, config); const runner = startBackups(f.sqlite, config);
await runner.ready;
expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop(); expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop();
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); } } finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
}); });
@@ -71,3 +72,47 @@ test("backup and restore CLIs work against a live database without overwriting i
expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 }); expect(f.sqlite.query('SELECT count(*) AS n FROM sessions').get()).toEqual({ n: 2 });
} finally { f.close(); rmSync(directory, { recursive: true, force: true }); } } finally { f.close(); rmSync(directory, { recursive: true, force: true }); }
}); });
test('scheduled backups yield to the event loop, retain WAL data, and verify replicas', async () => {
const directory = mkdtempSync(join(tmpdir(), 'minabot-async-backup-'));
const path = join(directory, 'live.sqlite'), f = fixture(path);
let runner: ReturnType<typeof startBackups> | undefined;
try {
f.sqlite.exec('PRAGMA journal_mode=WAL');
await f.json('/habits', 'POST', { name: 'Async snapshot', method: 'manual' }, 201);
const config = backupConfig(path, { BACKUP_REPLICA_DIR: 'replica' });
mkdirSync(config.replica!);
let yielded = false;
const timer = new Promise<void>(resolve => setTimeout(() => { yielded = true; resolve(); }, 0));
runner = startBackups(path, config);
expect(runner.state.lastSuccess).toBe(0);
await runner.ready;
expect(yielded).toBe(true); await timer;
expect(runner.state.failed).toBe(false);
expect(runner.state.lastSuccess).toBeGreaterThan(0);
const snapshots = readdirSync(config.directory).filter(file => file.endsWith('.sqlite'));
expect(snapshots).toHaveLength(1);
const check = new Database(join(config.replica!, snapshots[0]!), { readonly: true });
try { expect(check.query('SELECT count(*) AS n FROM habits').get()).toEqual({ n: 1 }); } finally { check.close(); }
await runner.tick();
expect(readdirSync(config.directory).filter(file => file.endsWith('.sqlite'))).toHaveLength(1);
} finally { runner?.stop(); f.close(); rmSync(directory, { recursive: true, force: true }); }
});
test('scheduled backup ticks coalesce, expose failure and recovery, and stop admitting work', async () => {
const config = backupConfig('/tmp/test-only.sqlite', {});
let calls = 0, release!: (value: number) => void;
const run = async () => { calls++; return new Promise<number>(resolve => { release = resolve; }); };
const runner = startBackups('/tmp/test-only.sqlite', config, run);
expect(runner.tick()).toBe(runner.ready);
expect(calls).toBe(1);
release(123); await runner.ready;
expect(runner.state.lastSuccess).toBe(123);
const second = runner.tick(); expect(calls).toBe(2);
runner.stop(); release(456); await second;
await runner.tick(); expect(calls).toBe(2);
const failed = startBackups('/tmp/test-only.sqlite', config, async () => { throw new Error('test failure'); });
await failed.ready; expect(failed.state.failed).toBe(true); failed.stop();
const disabled = startBackups('/tmp/test-only.sqlite', { ...config, enabled: false }, run);
await disabled.ready; expect(calls).toBe(2); disabled.stop();
});

View File

@@ -82,18 +82,40 @@ export function restoreBackup(source: string, destination: string) {
} finally { if (existsSync(temporary)) unlinkSync(temporary); } } finally { if (existsSync(temporary)) unlinkSync(temporary); }
return destination; return destination;
} }
export function startBackups(database: Database, config: BackupConfig) { /** Runs all filesystem and SQLite backup work in a separate Bun process. */
const state = { enabled: config.enabled, lastSuccess: 0, failed: false }; export async function runScheduledBackup(database: string, config: BackupConfig): Promise<number> {
if (!config.enabled) return { state, stop() {} }; const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url);
const tick = () => { const built = new URL("../scripts/scheduled-backup.ts", import.meta.url);
try { const script = (existsSync(source) ? source : built).pathname;
const latest = latestBackupTime(config); const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], {
if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; } stdout: "pipe", stderr: "ignore",
createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false; });
console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() })); const [output, status] = await Promise.all([new Response(child.stdout).text(), child.exited]);
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); } if (status !== 0) throw new Error("Scheduled backup process failed");
}; const { timestamp, created } = JSON.parse(output) as { timestamp: number; created: boolean };
tick(); if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("Scheduled backup returned an invalid timestamp");
const timer = setInterval(tick, 60000); timer.unref(); if (created) console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date(timestamp).toISOString() }));
return { state, stop: () => clearInterval(timer) }; return timestamp;
}
export function startBackups(database: Database | string, config: BackupConfig, run = runScheduledBackup) {
const state = { enabled: config.enabled, lastSuccess: 0, failed: false };
let running: Promise<void> | undefined;
let stopped = false;
const tick = (): Promise<void> => {
if (stopped || !config.enabled) return Promise.resolve();
if (running) return running;
running = (async () => {
try {
const path = typeof database === 'string' ? database : database.filename;
if (!path || path === ':memory:') throw new Error('Scheduled backups require a file database');
state.lastSuccess = await run(path, config); state.failed = false;
} catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); }
})().finally(() => { running = undefined; });
return running;
};
// Callers await readiness before accepting traffic, matching the original startup check.
const ready = tick();
const timer = config.enabled ? setInterval(() => { void tick(); }, 60000) : undefined;
timer?.unref();
return { state, ready, tick, stop: () => { stopped = true; clearInterval(timer); } };
} }