// This file is the timers.spec column's grammar: parse the three stored // shapes ("every ", "cron <5 fields>", "at "), compute each // one's next fire from now (spec §6.1's coalescing — always from now, never // from the scheduled time), and render the canonical text back for // Converge and Runner to store and for `carlos schedule ls` to show. package schedule import ( "errors" "fmt" "strconv" "strings" "time" ) const ( KindSchedule = "schedule" KindOnce = "once" MinEvery = time.Minute MaxEvery = 720 * time.Hour MaxOnceAhead = 400 * 24 * time.Hour // MaxOncePerHost bounds how many pending "at" jobs a single box may // hold at once — a later task's (the runner's) enforcement point, kept // here with the other schedule-wide limits. MaxOncePerHost = 1000 ) // Spec is the parsed timers.spec column — one of three shapes, and the only // thing the runner needs to know to compute the next fire. type Spec struct { Kind string Every time.Duration Cron *CronExpr At time.Time } // ParseSpec reads the canonical column text: "every ", // "cron <5 fields>", "at ". String is its inverse. func ParseSpec(s string) (Spec, error) { kind, rest, _ := strings.Cut(strings.TrimSpace(s), " ") rest = strings.TrimSpace(rest) switch kind { case "every": d, err := time.ParseDuration(rest) if err != nil { return Spec{}, fmt.Errorf("every: %w", err) } if err := ValidEvery(d); err != nil { return Spec{}, err } return Spec{Kind: KindSchedule, Every: d}, nil case "cron": c, err := ParseCron(rest) if err != nil { return Spec{}, err } return Spec{Kind: KindSchedule, Cron: c}, nil case "at": n, err := strconv.ParseInt(rest, 10, 64) if err != nil { return Spec{}, fmt.Errorf("at: %q is not a unix time", rest) } return Spec{Kind: KindOnce, At: time.Unix(n, 0).UTC()}, nil } return Spec{}, fmt.Errorf("spec %q: want every|cron|at", s) } // ValidEvery is the member-facing bound: whole minutes, 1m to 30d. func ValidEvery(d time.Duration) error { switch { case d < MinEvery: return errors.New("every: shortest interval is 1m") case d > MaxEvery: return errors.New("every: longest interval is 720h (30 days)") case d%time.Minute != 0: return errors.New("every: whole minutes only") } return nil } func (s Spec) String() string { switch { case s.Every > 0: return "every " + shortEvery(s.Every) case s.Cron != nil: return "cron " + s.Cron.String() default: return "at " + strconv.FormatInt(s.At.Unix(), 10) } } // shortEvery renders d the way a member typed it, not the way // time.Duration.String does: stdlib always spells out hours, minutes AND // seconds ("6h" comes back "6h0m0s"), which is what a box would have stored // as the timers.spec column and what `carlos schedule ls` would have shown // verbatim. ValidEvery only ever lets a whole number of minutes through, so // d has no seconds component to render; this drops whichever of hours/ // minutes is zero instead of printing it. func shortEvery(d time.Duration) string { h := d / time.Hour m := (d % time.Hour) / time.Minute switch { case h == 0: return fmt.Sprintf("%dm", m) case m == 0: return fmt.Sprintf("%dh", h) default: return fmt.Sprintf("%dh%dm", h, m) } } // Next is the first fire strictly after now — epoch-aligned for every, // minute-stepped for cron, the instant itself for a pending once and the // zero time for a once that has passed. Always from now, never from the // scheduled time: that is what coalesces a missed run into one (spec §6.1). func (s Spec) Next(now time.Time) time.Time { now = now.UTC() switch { case s.Every > 0: n := now.Unix() e := int64(s.Every / time.Second) return time.Unix((n/e+1)*e, 0).UTC() case s.Cron != nil: return s.Cron.Next(now) default: if !s.At.After(now) { return time.Time{} } return s.At.UTC() } }