feat: add jitter support for scheduling to randomize execution times

This commit is contained in:
2025-12-06 14:08:32 +01:00
parent 777557f82c
commit 61b8b1a6af
3 changed files with 100 additions and 24 deletions

View File

@@ -134,7 +134,7 @@
}, },
// === DASHBOARD === // === DASHBOARD ===
"dashboard": { "dashboard": {
"enabled": false, "enabled": false, // autostart the dashboard with the bot
"port": 3000, "port": 3000,
"host": "127.0.0.1" "host": "127.0.0.1"
}, },
@@ -144,7 +144,12 @@
// Time is based on YOUR computer/server timezone (automatically detected) // Time is based on YOUR computer/server timezone (automatically detected)
"scheduling": { "scheduling": {
"enabled": false, // Set to true to enable automatic daily runs "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 === // === UPDATES ===
"update": { "update": {

View File

@@ -202,6 +202,11 @@ export interface ConfigScheduling {
cron?: { // Legacy cron format (for backwards compatibility) - DEPRECATED cron?: { // Legacy cron format (for backwards compatibility) - DEPRECATED
schedule?: string; // Cron expression - e.g., "0 9 * * *" for 9 AM daily 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 { export interface ConfigErrorReporting {

View File

@@ -20,6 +20,7 @@ export class InternalScheduler {
private taskCallback: () => Promise<void> private taskCallback: () => Promise<void>
private isRunning: boolean = false private isRunning: boolean = false
private lastRunTime: Date | null = null private lastRunTime: Date | null = null
private lastCronExpression: string | null = null
constructor(config: Config, taskCallback: () => Promise<void>) { constructor(config: Config, taskCallback: () => Promise<void>) {
this.config = config this.config = config
@@ -39,34 +40,36 @@ export class InternalScheduler {
return false return false
} }
// Get schedule from simple time format (e.g., "09:00") or fallback to cron format // Get schedule from simple time format (e.g., "09:00") or fallback to cron format (supports jitter)
const schedule = this.parseSchedule(scheduleConfig) 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') log('main', 'SCHEDULER', 'Invalid schedule format. Use time in HH:MM format (e.g., "09:00" for 9 AM)', 'error')
return false return false
} }
// Validate cron expression // Validate cron expression
if (!cron.validate(schedule)) { if (!cron.validate(cronExpr)) {
log('main', 'SCHEDULER', `Invalid schedule: "${schedule}"`, 'error') log('main', 'SCHEDULER', `Invalid schedule: "${cronExpr}"`, 'error')
return false return false
} }
try { try {
const timezone = this.detectTimezone() const timezone = this.detectTimezone()
this.cronJob = cron.schedule(schedule, async () => { this.cronJob = cron.schedule(cronExpr, async () => {
await this.runScheduledTask() await this.runScheduledTask()
}, { }, {
scheduled: true, scheduled: true,
timezone 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', '✓ 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', ` Timezone: ${timezone}`, 'log', 'cyan')
log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, '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 * Parse schedule from config - supports simple time format (HH:MM) or cron expression
* @returns Cron expression string * @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") // Priority 1: Simple time format (e.g., "09:00")
if (scheduleConfig.time) { if (scheduleConfig.time) {
const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(scheduleConfig.time.trim()) const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(scheduleConfig.time.trim())
@@ -90,20 +93,53 @@ export class InternalScheduler {
const minutes = parseInt(timeMatch[2]!, 10) const minutes = parseInt(timeMatch[2]!, 10)
if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) { if (hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59) {
// Convert to cron: "minute hour * * *" = daily at specified time const jitter = this.applyJitter(hours, minutes, scheduleConfig.jitter)
return `${minutes} ${hours} * * *` 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) // Priority 2: COMPATIBILITY format (cron.schedule field, pre-v2.58)
if (scheduleConfig.cron?.schedule) { 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 // 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', '✓ Scheduled run completed successfully', 'log', 'green')
log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan') log('main', 'SCHEDULER', ` Next run: ${this.getNextRunTime()}`, 'log', 'cyan')
this.rescheduleWithJitter()
return // Success - exit retry loop return // Success - exit retry loop
} catch (error) { } catch (error) {
@@ -164,6 +201,9 @@ export class InternalScheduler {
this.isRunning = false 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 * Get the next scheduled run time
*/ */
@@ -188,14 +258,10 @@ export class InternalScheduler {
const timezone = this.detectTimezone() const timezone = this.detectTimezone()
// Get the cron schedule being used // Get the cron schedule being used
let cronSchedule: string const cronSchedule = this.lastCronExpression
if (scheduleConfig?.time) { ?? (scheduleConfig?.time ? this.buildSchedule(scheduleConfig).cronExpr : undefined)
cronSchedule = this.parseSchedule(scheduleConfig) || '0 9 * * *' ?? scheduleConfig?.cron?.schedule
} else if (scheduleConfig?.cron?.schedule) { ?? '0 9 * * *'
cronSchedule = scheduleConfig.cron.schedule
} else {
cronSchedule = '0 9 * * *'
}
// Calculate next run based on cron expression // Calculate next run based on cron expression
const now = new Date() const now = new Date()