Initial Backupper Unraid plugin
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/backupper-unraid/backupper/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{}
|
||||
}
|
||||
Reference in New Issue
Block a user