@@ -41,7 +41,7 @@ type Scheduler struct {
4141 wg sync.WaitGroup
4242 mu sync.RWMutex
4343 maxConcurrentJobs int
44- jobSemaphore chan struct {}
44+ concurrencySem * concurrencySemaphore // go-cron middleware semaphore
4545 retryExecutor * RetryExecutor
4646 jobsByName map [string ]Job
4747 disabledNames map [string ]struct {}
@@ -50,6 +50,42 @@ type Scheduler struct {
5050 onJobComplete func (jobName string , success bool )
5151}
5252
53+ // concurrencySemaphore holds a swappable semaphore channel used by the
54+ // go-cron MaxConcurrentSkip-style job wrapper. The wrapper reads the
55+ // current channel via a mutex-protected accessor so that SetMaxConcurrentJobs
56+ // can resize the limit before the scheduler is started.
57+ type concurrencySemaphore struct {
58+ mu sync.RWMutex
59+ ch chan struct {}
60+ cap int
61+ }
62+
63+ func newConcurrencySemaphore (n int ) * concurrencySemaphore {
64+ return & concurrencySemaphore {
65+ ch : make (chan struct {}, n ),
66+ cap : n ,
67+ }
68+ }
69+
70+ func (cs * concurrencySemaphore ) resize (n int ) {
71+ cs .mu .Lock ()
72+ defer cs .mu .Unlock ()
73+ cs .ch = make (chan struct {}, n )
74+ cs .cap = n
75+ }
76+
77+ func (cs * concurrencySemaphore ) getChan () chan struct {} {
78+ cs .mu .RLock ()
79+ defer cs .mu .RUnlock ()
80+ return cs .ch
81+ }
82+
83+ func (cs * concurrencySemaphore ) getCap () int {
84+ cs .mu .RLock ()
85+ defer cs .mu .RUnlock ()
86+ return cs .cap
87+ }
88+
5389func NewScheduler (l * slog.Logger ) * Scheduler {
5490 return NewSchedulerWithOptions (l , nil , 0 )
5591}
@@ -85,10 +121,19 @@ func newSchedulerInternal(
85121 // can capture it by reference; the variable is assigned after cron.New().
86122 var cronInstance * cron.Cron
87123
124+ // Default to 10 concurrent jobs, can be configured via SetMaxConcurrentJobs
125+ maxConcurrent := 10
126+ sem := newConcurrencySemaphore (maxConcurrent )
127+
128+ // Build the go-cron middleware chain. Concurrency limiting uses a
129+ // MaxConcurrentSkip-style wrapper backed by the scheduler's resizable
130+ // semaphore so that SetMaxConcurrentJobs can adjust the limit before Start.
131+ concurrencyWrapper := maxConcurrentSkipWrapper (cronUtils , sem )
132+
88133 cronOpts := []cron.Option {
89134 cron .WithParser (parser ),
90135 cron .WithLogger (cronUtils ),
91- cron .WithChain (cron .Recover (cronUtils )),
136+ cron .WithChain (cron .Recover (cronUtils ), concurrencyWrapper ),
92137 cron .WithCapacity (64 ), // pre-allocate for typical workloads
93138 }
94139
@@ -116,9 +161,6 @@ func newSchedulerInternal(
116161
117162 cronInstance = cron .New (cronOpts ... )
118163
119- // Default to 10 concurrent jobs, can be configured
120- maxConcurrent := 10
121-
122164 var clock Clock = GetDefaultClock ()
123165 if cronClock != nil {
124166 clock = cronClock .FakeClock
@@ -128,7 +170,7 @@ func newSchedulerInternal(
128170 Logger : l ,
129171 cron : cronInstance ,
130172 maxConcurrentJobs : maxConcurrent ,
131- jobSemaphore : make ( chan struct {}, maxConcurrent ) ,
173+ concurrencySem : sem ,
132174 retryExecutor : NewRetryExecutor (l ),
133175 jobsByName : make (map [string ]Job ),
134176 disabledNames : make (map [string ]struct {}),
@@ -144,15 +186,73 @@ func newSchedulerInternal(
144186 return s
145187}
146188
147- // SetMaxConcurrentJobs configures the maximum number of concurrent jobs
189+ // maxConcurrentSkipWrapper returns a cron.JobWrapper that limits the total
190+ // number of concurrent jobs across all entries. When the limit is reached,
191+ // new invocations are skipped (not queued) and a log message is emitted.
192+ //
193+ // This is functionally equivalent to go-cron's cron.MaxConcurrentSkip but
194+ // uses the scheduler's resizable concurrencySemaphore so that the limit
195+ // can be adjusted via SetMaxConcurrentJobs before the scheduler starts.
196+ func maxConcurrentSkipWrapper (logger cron.Logger , sem * concurrencySemaphore ) cron.JobWrapper {
197+ return func (j cron.Job ) cron.Job {
198+ return & maxConcurrentSkipJob {inner : j , sem : sem , logger : logger }
199+ }
200+ }
201+
202+ // maxConcurrentSkipJob implements cron.Job and cron.JobWithContext.
203+ // It acquires a slot from the shared concurrencySemaphore before running
204+ // the inner job. If no slot is available, the invocation is skipped.
205+ type maxConcurrentSkipJob struct {
206+ inner cron.Job
207+ sem * concurrencySemaphore
208+ logger cron.Logger
209+ }
210+
211+ func (m * maxConcurrentSkipJob ) Run () {
212+ m .RunWithContext (context .Background ())
213+ }
214+
215+ // RunWithContext attempts to acquire a slot from the shared concurrencySemaphore
216+ // before delegating execution to the wrapped job. If no slot is immediately
217+ // available, the invocation is skipped and logged via cron.Logger.
218+ func (m * maxConcurrentSkipJob ) RunWithContext (ctx context.Context ) {
219+ ch := m .sem .getChan ()
220+ select {
221+ case ch <- struct {}{}: // try to acquire slot
222+ defer func () { <- ch }()
223+ if jc , ok := m .inner .(cron.JobWithContext ); ok {
224+ jc .RunWithContext (ctx )
225+ } else {
226+ m .inner .Run ()
227+ }
228+ default :
229+ // cron.Logger only exposes Info and Error; use Info since skipping
230+ // is non-fatal. Via CronUtils, cron.Logger.Info maps to slog.Debug,
231+ // so cron-scheduled skips appear at Debug level while the scheduler's
232+ // own RunJob/Start paths log at Warn via slog directly. This is
233+ // intentional: frequent cron skips stay quiet, manual skips are visible.
234+ m .logger .Info ("skip" , "reason" , "max concurrent reached" ,
235+ "limit" , m .sem .getCap ())
236+ }
237+ }
238+
239+ // SetMaxConcurrentJobs configures the maximum number of concurrent jobs.
240+ // The limit is enforced by the go-cron middleware chain (MaxConcurrentSkip
241+ // pattern). When the limit is reached, new job invocations are skipped.
242+ //
243+ // This should be called before Start(); calling it on a running scheduler
244+ // resizes the semaphore but in-flight jobs retain the previous channel.
148245func (s * Scheduler ) SetMaxConcurrentJobs (maxJobs int ) {
149246 if maxJobs < 1 {
150247 maxJobs = 1
151248 }
152249 s .mu .Lock ()
153250 defer s .mu .Unlock ()
251+ if s .cron != nil && s .cron .IsRunning () {
252+ s .Logger .Warn ("SetMaxConcurrentJobs called on running scheduler; in-flight jobs retain previous limit" )
253+ }
154254 s .maxConcurrentJobs = maxJobs
155- s .jobSemaphore = make ( chan struct {}, maxJobs )
255+ s .concurrencySem . resize ( maxJobs )
156256}
157257
158258func (s * Scheduler ) SetMetricsRecorder (recorder MetricsRecorder ) {
@@ -408,7 +508,8 @@ func (s *Scheduler) RunJob(_ context.Context, jobName string) error {
408508
409509 // Delegate to go-cron's TriggerEntryByName for proper middleware chain execution.
410510 // This works for all job types including triggered schedules, since all jobs now
411- // have cron entries (registered via TriggeredSchedule in PR #498).
511+ // have cron entries (registered via TriggeredSchedule in PR #498). The
512+ // MaxConcurrentSkip middleware in the chain handles concurrency limiting.
412513 if err := s .cron .TriggerEntryByName (jobName ); err != nil {
413514 return fmt .Errorf ("trigger job %s: %w" , jobName , err )
414515 }
@@ -711,17 +812,9 @@ func (w *jobWrapper) runWithCtx(ctx context.Context) {
711812 }
712813 }()
713814
714- // Acquire semaphore slot for job concurrency limit
715- select {
716- case w .s .jobSemaphore <- struct {}{}:
717- // Got a slot, proceed
718- defer func () { <- w .s .jobSemaphore }() // Release slot when done
719- default :
720- // No slots available, skip this execution
721- w .s .Logger .Warn (fmt .Sprintf ("Job %q skipped - max concurrent jobs limit reached (%d)" ,
722- w .j .GetName (), w .s .maxConcurrentJobs ))
723- return
724- }
815+ // NOTE: Concurrency limiting is handled by the go-cron middleware chain
816+ // (maxConcurrentSkipWrapper). Dependencies are handled by go-cron's native
817+ // DAG engine. No manual semaphore or workflow checks needed here.
725818
726819 if ! w .s .cron .IsRunning () {
727820 return
0 commit comments