package models import ( "log" "strings" "sync" "time" "github.com/robfig/cron/v3" ) var ( schedMu sync.Mutex schedCron *cron.Cron entryTarget = map[cron.EntryID]targetRef{} ) type targetRef struct { scope string // "node" or "svc:" targetID string } // StartScheduler starts the in-process cron scheduler and performs an initial // sync from the current configuration. Call once from main.go before // beego.Run() — not from the scan/info CLI paths. func StartScheduler() { schedMu.Lock() schedCron = cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger))) schedCron.Start() schedMu.Unlock() SyncSchedule() } // SyncSchedule rebuilds all cron entries from the current backup target // definitions (node + every service). Called once at startup and after any // target add/update/delete so the running scheduler reflects the latest // definitions. func SyncSchedule() { schedMu.Lock() defer schedMu.Unlock() if schedCron == nil { return } for _, e := range schedCron.Entries() { schedCron.Remove(e.ID) } entryTarget = map[cron.EntryID]targetRef{} mu.RLock() nodeTargets := make([]BackupTarget, len(cfg.Node.Backup.Targets)) copy(nodeTargets, cfg.Node.Backup.Targets) services := make([]Service, len(cfg.Services)) copy(services, cfg.Services) mu.RUnlock() for _, t := range nodeTargets { registerJob("node", t) } for _, s := range services { if s.Backup == nil { continue } for _, t := range s.Backup.Targets { registerJob("svc:"+s.ID, t) } } refreshNextRunLocked() } // registerJob schedules a single target's cron job. Caller must hold schedMu. func registerJob(scope string, t BackupTarget) { if t.Schedule == "" { return } schedule, err := cron.ParseStandard(t.Schedule) if err != nil { // Already validated at CRUD time; defensively skip a bad schedule // rather than let it wedge the whole sync. return } targetID := t.ID job := cron.NewChain(cron.SkipIfStillRunning(cron.DefaultLogger)).Then(cron.FuncJob(func() { runScheduledTarget(scope, targetID) })) id := schedCron.Schedule(schedule, job) entryTarget[id] = targetRef{scope: scope, targetID: targetID} } func runScheduledTarget(scope, targetID string) { var err error switch { case scope == "node": err = RunNodeBackup([]string{targetID}) case strings.HasPrefix(scope, "svc:"): err = RunServiceBackup(strings.TrimPrefix(scope, "svc:"), []string{targetID}) } if err != nil { log.Printf("nodemaster: scheduled backup %s/%s failed: %v", scope, targetID, err) } schedMu.Lock() refreshNextRunLocked() schedMu.Unlock() } // refreshNextRunLocked writes each scheduled target's NextRun from the cron // entries' computed next-fire time. Caller must hold schedMu. func refreshNextRunLocked() { if schedCron == nil { return } updates := map[targetRef]time.Time{} for _, e := range schedCron.Entries() { if ref, ok := entryTarget[e.ID]; ok { updates[ref] = e.Next } } if len(updates) == 0 { return } mu.Lock() for ref, next := range updates { n := next if ref.scope == "node" { for i, t := range cfg.Node.Backup.Targets { if t.ID == ref.targetID { cfg.Node.Backup.Targets[i].NextRun = &n break } } continue } svcID := strings.TrimPrefix(ref.scope, "svc:") for i, s := range cfg.Services { if s.ID != svcID || s.Backup == nil { continue } for j, t := range s.Backup.Targets { if t.ID == ref.targetID { cfg.Services[i].Backup.Targets[j].NextRun = &n break } } break } } _ = persist() mu.Unlock() }