Run scheduled backups in an isolated subprocess
This commit is contained in:
16
scripts/scheduled-backup.ts
Normal file
16
scripts/scheduled-backup.ts
Normal 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(); }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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();
|
||||
});
|
||||
|
||||
@@ -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<number> {
|
||||
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<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); } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user