mirror of
https://github.com/LightZirconite/Microsoft-Rewards-Bot.git
synced 2026-01-09 17:06:15 +00:00
feat: add jitter support for scheduling to randomize execution times
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user