From 61b8b1a6af9a97ddb56b45ca6792779a16e586fc Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Sat, 6 Dec 2025 14:08:32 +0100 Subject: [PATCH] feat: add jitter support for scheduling to randomize execution times --- src/config.jsonc | 9 ++- src/interface/Config.ts | 5 ++ src/scheduler/InternalScheduler.ts | 110 +++++++++++++++++++++++------ 3 files changed, 100 insertions(+), 24 deletions(-) diff --git a/src/config.jsonc b/src/config.jsonc index 0ac875c..6c00a50 100644 --- a/src/config.jsonc +++ b/src/config.jsonc @@ -134,7 +134,7 @@ }, // === DASHBOARD === "dashboard": { - "enabled": false, + "enabled": false, // autostart the dashboard with the bot "port": 3000, "host": "127.0.0.1" }, @@ -144,7 +144,12 @@ // Time is based on YOUR computer/server timezone (automatically detected) "scheduling": { "enabled": false, // Set to true to enable automatic daily runs - "time": "09:00" // Time in 24h format (HH:MM) - e.g., "09:00" = 9 AM, "21:30" = 9:30 PM + "time": "09:00", // Time in 24h format (HH:MM) - e.g., "09:00" = 9 AM, "21:30" = 9:30 PM + "jitter": { + "enabled": false, // If true, apply a small daily offset to avoid exact-time runs + "minMinutesBefore": 40, // Max minutes to start before the scheduled time + "maxMinutesAfter": 20 // Max minutes to start after the scheduled time + } }, // === UPDATES === "update": { diff --git a/src/interface/Config.ts b/src/interface/Config.ts index 44927fe..859a739 100644 --- a/src/interface/Config.ts +++ b/src/interface/Config.ts @@ -202,6 +202,11 @@ export interface ConfigScheduling { cron?: { // Legacy cron format (for backwards compatibility) - DEPRECATED schedule?: string; // Cron expression - e.g., "0 9 * * *" for 9 AM daily }; + jitter?: { + enabled?: boolean; // If true, apply random +/- offset around scheduled time + minMinutesBefore?: number; // How many minutes before the scheduled time we may start (default 20) + maxMinutesAfter?: number; // How many minutes after the scheduled time we may start (default 30) + }; } export interface ConfigErrorReporting { diff --git a/src/scheduler/InternalScheduler.ts b/src/scheduler/InternalScheduler.ts index 648d855..3666ef7 100644 --- a/src/scheduler/InternalScheduler.ts +++ b/src/scheduler/InternalScheduler.ts @@ -20,6 +20,7 @@ export class InternalScheduler { private taskCallback: () => Promise private isRunning: boolean = false private lastRunTime: Date | null = null + private lastCronExpression: string | null = null constructor(config: Config, taskCallback: () => Promise) { this.config = config @@ -39,34 +40,36 @@ export class InternalScheduler { return false } - // Get schedule from simple time format (e.g., "09:00") or fallback to cron format - const schedule = this.parseSchedule(scheduleConfig) + // Get schedule from simple time format (e.g., "09:00") or fallback to cron format (supports jitter) + const { cronExpr, displayTime, jitterApplied } = this.buildSchedule(scheduleConfig) - if (!schedule) { + if (!cronExpr) { log('main', 'SCHEDULER', 'Invalid schedule format. Use time in HH:MM format (e.g., "09:00" for 9 AM)', 'error') return false } // Validate cron expression - if (!cron.validate(schedule)) { - log('main', 'SCHEDULER', `Invalid schedule: "${schedule}"`, 'error') + if (!cron.validate(cronExpr)) { + log('main', 'SCHEDULER', `Invalid schedule: "${cronExpr}"`, 'error') return false } try { const timezone = this.detectTimezone() - this.cronJob = cron.schedule(schedule, async () => { + this.cronJob = cron.schedule(cronExpr, async () => { await this.runScheduledTask() }, { scheduled: true, timezone }) - const displayTime = scheduleConfig.time || this.extractTimeFromCron(schedule) + this.lastCronExpression = cronExpr + const timeLabel = displayTime || this.extractTimeFromCron(cronExpr) + const jitterLabel = jitterApplied ? ` (jitter applied: ${jitterApplied} min)` : '' log('main', 'SCHEDULER', '✓ Internal scheduler started', 'log', 'green') - log('main', 'SCHEDULER', ` Daily run time: ${displayTime}`, 'log', 'cyan') + log('main', 'SCHEDULER', ` Daily run time: ${timeLabel}${jitterLabel}`, 'log', 'cyan') log('main', 'SCHEDULER', ` Timezone: ${timezone}`, 'log', 'cyan') log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan') @@ -81,7 +84,7 @@ export class InternalScheduler { * Parse schedule from config - supports simple time format (HH:MM) or cron expression * @returns Cron expression string */ - private parseSchedule(scheduleConfig: { time?: string; cron?: { schedule?: string } }): string | null { + private buildSchedule(scheduleConfig: { time?: string; cron?: { schedule?: string }; jitter?: { enabled?: boolean; minMinutesBefore?: number; maxMinutesAfter?: number } }): { cronExpr: string | null; displayTime: string; jitterApplied: number } { // Priority 1: Simple time format (e.g., "09:00") if (scheduleConfig.time) { const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(scheduleConfig.time.trim()) @@ -90,20 +93,53 @@ export class InternalScheduler { const minutes = parseInt(timeMatch[2]!, 10) if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { - // Convert to cron: "minute hour * * *" = daily at specified time - return `${minutes} ${hours} * * *` + const jitter = this.applyJitter(hours, minutes, scheduleConfig.jitter) + const cronExpr = `${jitter.minute} ${jitter.hour} * * *` + const display = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}` + return { cronExpr, displayTime: display, jitterApplied: jitter.offsetMinutes } } } - return null // Invalid time format + return { cronExpr: null, displayTime: '', jitterApplied: 0 } } // Priority 2: COMPATIBILITY format (cron.schedule field, pre-v2.58) if (scheduleConfig.cron?.schedule) { - return scheduleConfig.cron.schedule + return { cronExpr: scheduleConfig.cron.schedule, displayTime: this.extractTimeFromCron(scheduleConfig.cron.schedule), jitterApplied: 0 } } // Default: 9 AM daily - return '0 9 * * *' + const jitter = this.applyJitter(9, 0, scheduleConfig.jitter) + return { cronExpr: `${jitter.minute} ${jitter.hour} * * *`, displayTime: '09:00', jitterApplied: jitter.offsetMinutes } + } + + private applyJitter(hour: number, minute: number, jitter?: { enabled?: boolean; minMinutesBefore?: number; maxMinutesAfter?: number }) { + const enabled = jitter?.enabled === true + const before = Number.isFinite(jitter?.minMinutesBefore) ? Number(jitter!.minMinutesBefore) : 20 + const after = Number.isFinite(jitter?.maxMinutesAfter) ? Number(jitter!.maxMinutesAfter) : 30 + + if (!enabled || (before === 0 && after === 0)) { + return { hour, minute, offsetMinutes: 0 } + } + + const minOffset = -Math.abs(before) + const maxOffset = Math.abs(after) + const offset = this.getRandomInt(minOffset, maxOffset) + + let totalMinutes = hour * 60 + minute + offset + const minutesInDay = 24 * 60 + while (totalMinutes < 0) totalMinutes += minutesInDay + while (totalMinutes >= minutesInDay) totalMinutes -= minutesInDay + + const jitteredHour = Math.floor(totalMinutes / 60) + const jitteredMinute = totalMinutes % 60 + + return { hour: jitteredHour, minute: jitteredMinute, offsetMinutes: offset } + } + + private getRandomInt(minInclusive: number, maxInclusive: number): number { + const min = Math.ceil(minInclusive) + const max = Math.floor(maxInclusive) + return Math.floor(Math.random() * (max - min + 1)) + min } /** @@ -146,6 +182,7 @@ export class InternalScheduler { log('main', 'SCHEDULER', '✓ Scheduled run completed successfully', 'log', 'green') log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan') + this.rescheduleWithJitter() return // Success - exit retry loop } catch (error) { @@ -164,6 +201,9 @@ export class InternalScheduler { this.isRunning = false } } + + // If we exit the loop without success, still reschedule jitter for the next day + this.rescheduleWithJitter() } /** @@ -177,6 +217,36 @@ export class InternalScheduler { } } + private rescheduleWithJitter(): void { + const scheduleConfig = this.config.scheduling + // Only apply jitter for simple time-based schedules + if (!scheduleConfig?.enabled || !scheduleConfig.time) return + + const { cronExpr, displayTime, jitterApplied } = this.buildSchedule(scheduleConfig) + if (!cronExpr || !cron.validate(cronExpr)) { + log('main', 'SCHEDULER', 'Jitter reschedule skipped due to invalid schedule', 'warn') + return + } + + if (this.cronJob) { + this.cronJob.stop() + } + + const timezone = this.detectTimezone() + this.cronJob = cron.schedule(cronExpr, async () => { + await this.runScheduledTask() + }, { + scheduled: true, + timezone + }) + + this.lastCronExpression = cronExpr + const timeLabel = displayTime || this.extractTimeFromCron(cronExpr) + const jitterLabel = jitterApplied ? ` (jitter applied: ${jitterApplied} min)` : '' + + log('main', 'SCHEDULER', `Jitter rescheduled next run at ${timeLabel}${jitterLabel}. Next run: ${this.getNextRunTime()}`, 'log', 'cyan') + } + /** * Get the next scheduled run time */ @@ -188,14 +258,10 @@ export class InternalScheduler { const timezone = this.detectTimezone() // Get the cron schedule being used - let cronSchedule: string - if (scheduleConfig?.time) { - cronSchedule = this.parseSchedule(scheduleConfig) || '0 9 * * *' - } else if (scheduleConfig?.cron?.schedule) { - cronSchedule = scheduleConfig.cron.schedule - } else { - cronSchedule = '0 9 * * *' - } + const cronSchedule = this.lastCronExpression + ?? (scheduleConfig?.time ? this.buildSchedule(scheduleConfig).cronExpr : undefined) + ?? scheduleConfig?.cron?.schedule + ?? '0 9 * * *' // Calculate next run based on cron expression const now = new Date()