import { Cron } from 'croner'; import type { BackupConfig } from '../config/schema.js'; import type { OutboundMessage } from '../channels/types.js'; import { parseDuration } from '../session/index.js'; import { runBackupSnapshot } from './run.js'; import { initBackupHealth, markBackupFailure, markBackupSuccess } from './status.js'; interface ChannelLookup { get(name: string): { send(peerId: string, message: OutboundMessage): Promise } | undefined; } export interface BackupSchedulerDeps { dataDir: string; backupConfig: BackupConfig; channelLookup?: ChannelLookup; runSnapshot?: typeof runBackupSnapshot; } export class BackupScheduler { private cronJob: Cron | undefined; private intervalJob: ReturnType | undefined; private running = false; private consecutiveFailures = 0; private notifiedFailure = false; private readonly deps: BackupSchedulerDeps; constructor(deps: BackupSchedulerDeps) { this.deps = deps; } start(): void { initBackupHealth(this.deps.backupConfig.enabled); if (!this.deps.backupConfig.enabled) { return; } const backupSchedule = this.deps.backupConfig.schedule?.trim(); const backupIntervalMs = parseDuration(this.deps.backupConfig.interval); if (!backupSchedule && !backupIntervalMs) { console.warn(`Backup enabled but interval is invalid: ${this.deps.backupConfig.interval}`); return; } if (backupSchedule) { try { this.cronJob = new Cron(backupSchedule, { paused: false }, () => { void this.runScheduledBackup(); }); console.log(`Backup scheduler enabled (cron: ${backupSchedule})`); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`Backup cron schedule is invalid (${backupSchedule}): ${message}`); } } if (!this.cronJob && backupIntervalMs) { this.intervalJob = setInterval(() => { void this.runScheduledBackup(); }, backupIntervalMs); console.log(`Backup scheduler enabled (interval: ${this.deps.backupConfig.interval})`); } if (!this.cronJob && !this.intervalJob) { console.warn('Backup scheduler disabled: no valid backup.schedule or backup.interval'); return; } if (this.deps.backupConfig.run_on_start) { void this.runScheduledBackup(); } } stop(): void { if (this.cronJob) { this.cronJob.stop(); this.cronJob = undefined; } if (this.intervalJob) { clearInterval(this.intervalJob); this.intervalJob = undefined; } } private async runScheduledBackup(): Promise { if (this.running) { return; } this.running = true; try { const runner = this.deps.runSnapshot ?? runBackupSnapshot; const result = await runner({ dataDir: this.deps.dataDir, backupConfig: this.deps.backupConfig, }); console.log(`Backup completed: ${result.archivePath}${result.uploaded && result.remotePath ? ` -> ${result.remotePath}` : ''}`); markBackupSuccess(); await this.handleSuccess(); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Backup failed: ${message}`); markBackupFailure(message); await this.handleFailure(message); } finally { this.running = false; } } private async handleFailure(message: string): Promise { this.consecutiveFailures += 1; const threshold = this.deps.backupConfig.failure_threshold; if (this.consecutiveFailures < threshold || this.notifiedFailure) { return; } this.notifiedFailure = true; await this.notify( `Backup FAILING (${this.consecutiveFailures} consecutive failures).\nError: ${message}`, ); } private async handleSuccess(): Promise { if (this.notifiedFailure && this.deps.backupConfig.notify_recovery) { await this.notify( `Backup RECOVERED after ${this.consecutiveFailures} consecutive failure(s).`, ); } this.consecutiveFailures = 0; this.notifiedFailure = false; } private async notify(text: string): Promise { const notifyConfig = this.deps.backupConfig.notify; if (!notifyConfig || !this.deps.channelLookup) { return; } const adapter = this.deps.channelLookup.get(notifyConfig.channel); if (!adapter) { console.warn(`BackupScheduler: notification channel '${notifyConfig.channel}' not found`); return; } try { await adapter.send(notifyConfig.peer, { text }); } catch (err) { console.error('BackupScheduler: failed to send notification:', err); } } }