From 9407e482932e75555889b403e37520dbac002978 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:32:17 +0200 Subject: [PATCH] feat(backup): scheduled snapshot worker wired into server lifecycle --- packages/server/src/index.ts | 4 +++ .../server/src/utils/backupWorker.test.ts | 34 +++++++++++++++++++ packages/server/src/utils/backupWorker.ts | 33 ++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 packages/server/src/utils/backupWorker.test.ts create mode 100644 packages/server/src/utils/backupWorker.ts diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f1278349..ba736e64 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -26,6 +26,7 @@ import { adminRoutes } from './routes/admin.js'; import { gifRoutes } from './routes/gif.js'; import { federationRoutes } from './routes/federation.js'; import { startFederationWorkers, stopFederationWorkers } from './utils/federationWorker.js'; +import { startBackupWorker, stopBackupWorker } from './utils/backupWorker.js'; import './utils/federationRollback.js'; // Side-effect: registers rollback callbacks for outbox terminal failures. import { registerCallRelayHooks } from './ws/events.js'; import { resetStalePresenceOnBoot } from './utils/presenceBoot.js'; @@ -177,9 +178,12 @@ async function main(): Promise { startFederationWorkers(); } + startBackupWorker(); + const shutdown = async () => { console.log('Shutting down...'); stopFederationWorkers(); + stopBackupWorker(); await app.close(); closeDatabase(); // checkpoints WAL — leaves a complete on-disk file process.exit(0); diff --git a/packages/server/src/utils/backupWorker.test.ts b/packages/server/src/utils/backupWorker.test.ts new file mode 100644 index 00000000..2dc711a1 --- /dev/null +++ b/packages/server/src/utils/backupWorker.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { createSnapshot, pruneSnapshots, state } = vi.hoisted(() => ({ + createSnapshot: vi.fn(() => '/tmp/snap-scheduled.db'), + pruneSnapshots: vi.fn(), + state: { disabled: false }, +})); + +vi.mock('./backup.js', () => ({ createSnapshot, pruneSnapshots })); +vi.mock('../db/index.js', () => ({ getRawDb: () => ({}) })); +vi.mock('../config.js', () => ({ + config: { backup: { get disabled() { return state.disabled; }, intervalHours: 1 } }, +})); + +import { startBackupWorker, stopBackupWorker } from './backupWorker.js'; + +beforeEach(() => { vi.useFakeTimers(); createSnapshot.mockClear(); pruneSnapshots.mockClear(); state.disabled = false; }); +afterEach(() => { stopBackupWorker(); vi.useRealTimers(); }); + +describe('backupWorker', () => { + it('snapshots on each interval tick', () => { + startBackupWorker(); + vi.advanceTimersByTime(60 * 60 * 1000); // 1h + expect(createSnapshot).toHaveBeenCalledWith(expect.anything(), 'scheduled'); + expect(pruneSnapshots).toHaveBeenCalledOnce(); + }); + + it('does nothing when disabled', () => { + state.disabled = true; + startBackupWorker(); + vi.advanceTimersByTime(60 * 60 * 1000); + expect(createSnapshot).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/utils/backupWorker.ts b/packages/server/src/utils/backupWorker.ts new file mode 100644 index 00000000..1ed936fb --- /dev/null +++ b/packages/server/src/utils/backupWorker.ts @@ -0,0 +1,33 @@ +import { config } from '../config.js'; +import { getRawDb } from '../db/index.js'; +import { createSnapshot, pruneSnapshots } from './backup.js'; + +let timer: ReturnType | null = null; + +export function startBackupWorker(): void { + if (config.backup.disabled) { + console.log('[backup] scheduled worker disabled via BACKUP_DISABLED'); + return; + } + if (timer) return; + const intervalMs = config.backup.intervalHours * 60 * 60 * 1000; + timer = setInterval(() => { + try { + const snap = createSnapshot(getRawDb(), 'scheduled'); + pruneSnapshots(); + console.log(`[backup] scheduled snapshot written: ${snap}`); + } catch (err) { + console.error(`[backup] scheduled snapshot failed: ${(err as Error).message}`); + } + }, intervalMs); + // Do not keep the event loop alive solely for backups. + if (typeof timer.unref === 'function') timer.unref(); + console.log(`[backup] scheduled worker started (every ${config.backup.intervalHours}h)`); +} + +export function stopBackupWorker(): void { + if (timer) { + clearInterval(timer); + timer = null; + } +}