@@ -28,6 +28,11 @@ import { acquireSchedulerLock } from './scheduler-lock'
2828 */
2929export class Schedule implements UntimedSchedule {
3030 private static jobs = new Map < string , ScheduledJob > ( )
31+ // Dedupe registry: normalized job name + cron pattern → the timer already
32+ // started for it, so scheduling the same job on the same cadence twice (a
33+ // Job's `rate` auto-scheduled by the runner + an explicit Scheduler.ts entry)
34+ // creates ONE timer, not two. See start().
35+ private static scheduledKeys = new Map < string , ScheduledJob > ( )
3136 private cronPattern = ''
3237 private intervalMs : number | null = null
3338 private timezone : Timezone = 'America/Los_Angeles'
@@ -595,6 +600,22 @@ export class Schedule implements UntimedSchedule {
595600 return { stop : ( ) => { } , nextRun : ( ) => null }
596601 }
597602
603+ // Dedupe: the same job scheduled on the same cadence twice — e.g. a Job's
604+ // `rate` auto-scheduled by the runner AND an explicit `schedule.job(...)` in
605+ // the app's Scheduler.ts — must create ONE timer, not two (else it fires
606+ // twice). Key on the normalized name + pattern, so a job intentionally
607+ // scheduled on two DIFFERENT cadences still gets both timers.
608+ const dedupeKey = this . options . name
609+ ? `${ this . options . name . toLowerCase ( ) . replace ( / [ ^ a - z 0 - 9 ] + / g, '' ) } ::${ this . cronPattern || `every:${ this . intervalMs } ` } `
610+ : null
611+ if ( dedupeKey ) {
612+ const existing = Schedule . scheduledKeys . get ( dedupeKey )
613+ if ( existing ) {
614+ log . debug ( `[scheduler] duplicate schedule for "${ this . options . name } " (${ this . cronPattern || `every ${ this . intervalMs } ms` } ) skipped` )
615+ return existing
616+ }
617+ }
618+
598619 const task = this . wrapTask ( this . task )
599620 let stopped = false
600621 let timer : ReturnType < typeof setTimeout > | ReturnType < typeof setInterval > | null = null
@@ -714,6 +735,9 @@ export class Schedule implements UntimedSchedule {
714735 if ( this . options . name ) {
715736 Schedule . jobs . set ( this . options . name , job )
716737 }
738+ if ( dedupeKey ) {
739+ Schedule . scheduledKeys . set ( dedupeKey , job )
740+ }
717741
718742 log . info ( `Scheduled task with pattern: ${ this . cronPattern } in timezone: ${ this . timezone } ` )
719743 return job
0 commit comments