package cron import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "sync" "time" "github.com/adhocore/gronx" ) // RunJob manually triggers a job execution. // mode: "force" = run regardless of schedule, "due" = only run if due. // Returns (ran bool, reason string, error). func (cs *Service) RunJob(jobID string, force bool) (bool, string, error) { cs.mu.Lock() var job *Job for i := range cs.store.Jobs { if cs.store.Jobs[i].ID == jobID { j := cs.store.Jobs[i] // copy job = &j break } } handler := cs.onJob cs.mu.Unlock() if job == nil { return false, "", fmt.Errorf("job %s not found", jobID) } if handler == nil { return false, "", fmt.Errorf("no job handler configured") } if !force { // Check if job is due if job.State.NextRunAtMS == nil || *job.State.NextRunAtMS > nowMS() { return false, "not-due", nil } } // Execute outside lock with retry slog.Info("cron manual run", "id", job.ID, "name", job.Name, "force", force) result, _, err := ExecuteWithRetry(func() (string, error) { return handler(job) }, cs.retryCfg) // Update state cs.mu.Lock() defer cs.mu.Unlock() for i := range cs.store.Jobs { if cs.store.Jobs[i].ID != jobID { continue } now := nowMS() cs.store.Jobs[i].State.LastRunAtMS = &now if err != nil { cs.store.Jobs[i].State.LastStatus = "error" cs.store.Jobs[i].State.LastError = err.Error() } else { cs.store.Jobs[i].State.LastStatus = "ok" cs.store.Jobs[i].State.LastError = "" } // Recompute next run (unless one-time and delete after run) if cs.store.Jobs[i].DeleteAfterRun { cs.store.Jobs = append(cs.store.Jobs[:i], cs.store.Jobs[i+1:]...) } else { next := cs.computeNextRun(&cs.store.Jobs[i].Schedule, now) cs.store.Jobs[i].State.NextRunAtMS = next } cs.saveUnsafe() break } // Record run log cs.recordRun(jobID, err, result) if err != nil { return true, "", err } return true, result, nil } // GetRunLog returns recent run log entries for a job (or all jobs if jobID is empty). func (cs *Service) GetRunLog(jobID string, limit int) []RunLogEntry { cs.mu.Lock() defer cs.mu.Unlock() if limit <= 0 { limit = 20 } var result []RunLogEntry for i := len(cs.runLog) - 1; i >= 0 && len(result) < limit; i-- { entry := cs.runLog[i] if jobID == "" || entry.JobID == jobID { result = append(result, entry) } } return result } func (cs *Service) recordRun(jobID string, err error, resultText string) { cs.mu.Lock() defer cs.mu.Unlock() entry := RunLogEntry{ Ts: nowMS(), JobID: jobID, } if err != nil { entry.Status = "error" entry.Error = err.Error() } else { entry.Status = "ok" entry.Summary = TruncateOutput(resultText) } cs.runLog = append(cs.runLog, entry) // Keep last 200 entries in memory if len(cs.runLog) > 200 { cs.runLog = cs.runLog[len(cs.runLog)-200:] } } // --- Internal scheduling loop --- func (cs *Service) runLoop(stopChan chan struct{}) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-stopChan: return case <-ticker.C: cs.checkJobs() } } } func (cs *Service) checkJobs() { cs.mu.Lock() now := nowMS() var dueJobIDs []string for i := range cs.store.Jobs { job := &cs.store.Jobs[i] if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now { dueJobIDs = append(dueJobIDs, job.ID) } } if len(dueJobIDs) == 0 { cs.mu.Unlock() return } // Clear NextRunAtMS to prevent duplicate execution dueMap := make(map[string]bool, len(dueJobIDs)) for _, id := range dueJobIDs { dueMap[id] = true } for i := range cs.store.Jobs { if dueMap[cs.store.Jobs[i].ID] { cs.store.Jobs[i].State.NextRunAtMS = nil } } cs.saveUnsafe() cs.mu.Unlock() // Execute jobs in parallel — scheduler enforces per-session serialization var wg sync.WaitGroup for _, jobID := range dueJobIDs { wg.Add(1) go func(id string) { defer wg.Done() cs.executeJobByID(id) }(jobID) } wg.Wait() } func (cs *Service) executeJobByID(jobID string) { cs.mu.Lock() var job *Job for i := range cs.store.Jobs { if cs.store.Jobs[i].ID == jobID { j := cs.store.Jobs[i] // copy job = &j break } } handler := cs.onJob cs.mu.Unlock() if job == nil || handler == nil { return } slog.Info("cron executing job", "id", job.ID, "name", job.Name) result, attempts, err := ExecuteWithRetry(func() (string, error) { return handler(job) }, cs.retryCfg) if attempts > 1 { slog.Info("cron job retried", "id", job.ID, "attempts", attempts, "success", err == nil) } cs.mu.Lock() defer cs.mu.Unlock() for i := range cs.store.Jobs { if cs.store.Jobs[i].ID != jobID { continue } now := nowMS() cs.store.Jobs[i].State.LastRunAtMS = &now if err != nil { cs.store.Jobs[i].State.LastStatus = "error" cs.store.Jobs[i].State.LastError = err.Error() slog.Error("cron job failed", "id", jobID, "error", err) } else { cs.store.Jobs[i].State.LastStatus = "ok" cs.store.Jobs[i].State.LastError = "" slog.Info("cron job completed", "id", jobID, "result", result) } // Schedule next run or handle one-time jobs if cs.store.Jobs[i].DeleteAfterRun { cs.store.Jobs = append(cs.store.Jobs[:i], cs.store.Jobs[i+1:]...) } else { next := cs.computeNextRun(&cs.store.Jobs[i].Schedule, now) cs.store.Jobs[i].State.NextRunAtMS = next if next == nil { cs.store.Jobs[i].Enabled = false } } break } cs.saveUnsafe() } // --- Schedule computation --- func (cs *Service) computeNextRun(schedule *Schedule, now int64) *int64 { switch schedule.Kind { case "at": if schedule.AtMS != nil && *schedule.AtMS > now { return schedule.AtMS } return nil case "every": if schedule.EveryMS == nil || *schedule.EveryMS <= 0 { return nil } next := now + *schedule.EveryMS return &next case "cron": if schedule.Expr == "" { return nil } nowTime := time.UnixMilli(now) if schedule.TZ != "" { if loc, err := time.LoadLocation(schedule.TZ); err == nil { nowTime = nowTime.In(loc) } } nextTime, err := gronx.NextTickAfter(schedule.Expr, nowTime, false) if err != nil { slog.Error("cron: failed to compute next run", "expr", schedule.Expr, "error", err) return nil } nextMS := nextTime.UnixMilli() return &nextMS default: return nil } } func (cs *Service) validateSchedule(schedule *Schedule) error { switch schedule.Kind { case "at": if schedule.AtMS == nil { return fmt.Errorf("at schedule requires atMs") } case "every": if schedule.EveryMS == nil || *schedule.EveryMS <= 0 { return fmt.Errorf("every schedule requires positive everyMs") } case "cron": if schedule.Expr == "" { return fmt.Errorf("cron schedule requires expr") } gx := gronx.New() if !gx.IsValid(schedule.Expr) { return fmt.Errorf("invalid cron expression: %s", schedule.Expr) } if schedule.TZ != "" { if _, err := time.LoadLocation(schedule.TZ); err != nil { return fmt.Errorf("invalid timezone: %s", schedule.TZ) } } default: return fmt.Errorf("unknown schedule kind: %s", schedule.Kind) } return nil } func (cs *Service) getNextWakeMS() *int64 { var earliest *int64 for _, job := range cs.store.Jobs { if job.Enabled && job.State.NextRunAtMS != nil { if earliest == nil || *job.State.NextRunAtMS < *earliest { earliest = job.State.NextRunAtMS } } } return earliest } // --- Persistence --- func (cs *Service) loadUnsafe() error { data, err := os.ReadFile(cs.storePath) if err != nil { if os.IsNotExist(err) { return nil } return err } return json.Unmarshal(data, &cs.store) } func (cs *Service) saveUnsafe() error { dir := filepath.Dir(cs.storePath) if err := os.MkdirAll(dir, 0755); err != nil { return err } data, err := json.MarshalIndent(cs.store, "", " ") if err != nil { return err } return os.WriteFile(cs.storePath, data, 0644) }