151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
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<void> } | undefined;
|
|
}
|
|
|
|
export interface BackupSchedulerDeps {
|
|
dataDir: string;
|
|
backupConfig: BackupConfig;
|
|
channelLookup?: ChannelLookup;
|
|
runSnapshot?: typeof runBackupSnapshot;
|
|
}
|
|
|
|
export class BackupScheduler {
|
|
private cronJob: Cron | undefined;
|
|
private intervalJob: ReturnType<typeof setInterval> | 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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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);
|
|
}
|
|
}
|
|
}
|