77 "runtime"
88 "strings"
99 "sync"
10+ "sync/atomic"
1011 "time"
1112
1213 "github.com/alecthomas/errors"
@@ -22,6 +23,16 @@ type Config struct {
2223 SchedulerDB string `hcl:"scheduler-db" help:"Path to the scheduler state database." default:"${CACHEW_STATE}/scheduler.db"`
2324}
2425
26+ // idledPeriodicJob stores enough information to re-arm a periodic job that was
27+ // stopped due to queue inactivity.
28+ type idledPeriodicJob struct {
29+ queue string
30+ id string
31+ interval time.Duration
32+ run func (ctx context.Context ) error
33+ idleTimeout time.Duration
34+ }
35+
2536type queueJob struct {
2637 id string
2738 queue string
@@ -49,9 +60,14 @@ type Scheduler interface {
4960 // Jobs run concurrently across queues, but never within a queue.
5061 Submit (queue , id string , run func (ctx context.Context ) error )
5162 // SubmitPeriodicJob submits a job to the queue that runs immediately, and then periodically after the interval.
63+ // If idleTimeout is provided, the job stops re-arming when the queue has not been touched (via Touch) for
64+ // longer than the timeout. Calling Touch on an idle queue re-arms all its stopped periodic jobs.
5265 //
5366 // Jobs run concurrently across queues, but never within a queue.
54- SubmitPeriodicJob (queue , id string , interval time.Duration , run func (ctx context.Context ) error )
67+ SubmitPeriodicJob (queue , id string , interval time.Duration , run func (ctx context.Context ) error , idleTimeout ... time.Duration )
68+ // Touch records activity for a queue, resetting its idle timer. If the queue had periodic jobs that were
69+ // stopped due to inactivity, they are re-scheduled.
70+ Touch (queue string )
5571}
5672
5773type prefixedScheduler struct {
@@ -63,8 +79,12 @@ func (p *prefixedScheduler) Submit(queue, id string, run func(ctx context.Contex
6379 p .scheduler .Submit (queue , p .prefix + id , run )
6480}
6581
66- func (p * prefixedScheduler ) SubmitPeriodicJob (queue , id string , interval time.Duration , run func (ctx context.Context ) error ) {
67- p .scheduler .SubmitPeriodicJob (queue , p .prefix + id , interval , run )
82+ func (p * prefixedScheduler ) SubmitPeriodicJob (queue , id string , interval time.Duration , run func (ctx context.Context ) error , idleTimeout ... time.Duration ) {
83+ p .scheduler .SubmitPeriodicJob (queue , p .prefix + id , interval , run , idleTimeout ... )
84+ }
85+
86+ func (p * prefixedScheduler ) Touch (queue string ) {
87+ p .scheduler .Touch (queue )
6888}
6989
7090func (p * prefixedScheduler ) WithQueuePrefix (prefix string ) Scheduler {
@@ -85,11 +105,13 @@ type RootScheduler struct {
85105 // ctx is cancelled when the scheduler is shutting down. Periodic re-arm
86106 // goroutines select on it so they exit cleanly instead of submitting to a
87107 // dead scheduler.
88- ctx context.Context //nolint:containedctx
89- cancel context.CancelFunc
90- wg sync.WaitGroup
91- store ScheduleStore
92- metrics * schedulerMetrics
108+ ctx context.Context //nolint:containedctx
109+ cancel context.CancelFunc
110+ wg sync.WaitGroup
111+ store ScheduleStore
112+ metrics * schedulerMetrics
113+ lastTouched sync.Map // queue -> *atomic.Int64 (unix nanos)
114+ idledJobs sync.Map // jobKey -> *idledPeriodicJob
93115}
94116
95117var _ Scheduler = & RootScheduler {}
@@ -173,7 +195,18 @@ func (q *RootScheduler) Submit(queue, id string, run func(ctx context.Context) e
173195 q .cond .Signal ()
174196}
175197
176- func (q * RootScheduler ) SubmitPeriodicJob (queue , id string , interval time.Duration , run func (ctx context.Context ) error ) {
198+ func (q * RootScheduler ) SubmitPeriodicJob (queue , id string , interval time.Duration , run func (ctx context.Context ) error , idleTimeout ... time.Duration ) {
199+ var timeout time.Duration
200+ if len (idleTimeout ) > 0 {
201+ timeout = idleTimeout [0 ]
202+ }
203+ if timeout > 0 {
204+ q .touchQueue (queue )
205+ }
206+ q .submitPeriodicJob (queue , id , interval , run , timeout )
207+ }
208+
209+ func (q * RootScheduler ) submitPeriodicJob (queue , id string , interval time.Duration , run func (ctx context.Context ) error , idleTimeout time.Duration ) {
177210 if q .ctx .Err () != nil {
178211 return
179212 }
@@ -192,7 +225,14 @@ func (q *RootScheduler) SubmitPeriodicJob(queue, id string, interval time.Durati
192225 // to wake and submit to a dead scheduler. The new pod's
193226 // warmExistingRepos re-registers periodic jobs on startup.
194227 go q .sleepThenSubmit (interval , func () {
195- q .SubmitPeriodicJob (queue , id , interval , run )
228+ if idleTimeout > 0 && q .isQueueIdle (queue , idleTimeout ) {
229+ logging .FromContext (ctx ).InfoContext (ctx , "Periodic job idled out" , "queue" , queue , "job" , id )
230+ q .idledJobs .Store (key , & idledPeriodicJob {
231+ queue : queue , id : id , interval : interval , run : run , idleTimeout : idleTimeout ,
232+ })
233+ return
234+ }
235+ q .submitPeriodicJob (queue , id , interval , run , idleTimeout )
196236 })
197237 return errors .WithStack (err )
198238 })
@@ -204,6 +244,33 @@ func (q *RootScheduler) SubmitPeriodicJob(queue, id string, interval time.Durati
204244 go q .sleepThenSubmit (delay , submit )
205245}
206246
247+ // Touch records activity for a queue, resetting its idle timer. If the queue
248+ // had periodic jobs that were stopped due to inactivity, they are re-scheduled.
249+ func (q * RootScheduler ) Touch (queue string ) {
250+ q .touchQueue (queue )
251+ q .idledJobs .Range (func (key , value any ) bool {
252+ job := value .(* idledPeriodicJob )
253+ if job .queue == queue {
254+ q .idledJobs .Delete (key )
255+ q .submitPeriodicJob (job .queue , job .id , job .interval , job .run , job .idleTimeout )
256+ }
257+ return true
258+ })
259+ }
260+
261+ func (q * RootScheduler ) touchQueue (queue string ) {
262+ val , _ := q .lastTouched .LoadOrStore (queue , & atomic.Int64 {})
263+ val .(* atomic.Int64 ).Store (time .Now ().UnixNano ())
264+ }
265+
266+ func (q * RootScheduler ) isQueueIdle (queue string , timeout time.Duration ) bool {
267+ val , ok := q .lastTouched .Load (queue )
268+ if ! ok {
269+ return true
270+ }
271+ return time .Since (time .Unix (0 , val .(* atomic.Int64 ).Load ())) > timeout
272+ }
273+
207274// sleepThenSubmit waits for d, then runs fn — unless the scheduler is
208275// shutting down, in which case it returns immediately.
209276func (q * RootScheduler ) sleepThenSubmit (d time.Duration , fn func ()) {
0 commit comments