154 lines
3.9 KiB
Go
154 lines
3.9 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.casaderoll.de/michael/urbm/internal/model"
|
|
)
|
|
|
|
type ConfigProvider func() model.Config
|
|
type Enqueue func(model.Job) error
|
|
type EnqueueMaintenance func(string, string) (model.Run, error)
|
|
type LastRun func(string) time.Time
|
|
|
|
type Scheduler struct {
|
|
config ConfigProvider
|
|
enqueue Enqueue
|
|
maintenance EnqueueMaintenance
|
|
lastRun LastRun
|
|
log *slog.Logger
|
|
mu sync.Mutex
|
|
last map[string]time.Time
|
|
}
|
|
|
|
func New(config ConfigProvider, enqueue Enqueue, maintenance EnqueueMaintenance, lastRun LastRun, log *slog.Logger) *Scheduler {
|
|
return &Scheduler{config: config, enqueue: enqueue, maintenance: maintenance, lastRun: lastRun, log: log, last: map[string]time.Time{}}
|
|
}
|
|
|
|
func (s *Scheduler) Run(ctx context.Context) {
|
|
s.catchUp(time.Now())
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
s.tick(time.Now())
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case now := <-ticker.C:
|
|
s.tick(now)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) tick(now time.Time) {
|
|
config := s.config()
|
|
minute := now.Truncate(time.Minute)
|
|
for _, job := range config.Jobs {
|
|
if !job.Enabled || job.Schedule.Cron == "" {
|
|
continue
|
|
}
|
|
at, expression, ok := scheduledTime(job, minute, s.log)
|
|
if !ok || !expression.Matches(at) {
|
|
continue
|
|
}
|
|
job := job
|
|
s.once("job:"+job.ID, minute, func() error { return s.enqueue(job) })
|
|
}
|
|
s.scheduleMaintenance(config, minute, "check", config.Settings.CheckCron)
|
|
s.scheduleMaintenance(config, minute, "prune", config.Settings.PruneCron)
|
|
}
|
|
|
|
func (s *Scheduler) catchUp(now time.Time) {
|
|
config := s.config()
|
|
window := time.Duration(config.Settings.CatchUpWindowHrs) * time.Hour
|
|
if window <= 0 {
|
|
return
|
|
}
|
|
for _, job := range config.Jobs {
|
|
if !job.Enabled || job.Schedule.Cron == "" {
|
|
continue
|
|
}
|
|
loc := time.Local
|
|
if job.Schedule.Timezone != "" {
|
|
if loaded, err := time.LoadLocation(job.Schedule.Timezone); err == nil {
|
|
loc = loaded
|
|
}
|
|
}
|
|
expression, err := Parse(job.Schedule.Cron)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
lastScheduled := previous(expression, now.In(loc), window)
|
|
if !lastScheduled.IsZero() && s.lastRun(job.ID).Before(lastScheduled.UTC()) {
|
|
if err := s.enqueue(job); err != nil {
|
|
s.log.Warn("catch-up enqueue failed", "jobId", job.ID, "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) scheduleMaintenance(config model.Config, minute time.Time, action, spec string) {
|
|
if spec == "" {
|
|
return
|
|
}
|
|
expression, err := Parse(spec)
|
|
if err != nil {
|
|
s.log.Warn("invalid maintenance schedule", "action", action, "error", err)
|
|
return
|
|
}
|
|
if !expression.Matches(minute) {
|
|
return
|
|
}
|
|
for _, repo := range config.Repositories {
|
|
repoID := repo.ID
|
|
s.once(action+":"+repoID, minute, func() error {
|
|
_, err := s.maintenance(repoID, action)
|
|
return err
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *Scheduler) once(key string, minute time.Time, action func() error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.last[key].Equal(minute) {
|
|
return
|
|
}
|
|
s.last[key] = minute
|
|
if err := action(); err != nil {
|
|
s.log.Warn("schedule enqueue failed", "key", key, "error", err)
|
|
}
|
|
}
|
|
|
|
func scheduledTime(job model.Job, minute time.Time, log *slog.Logger) (time.Time, Expression, bool) {
|
|
expression, err := Parse(job.Schedule.Cron)
|
|
if err != nil {
|
|
log.Warn("invalid job schedule", "jobId", job.ID, "error", err)
|
|
return time.Time{}, Expression{}, false
|
|
}
|
|
loc := time.Local
|
|
if job.Schedule.Timezone != "" {
|
|
if loaded, err := time.LoadLocation(job.Schedule.Timezone); err == nil {
|
|
loc = loaded
|
|
} else {
|
|
log.Warn("invalid job timezone", "jobId", job.ID, "error", err)
|
|
}
|
|
}
|
|
return minute.In(loc), expression, true
|
|
}
|
|
|
|
func previous(expression Expression, now time.Time, window time.Duration) time.Time {
|
|
current := now.Truncate(time.Minute)
|
|
limit := current.Add(-window)
|
|
for !current.Before(limit) {
|
|
if expression.Matches(current) {
|
|
return current
|
|
}
|
|
current = current.Add(-time.Minute)
|
|
}
|
|
return time.Time{}
|
|
}
|