carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// This file is the timers.spec column's grammar: parse the three stored
2// shapes ("every <duration>", "cron <5 fields>", "at <unix>"), compute each
3// one's next fire from now (spec §6.1's coalescing — always from now, never
4// from the scheduled time), and render the canonical text back for
5// Converge and Runner to store and for `carlos schedule ls` to show.
6
7package schedule
8
9import (
10 "errors"
11 "fmt"
12 "strconv"
13 "strings"
14 "time"
15)
16
17const (
18 KindSchedule = "schedule"
19 KindOnce = "once"
20
21 MinEvery = time.Minute
22 MaxEvery = 720 * time.Hour
23 MaxOnceAhead = 400 * 24 * time.Hour
24
25 // MaxOncePerHost bounds how many pending "at" jobs a single box may
26 // hold at once — a later task's (the runner's) enforcement point, kept
27 // here with the other schedule-wide limits.
28 MaxOncePerHost = 1000
29)
30
31// Spec is the parsed timers.spec column — one of three shapes, and the only
32// thing the runner needs to know to compute the next fire.
33type Spec struct {
34 Kind string
35 Every time.Duration
36 Cron *CronExpr
37 At time.Time
38}
39
40// ParseSpec reads the canonical column text: "every <duration>",
41// "cron <5 fields>", "at <unix>". String is its inverse.
42func ParseSpec(s string) (Spec, error) {
43 kind, rest, _ := strings.Cut(strings.TrimSpace(s), " ")
44 rest = strings.TrimSpace(rest)
45 switch kind {
46 case "every":
47 d, err := time.ParseDuration(rest)
48 if err != nil {
49 return Spec{}, fmt.Errorf("every: %w", err)
50 }
51 if err := ValidEvery(d); err != nil {
52 return Spec{}, err
53 }
54 return Spec{Kind: KindSchedule, Every: d}, nil
55 case "cron":
56 c, err := ParseCron(rest)
57 if err != nil {
58 return Spec{}, err
59 }
60 return Spec{Kind: KindSchedule, Cron: c}, nil
61 case "at":
62 n, err := strconv.ParseInt(rest, 10, 64)
63 if err != nil {
64 return Spec{}, fmt.Errorf("at: %q is not a unix time", rest)
65 }
66 return Spec{Kind: KindOnce, At: time.Unix(n, 0).UTC()}, nil
67 }
68 return Spec{}, fmt.Errorf("spec %q: want every|cron|at", s)
69}
70
71// ValidEvery is the member-facing bound: whole minutes, 1m to 30d.
72func ValidEvery(d time.Duration) error {
73 switch {
74 case d < MinEvery:
75 return errors.New("every: shortest interval is 1m")
76 case d > MaxEvery:
77 return errors.New("every: longest interval is 720h (30 days)")
78 case d%time.Minute != 0:
79 return errors.New("every: whole minutes only")
80 }
81 return nil
82}
83
84func (s Spec) String() string {
85 switch {
86 case s.Every > 0:
87 return "every " + shortEvery(s.Every)
88 case s.Cron != nil:
89 return "cron " + s.Cron.String()
90 default:
91 return "at " + strconv.FormatInt(s.At.Unix(), 10)
92 }
93}
94
95// shortEvery renders d the way a member typed it, not the way
96// time.Duration.String does: stdlib always spells out hours, minutes AND
97// seconds ("6h" comes back "6h0m0s"), which is what a box would have stored
98// as the timers.spec column and what `carlos schedule ls` would have shown
99// verbatim. ValidEvery only ever lets a whole number of minutes through, so
100// d has no seconds component to render; this drops whichever of hours/
101// minutes is zero instead of printing it.
102func shortEvery(d time.Duration) string {
103 h := d / time.Hour
104 m := (d % time.Hour) / time.Minute
105 switch {
106 case h == 0:
107 return fmt.Sprintf("%dm", m)
108 case m == 0:
109 return fmt.Sprintf("%dh", h)
110 default:
111 return fmt.Sprintf("%dh%dm", h, m)
112 }
113}
114
115// Next is the first fire strictly after now — epoch-aligned for every,
116// minute-stepped for cron, the instant itself for a pending once and the
117// zero time for a once that has passed. Always from now, never from the
118// scheduled time: that is what coalesces a missed run into one (spec §6.1).
119func (s Spec) Next(now time.Time) time.Time {
120 now = now.UTC()
121 switch {
122 case s.Every > 0:
123 n := now.Unix()
124 e := int64(s.Every / time.Second)
125 return time.Unix((n/e+1)*e, 0).UTC()
126 case s.Cron != nil:
127 return s.Cron.Next(now)
128 default:
129 if !s.At.After(now) {
130 return time.Time{}
131 }
132 return s.At.UTC()
133 }
134}
135