/** * Backup Scheduler for Onebox * * Uses @push.rocks/taskbuffer for cron-based scheduled backups * with GFS (Grandfather-Father-Son) time-window based retention scheme. */ import * as plugins from '../plugins.ts'; import type { IBackupSchedule, IBackupScheduleCreate, IBackupScheduleUpdate, IService, IRetentionPolicy, } from '../types.ts'; import { RETENTION_PRESETS } from '../types.ts'; import { logger } from '../logging.ts'; import { getErrorMessage } from '../utils/error.ts'; import type { Onebox } from './onebox.ts'; export class BackupScheduler { private oneboxRef: Onebox; private taskManager!: plugins.taskbuffer.TaskManager; private scheduledTasks: Map = new Map(); private initialized = false; constructor(oneboxRef: Onebox) { this.oneboxRef = oneboxRef; // TaskManager is created in init() to avoid log spam before ready } /** * Initialize the scheduler and load enabled schedules */ async init(): Promise { if (this.initialized) { logger.warn('BackupScheduler already initialized'); return; } try { // Create TaskManager here (not in constructor) to avoid "no cronjobs" log spam this.taskManager = new plugins.taskbuffer.TaskManager(); // Add heartbeat task immediately to prevent "no cronjobs specified" log spam // This runs hourly and does nothing, but keeps taskbuffer happy const heartbeatTask = new plugins.taskbuffer.Task({ name: 'backup-scheduler-heartbeat', taskFunction: async () => { // No-op heartbeat task }, }); this.taskManager.addAndScheduleTask(heartbeatTask, '0 * * * *'); // Hourly // Load all enabled schedules from database const schedules = this.oneboxRef.database.getEnabledBackupSchedules(); for (const schedule of schedules) { await this.registerTask(schedule); } // Start the task manager (activates cron scheduling) await this.taskManager.start(); this.initialized = true; logger.info(`Backup scheduler started with ${schedules.length} enabled schedule(s)`); } catch (error) { logger.error(`Failed to initialize backup scheduler: ${getErrorMessage(error)}`); throw error; } } /** * Stop the scheduler */ async stop(): Promise { if (!this.initialized || !this.taskManager) return; try { await this.taskManager.stop(); this.scheduledTasks.clear(); this.initialized = false; logger.info('Backup scheduler stopped'); } catch (error) { logger.error(`Failed to stop backup scheduler: ${getErrorMessage(error)}`); } } /** * Create a new backup schedule */ async createSchedule(request: IBackupScheduleCreate): Promise { // Validate based on scope type let serviceId: number | undefined; let serviceName: string | undefined; switch (request.scopeType) { case 'service': // Validate service exists if (!request.serviceName) { throw new Error('serviceName is required for service-specific schedules'); } const service = this.oneboxRef.database.getServiceByName(request.serviceName); if (!service) { throw new Error(`Service not found: ${request.serviceName}`); } serviceId = service.id!; serviceName = service.name; break; case 'pattern': // Validate pattern is provided if (!request.scopePattern) { throw new Error('scopePattern is required for pattern-based schedules'); } // Validate pattern matches at least one service const matchingServices = this.getServicesMatchingPattern(request.scopePattern); if (matchingServices.length === 0) { logger.warn(`Pattern "${request.scopePattern}" currently matches no services`); } break; case 'all': // No validation needed for global schedules break; default: throw new Error(`Invalid scope type: ${request.scopeType}`); } // Use provided cron expression const cronExpression = request.cronExpression; // Calculate next run time const nextRunAt = this.calculateNextRun(cronExpression); // Create schedule in database const schedule = this.oneboxRef.database.createBackupSchedule({ scopeType: request.scopeType, scopePattern: request.scopePattern, serviceId, serviceName, cronExpression, retention: request.retention, enabled: request.enabled !== false, lastRunAt: null, nextRunAt, lastStatus: null, lastError: null, createdAt: Date.now(), updatedAt: Date.now(), }); // Register task if enabled if (schedule.enabled) { await this.registerTask(schedule); } const scopeDesc = this.getScopeDescription(schedule); const retentionDesc = this.getRetentionDescription(schedule.retention); logger.info(`Backup schedule created: ${schedule.id} for ${scopeDesc} (${retentionDesc})`); return schedule; } /** * Update an existing backup schedule */ async updateSchedule(scheduleId: number, updates: IBackupScheduleUpdate): Promise { const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId); if (!schedule) { throw new Error(`Backup schedule not found: ${scheduleId}`); } // Deschedule existing task if present await this.descheduleTask(scheduleId); // Update database this.oneboxRef.database.updateBackupSchedule(scheduleId, updates); // Get updated schedule const updatedSchedule = this.oneboxRef.database.getBackupScheduleById(scheduleId)!; // Calculate new next run time if cron changed if (updates.cronExpression) { const nextRunAt = this.calculateNextRun(updatedSchedule.cronExpression); this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt }); } // Re-register task if enabled if (updatedSchedule.enabled) { await this.registerTask(updatedSchedule); } logger.info(`Backup schedule updated: ${scheduleId}`); return this.oneboxRef.database.getBackupScheduleById(scheduleId)!; } /** * Delete a backup schedule */ async deleteSchedule(scheduleId: number): Promise { const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId); if (!schedule) { throw new Error(`Backup schedule not found: ${scheduleId}`); } // Deschedule task await this.descheduleTask(scheduleId); // Delete from database this.oneboxRef.database.deleteBackupSchedule(scheduleId); logger.info(`Backup schedule deleted: ${scheduleId}`); } /** * Trigger immediate backup for a schedule */ async triggerBackup(scheduleId: number): Promise { const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId); if (!schedule) { throw new Error(`Backup schedule not found: ${scheduleId}`); } logger.info(`Manually triggering backup for schedule ${scheduleId}`); await this.executeBackup(schedule); } /** * Get all schedules */ getAllSchedules(): IBackupSchedule[] { return this.oneboxRef.database.getAllBackupSchedules(); } /** * Get schedule by ID */ getScheduleById(id: number): IBackupSchedule | null { return this.oneboxRef.database.getBackupScheduleById(id); } /** * Get schedules for a service */ getSchedulesForService(serviceName: string): IBackupSchedule[] { const service = this.oneboxRef.database.getServiceByName(serviceName); if (!service) { return []; } return this.oneboxRef.database.getBackupSchedulesByService(service.id!); } /** * Get retention presets */ getRetentionPresets(): typeof RETENTION_PRESETS { return RETENTION_PRESETS; } // ========== Private Methods ========== /** * Register a task for a schedule */ private async registerTask(schedule: IBackupSchedule): Promise { const taskName = `backup-${schedule.id}`; const task = new plugins.taskbuffer.Task({ name: taskName, taskFunction: async () => { await this.executeBackup(schedule); }, }); // Add and schedule the task this.taskManager.addAndScheduleTask(task, schedule.cronExpression); this.scheduledTasks.set(schedule.id!, task); // Update next run time in database this.updateNextRunTime(schedule.id!); logger.debug(`Registered backup task: ${taskName} with cron: ${schedule.cronExpression}`); } /** * Deschedule a task */ private async descheduleTask(scheduleId: number): Promise { const task = this.scheduledTasks.get(scheduleId); if (task) { await this.taskManager.descheduleTask(task); this.scheduledTasks.delete(scheduleId); logger.debug(`Descheduled backup task for schedule ${scheduleId}`); } } /** * Execute a backup for a schedule */ private async executeBackup(schedule: IBackupSchedule): Promise { const scopeDesc = this.getScopeDescription(schedule); const servicesToBackup = this.getServicesForSchedule(schedule); if (servicesToBackup.length === 0) { logger.warn(`No services to backup for schedule ${schedule.id} (${scopeDesc})`); this.oneboxRef.database.updateBackupSchedule(schedule.id!, { lastRunAt: Date.now(), lastStatus: 'success', lastError: 'No matching services found', }); this.updateNextRunTime(schedule.id!); return; } const retentionDesc = this.getRetentionDescription(schedule.retention); logger.info(`Executing scheduled backup for ${scopeDesc}: ${servicesToBackup.length} service(s) (${retentionDesc})`); let successCount = 0; let failCount = 0; const errors: string[] = []; for (const service of servicesToBackup) { try { // Create backup with schedule ID await this.oneboxRef.backupManager.createBackup(service.name, { scheduleId: schedule.id, }); // Apply time-window based retention policy for this service await this.applyRetention(schedule, service.id!); successCount++; logger.success(`Scheduled backup completed for ${service.name}`); } catch (error) { const errorMessage = getErrorMessage(error); logger.error(`Scheduled backup failed for ${service.name}: ${errorMessage}`); errors.push(`${service.name}: ${errorMessage}`); failCount++; } } // Update schedule status const lastStatus = failCount === 0 ? 'success' : 'failed'; const lastError = errors.length > 0 ? errors.join('; ') : null; this.oneboxRef.database.updateBackupSchedule(schedule.id!, { lastRunAt: Date.now(), lastStatus, lastError, }); if (failCount === 0) { logger.success(`Scheduled backup completed for ${scopeDesc}: ${successCount} service(s)`); } else { logger.warn(`Scheduled backup partially failed for ${scopeDesc}: ${successCount} succeeded, ${failCount} failed`); } // Update next run time this.updateNextRunTime(schedule.id!); } /** * Apply time-window based retention policy * Works correctly regardless of backup frequency (cron schedule) */ private async applyRetention(schedule: IBackupSchedule, serviceId: number): Promise { // Get all backups for this schedule and service const allBackups = this.oneboxRef.database.getBackupsByService(serviceId); const backups = allBackups.filter(b => b.scheduleId === schedule.id); if (backups.length === 0) { return; } const { hourly, daily, weekly, monthly } = schedule.retention; const now = Date.now(); const toKeep = new Set(); // Hourly: Keep up to N most recent backups from last 24 hours if (hourly > 0) { const recentBackups = backups .filter(b => now - b.createdAt < 24 * 60 * 60 * 1000) .sort((a, b) => b.createdAt - a.createdAt) .slice(0, hourly); recentBackups.forEach(b => toKeep.add(b.id!)); } // Daily: Keep oldest backup per day for last N days if (daily > 0) { for (let i = 0; i < daily; i++) { const dayStart = this.getStartOfDay(now, i); const dayEnd = dayStart + 24 * 60 * 60 * 1000; const dayBackups = backups.filter(b => b.createdAt >= dayStart && b.createdAt < dayEnd ); if (dayBackups.length > 0) { // Keep oldest from this day (most representative) const oldest = dayBackups.sort((a, b) => a.createdAt - b.createdAt)[0]; toKeep.add(oldest.id!); } } } // Weekly: Keep oldest backup per week for last N weeks if (weekly > 0) { for (let i = 0; i < weekly; i++) { const weekStart = this.getStartOfWeek(now, i); const weekEnd = weekStart + 7 * 24 * 60 * 60 * 1000; const weekBackups = backups.filter(b => b.createdAt >= weekStart && b.createdAt < weekEnd ); if (weekBackups.length > 0) { const oldest = weekBackups.sort((a, b) => a.createdAt - b.createdAt)[0]; toKeep.add(oldest.id!); } } } // Monthly: Keep oldest backup per month for last N months if (monthly > 0) { for (let i = 0; i < monthly; i++) { const { start, end } = this.getMonthRange(now, i); const monthBackups = backups.filter(b => b.createdAt >= start && b.createdAt < end ); if (monthBackups.length > 0) { const oldest = monthBackups.sort((a, b) => a.createdAt - b.createdAt)[0]; toKeep.add(oldest.id!); } } } // Delete anything not in toKeep for (const backup of backups) { if (!toKeep.has(backup.id!)) { try { await this.oneboxRef.backupManager.deleteBackup(backup.id!); logger.info(`Deleted backup ${backup.filename} (retention policy)`); } catch (error) { logger.warn(`Failed to delete old backup ${backup.filename}: ${getErrorMessage(error)}`); } } } } /** * Get start of day (midnight) for N days ago */ private getStartOfDay(now: number, daysAgo: number): number { const date = new Date(now); date.setDate(date.getDate() - daysAgo); date.setHours(0, 0, 0, 0); return date.getTime(); } /** * Get start of week (Sunday midnight) for N weeks ago */ private getStartOfWeek(now: number, weeksAgo: number): number { const date = new Date(now); date.setDate(date.getDate() - (weeksAgo * 7) - date.getDay()); date.setHours(0, 0, 0, 0); return date.getTime(); } /** * Get month range for N months ago */ private getMonthRange(now: number, monthsAgo: number): { start: number; end: number } { const date = new Date(now); date.setMonth(date.getMonth() - monthsAgo); date.setDate(1); date.setHours(0, 0, 0, 0); const start = date.getTime(); date.setMonth(date.getMonth() + 1); const end = date.getTime(); return { start, end }; } /** * Update next run time for a schedule */ private updateNextRunTime(scheduleId: number): void { const schedule = this.oneboxRef.database.getBackupScheduleById(scheduleId); if (!schedule) return; const nextRunAt = this.calculateNextRun(schedule.cronExpression); this.oneboxRef.database.updateBackupSchedule(scheduleId, { nextRunAt }); } /** * Calculate next run time from cron expression */ private calculateNextRun(cronExpression: string): number { try { // Get next scheduled runs from task manager const scheduledTasks = this.taskManager.getScheduledTasks(); // Find our task and get its next run for (const taskInfo of scheduledTasks) { if (taskInfo.schedule === cronExpression && taskInfo.nextRun) { return taskInfo.nextRun.getTime(); } } // Fallback: parse cron and calculate next occurrence // Simple implementation for common patterns const now = new Date(); const parts = cronExpression.split(' '); if (parts.length === 5) { const [minute, hour, dayOfMonth, month, dayOfWeek] = parts; // For daily schedules (e.g., "0 2 * * *") if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { const nextRun = new Date(now); nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0); if (nextRun <= now) { nextRun.setDate(nextRun.getDate() + 1); } return nextRun.getTime(); } // For weekly schedules (e.g., "0 2 * * 0") if (dayOfMonth === '*' && month === '*' && dayOfWeek !== '*') { const targetDay = parseInt(dayOfWeek); const nextRun = new Date(now); nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0); const currentDay = now.getDay(); let daysUntilTarget = (targetDay - currentDay + 7) % 7; if (daysUntilTarget === 0 && nextRun <= now) { daysUntilTarget = 7; } nextRun.setDate(nextRun.getDate() + daysUntilTarget); return nextRun.getTime(); } // For monthly schedules (e.g., "0 2 1 * *") if (dayOfMonth !== '*' && month === '*' && dayOfWeek === '*') { const targetDay = parseInt(dayOfMonth); const nextRun = new Date(now); nextRun.setDate(targetDay); nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0); if (nextRun <= now) { nextRun.setMonth(nextRun.getMonth() + 1); } return nextRun.getTime(); } // For yearly schedules (e.g., "0 2 1 1 *") if (dayOfMonth !== '*' && month !== '*' && dayOfWeek === '*') { const targetMonth = parseInt(month) - 1; // JavaScript months are 0-indexed const targetDay = parseInt(dayOfMonth); const nextRun = new Date(now); nextRun.setMonth(targetMonth, targetDay); nextRun.setHours(parseInt(hour), parseInt(minute), 0, 0); if (nextRun <= now) { nextRun.setFullYear(nextRun.getFullYear() + 1); } return nextRun.getTime(); } } // Default: next day at 2 AM const fallback = new Date(now); fallback.setDate(fallback.getDate() + 1); fallback.setHours(2, 0, 0, 0); return fallback.getTime(); } catch { // On any error, return tomorrow at 2 AM const fallback = new Date(); fallback.setDate(fallback.getDate() + 1); fallback.setHours(2, 0, 0, 0); return fallback.getTime(); } } /** * Get services that match a schedule based on its scope type */ private getServicesForSchedule(schedule: IBackupSchedule): IService[] { const allServices = this.oneboxRef.database.getAllServices(); switch (schedule.scopeType) { case 'all': return allServices; case 'pattern': if (!schedule.scopePattern) return []; return this.getServicesMatchingPattern(schedule.scopePattern); case 'service': if (!schedule.serviceId) return []; const service = allServices.find(s => s.id === schedule.serviceId); return service ? [service] : []; default: return []; } } /** * Get services that match a glob pattern */ private getServicesMatchingPattern(pattern: string): IService[] { const allServices = this.oneboxRef.database.getAllServices(); return allServices.filter(s => this.matchesGlobPattern(s.name, pattern)); } /** * Simple glob pattern matching (supports * and ?) */ private matchesGlobPattern(text: string, pattern: string): boolean { // Convert glob pattern to regex // Escape special regex characters except * and ? const regexPattern = pattern .replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape special chars .replace(/\*/g, '.*') // * matches any characters .replace(/\?/g, '.'); // ? matches single character const regex = new RegExp(`^${regexPattern}$`, 'i'); return regex.test(text); } /** * Get human-readable description of a schedule's scope */ private getScopeDescription(schedule: IBackupSchedule): string { switch (schedule.scopeType) { case 'all': return 'all services'; case 'pattern': return `pattern "${schedule.scopePattern}"`; case 'service': return `service "${schedule.serviceName}"`; default: return 'unknown scope'; } } /** * Get human-readable description of retention policy */ private getRetentionDescription(retention: IRetentionPolicy): string { return `H:${retention.hourly} D:${retention.daily} W:${retention.weekly} M:${retention.monthly}`; } }