fastcheck/server/src/services/dbBackupService.ts
2026-04-08 13:58:46 -04:00

145 lines
4.5 KiB
TypeScript

import cron from 'node-cron';
import path from 'path';
import fs from 'fs/promises';
import { spawn } from 'child_process';
import ELKService from './elkService';
export class DbBackupService {
private static instance: DbBackupService;
private cronJob: cron.ScheduledTask | null = null;
private isInitialized = false;
private isRunning = false;
private constructor() {}
public static getInstance(): DbBackupService {
if (!DbBackupService.instance) {
DbBackupService.instance = new DbBackupService();
}
return DbBackupService.instance;
}
public async initialize(): Promise<void> {
if (this.isInitialized) {
ELKService.info('[DbBackupService] Already initialized');
return;
}
const enabled = (process.env.DB_BACKUP_ENABLED || 'true').toLowerCase() === 'true';
if (!enabled) {
this.isInitialized = true;
ELKService.info('[DbBackupService] Disabled by DB_BACKUP_ENABLED');
return;
}
const schedule = process.env.DB_BACKUP_CRON || '0 2 * * *';
const timezone = process.env.DB_BACKUP_TIMEZONE || 'UTC';
this.cronJob = cron.schedule(schedule, async () => {
await this.runBackup();
}, { scheduled: false, timezone });
this.cronJob.start();
this.isInitialized = true;
ELKService.info('[DbBackupService] Initialized');
ELKService.logBusinessEvent('db_backup_service_initialized', { schedule, timezone });
}
private getBackupDir(): string {
return process.env.DB_BACKUP_DIR || path.resolve(process.cwd(), 'backups');
}
private getRetentionDays(): number {
const v = parseInt(process.env.DB_BACKUP_RETENTION_DAYS || '7', 10);
return Number.isFinite(v) && v > 0 ? v : 7;
}
private async runBackup(): Promise<void> {
if (this.isRunning) {
ELKService.warn('[DbBackupService] Backup already running, skipping');
return;
}
this.isRunning = true;
const startedAt = Date.now();
try {
const uri = process.env.MONGODB_URI;
if (!uri) {
ELKService.error('[DbBackupService] Missing MONGODB_URI, cannot run backup', new Error('MONGODB_URI is not set'));
return;
}
const backupDir = this.getBackupDir();
await fs.mkdir(backupDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupFile = path.join(backupDir, `mongo-backup-${timestamp}.archive.gz`);
await this.runMongodump(uri, backupFile);
const { deletedCount } = await this.applyRetention(backupDir, this.getRetentionDays());
ELKService.info('[DbBackupService] Backup completed', {
backupFile: path.basename(backupFile),
retentionDays: this.getRetentionDays(),
deletedCount,
durationMs: Date.now() - startedAt
});
ELKService.logBusinessEvent('db_backup_completed', {
backupFile: path.basename(backupFile),
retentionDays: this.getRetentionDays(),
deletedCount
});
} catch (error) {
ELKService.error('[DbBackupService] Backup failed', error as Error);
ELKService.logBusinessEvent('db_backup_failed', { message: (error as Error)?.message || 'Unknown error' });
} finally {
this.isRunning = false;
}
}
private async runMongodump(mongoUri: string, archivePath: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn('mongodump', [`--uri=${mongoUri}`, `--archive=${archivePath}`, '--gzip'], {
stdio: ['ignore', 'ignore', 'pipe']
});
let stderr = '';
child.stderr.on('data', chunk => {
stderr += chunk?.toString?.() || '';
if (stderr.length > 20000) stderr = stderr.slice(-20000);
});
child.on('error', err => reject(err));
child.on('close', code => {
if (code === 0) return resolve();
reject(new Error(`mongodump exited with code ${code}${stderr ? `: ${stderr}` : ''}`));
});
});
}
private async applyRetention(backupDir: string, retentionDays: number): Promise<{ deletedCount: number }> {
const now = Date.now();
const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000;
const files = await fs.readdir(backupDir);
let deletedCount = 0;
for (const f of files) {
if (!f.startsWith('mongo-backup-') || !f.endsWith('.archive.gz')) continue;
const full = path.join(backupDir, f);
try {
const st = await fs.stat(full);
const ageMs = now - st.mtimeMs;
if (ageMs > maxAgeMs) {
await fs.unlink(full);
deletedCount += 1;
}
} catch {
}
}
return { deletedCount };
}
}