From b0950cb4b44706ac9be344c3209d8bdfda375e16 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Tue, 8 Sep 2026 16:12:53 +0200 Subject: [PATCH] Run scheduled backups in an isolated subprocess --- scripts/scheduled-backup.ts | 16 ++++++++++++ src/index.ts | 1 + src/ops/backups.test.ts | 45 +++++++++++++++++++++++++++++++++ src/ops/backups.ts | 50 ++++++++++++++++++++++++++----------- 4 files changed, 98 insertions(+), 14 deletions(-) create mode 100644 scripts/scheduled-backup.ts diff --git a/scripts/scheduled-backup.ts b/scripts/scheduled-backup.ts new file mode 100644 index 0000000..83150ea --- /dev/null +++ b/scripts/scheduled-backup.ts @@ -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(); } +} diff --git a/src/index.ts b/src/index.ts index be1e836..1eca478 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ if (process.env.NODE_ENV !== "production") { } 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()); 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; diff --git a/src/ops/backups.test.ts b/src/ops/backups.test.ts index b9bd3ea..dc29f83 100644 --- a/src/ops/backups.test.ts +++ b/src/ops/backups.test.ts @@ -35,6 +35,7 @@ test("WAL snapshots restore committed progress, revoke sessions, replicate, and expect(readdirSync(config.replica!)).toHaveLength(2); expect(readdirSync(config.directory)).toContain("keep.txt"); const runner = startBackups(f.sqlite, config); + await runner.ready; expect(runner.state.failed).toBe(false); expect(runner.state.lastSuccess).toBeGreaterThan(0); runner.stop(); } 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 }); } 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 | 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(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(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(); +}); diff --git a/src/ops/backups.ts b/src/ops/backups.ts index 1e1176c..8a2bc12 100644 --- a/src/ops/backups.ts +++ b/src/ops/backups.ts @@ -82,18 +82,40 @@ export function restoreBackup(source: string, destination: string) { } finally { if (existsSync(temporary)) unlinkSync(temporary); } return destination; } -export function startBackups(database: Database, config: BackupConfig) { - const state = { enabled: config.enabled, lastSuccess: 0, failed: false }; - if (!config.enabled) return { state, stop() {} }; - const tick = () => { - try { - const latest = latestBackupTime(config); - if (latest && Date.now() - latest < config.intervalMs) { state.lastSuccess = latest; state.failed = false; return; } - createBackup(database, config); state.lastSuccess = Date.now(); state.failed = false; - console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date().toISOString() })); - } catch { state.failed = true; console.error(JSON.stringify({ event: "backup_failed", timestamp: new Date().toISOString() })); } - }; - tick(); - const timer = setInterval(tick, 60000); timer.unref(); - return { state, stop: () => clearInterval(timer) }; +/** Runs all filesystem and SQLite backup work in a separate Bun process. */ +export async function runScheduledBackup(database: string, config: BackupConfig): Promise { + const source = new URL("../../scripts/scheduled-backup.ts", import.meta.url); + const built = new URL("../scripts/scheduled-backup.ts", import.meta.url); + const script = (existsSync(source) ? source : built).pathname; + const child = Bun.spawn([process.execPath, script, resolve(database), JSON.stringify(config)], { + stdout: "pipe", stderr: "ignore", + }); + const [output, status] = await Promise.all([new Response(child.stdout).text(), child.exited]); + if (status !== 0) throw new Error("Scheduled backup process failed"); + const { timestamp, created } = JSON.parse(output) as { timestamp: number; created: boolean }; + if (!Number.isFinite(timestamp) || timestamp <= 0) throw new Error("Scheduled backup returned an invalid timestamp"); + if (created) console.log(JSON.stringify({ event: "backup_completed", timestamp: new Date(timestamp).toISOString() })); + return timestamp; +} +export function startBackups(database: Database | string, config: BackupConfig, run = runScheduledBackup) { + const state = { enabled: config.enabled, lastSuccess: 0, failed: false }; + let running: Promise | undefined; + let stopped = false; + const tick = (): Promise => { + 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); } }; }