feat(backup): add scheduler alerts and recovery notifications
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
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';
|
||||
|
||||
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 {
|
||||
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}` : ''}`);
|
||||
await this.handleSuccess();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Backup failed: ${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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user