Files
URBM/internal/scheduler/scheduler.go
T

169 lines
4.3 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
func PreviousScheduled(spec, timezone string, now time.Time, window time.Duration) (time.Time, error) {
expression, err := Parse(spec)
if err != nil {
return time.Time{}, err
}
loc := time.Local
if timezone != "" {
loc, err = time.LoadLocation(timezone)
if err != nil {
return time.Time{}, err
}
}
return previous(expression, now.In(loc), window).UTC(), nil
}
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{}
}