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": {
"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": {

View File

@@ -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 {

View File

@@ -20,6 +20,7 @@ export class InternalScheduler {
private taskCallback: () => Promise<void>
private isRunning: boolean = false
private lastRunTime: Date | null = null
private lastCronExpression: string | null = null
constructor(config: Config, taskCallback: () => Promise<void>) {
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()