]> git.giacomolli.uk - onechat/blob - schedule.go
Deliver routed WhatsApp batches to children
[onechat] / schedule.go
1 package main
2
3 import (
4         "cmp"
5         "context"
6         "encoding/json"
7         "errors"
8         "fmt"
9         "log/slog"
10         "os"
11         "slices"
12         "strconv"
13         "strings"
14         "sync"
15         "time"
16 )
17
18 const (
19         // scheduleFileName is the file of the schedules in the directory of the
20         // agent.
21         scheduleFileName = "schedules.json"
22
23         // scheduleTextBytes is the most bytes of the text of a schedule.
24         scheduleTextBytes = 4096
25
26         // scheduleAtLayout is the layout of the time of a one-time schedule.
27         scheduleAtLayout = "2006-01-02 15:04"
28
29         // scheduleTimeLayout is the layout of the times in the answers of the
30         // tools and in the log.
31         scheduleTimeLayout = "Monday 2006-01-02 15:04 -07:00"
32
33         // scheduleSource is the source of the work events of the runs.
34         scheduleSource = "schedule"
35
36         // scheduleCheck is the longest wait of Run before it looks at the times
37         // again, so that it sees a suspend of the machine or a change of the
38         // clock.
39         scheduleCheck = time.Minute
40
41         // scheduleLate is how long after its time a run is late.
42         scheduleLate = time.Minute
43
44         // scheduleEmptyReply is the last line of the message of a run.
45         scheduleEmptyReply = "If nothing in this task needs the user, end your turn with an empty reply."
46
47         // scheduleLateLine tells the agent that a run is late.
48         scheduleLateLine = "This run is late: the daemon was stopped at that time."
49 )
50
51 // openSchedules reads the schedules of the file at path. A missing file holds
52 // none. The owner names its agent. Schedules without an owner take this name. A
53 // different stored owner is an error. The clock gives the time and the time
54 // zone of the schedules, and logger gets one line for each run.
55 func openSchedules(path, owner string, clock func() time.Time, logger *slog.Logger) (*Schedules, error) {
56         s := &Schedules{
57                 path:    path,
58                 owner:   owner,
59                 clock:   clock,
60                 logger:  logger,
61                 check:   scheduleCheck,
62                 changed: make(chan struct{}, 1),
63         }
64
65         data, err := os.ReadFile(path)
66         if errors.Is(err, os.ErrNotExist) {
67                 return s, nil
68         }
69
70         if err != nil {
71                 return nil, fmt.Errorf("read schedules: %w", err)
72         }
73
74         err = json.Unmarshal(data, &s.file)
75         if err != nil {
76                 return nil, fmt.Errorf("%s: %w", path, err)
77         }
78
79         changed := false
80         for i := range s.file.Schedules {
81                 schedule := &s.file.Schedules[i]
82                 if schedule.Owner == "" {
83                         schedule.Owner = owner
84                         changed = true
85                 }
86
87                 if schedule.Owner != owner {
88                         return nil, fmt.Errorf("schedule %d belongs to %q, not %q", schedule.ID, schedule.Owner, owner)
89                 }
90
91                 if schedule.Cron == "" {
92                         continue
93                 }
94
95                 _, err = scheduleParseCron(schedule.Cron)
96                 if err != nil {
97                         return nil, fmt.Errorf("%s: schedule %d: %w", path, schedule.ID, err)
98                 }
99         }
100
101         if changed {
102                 err = s.save()
103                 if err != nil {
104                         return nil, fmt.Errorf("record schedule owner: %w", err)
105                 }
106         }
107
108         return s, nil
109 }
110
111 // Schedules holds the schedules of the agent: tasks that start a turn at one
112 // time or at the times of a cron expression. Each change writes the file whole
113 // before it returns, so a schedule survives a restart. The mutex protects the
114 // schedules, because the tools change them from a turn while Run reads them.
115 // changed tells Run that a set or a remove changed the times.
116 type Schedules struct {
117         path    string
118         owner   string
119         clock   func() time.Time
120         logger  *slog.Logger
121         check   time.Duration
122         changed chan struct{}
123
124         mutex sync.Mutex
125         file  ScheduleFile
126 }
127
128 // Run starts the runs of the due schedules in l until ctx ends. It waits until
129 // the first due time, at most check at a time, and also wakes at a set or a
130 // remove. So a schedule that came due while the daemon was stopped runs at the
131 // start. It returns the error of a write of the log or of the file.
132 func (s *Schedules) Run(ctx context.Context, l *TurnLoop) error {
133         for {
134                 err := s.deliver(ctx, l)
135                 if err != nil {
136                         return err
137                 }
138
139                 var (
140                         timer   *time.Timer
141                         timeout <-chan time.Time
142                 )
143
144                 wait, found := s.Wait(s.clock())
145                 if found {
146                         timer = time.NewTimer(min(wait, s.check))
147                         timeout = timer.C
148                 }
149
150                 select {
151                 case <-ctx.Done():
152                         return nil
153                 case <-s.changed:
154                 case <-timeout:
155                 }
156
157                 if timer != nil {
158                         timer.Stop()
159                 }
160         }
161 }
162
163 // Set adds a schedule with text and returns the answer of the tool. Exactly one
164 // of at and cron is not nil. at is a local time in scheduleAtLayout after now,
165 // and it goes through scheduleWallTime, so a time in the hour that the clock
166 // repeats is its first occurrence. cron is an expression of scheduleParseCron.
167 func (s *Schedules) Set(text string, at, cron *string) (string, error) {
168         text = strings.TrimSpace(text)
169         if text == "" || len(text) > scheduleTextBytes {
170                 return "", fmt.Errorf("the text must have 1 to %d bytes", scheduleTextBytes)
171         }
172
173         now := s.clock()
174         schedule := Schedule{
175                 Text:  text,
176                 Owner: s.owner,
177         }
178
179         switch {
180         case (at == nil) == (cron == nil):
181                 return "", errors.New("give either at or cron, and null for the other")
182         case at != nil:
183                 parsed, err := time.Parse(scheduleAtLayout, *at)
184                 if err != nil {
185                         return "", fmt.Errorf("at must be a local time such as %s", now.Format(scheduleAtLayout))
186                 }
187
188                 schedule.Due = scheduleWallTime(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), now.Location())
189                 if !schedule.Due.After(now) {
190                         return "", fmt.Errorf("the time %s is not after now, %s", schedule.Due.Format(scheduleTimeLayout), now.Format(scheduleTimeLayout))
191                 }
192         default:
193                 parsed, err := scheduleParseCron(*cron)
194                 if err != nil {
195                         return "", err
196                 }
197
198                 next, found := parsed.Next(now)
199                 if !found {
200                         return "", fmt.Errorf("the cron expression %q matches no time in %d years", *cron, scheduleCronYears)
201                 }
202
203                 schedule.Cron = parsed.String()
204                 schedule.Due = next
205         }
206
207         s.mutex.Lock()
208         defer s.mutex.Unlock()
209
210         s.file.Next++
211         schedule.ID = s.file.Next
212         s.file.Schedules = append(s.file.Schedules, schedule)
213         err := s.save()
214         if err != nil {
215                 return "", err
216         }
217
218         s.change()
219
220         return fmt.Sprintf("Schedule %d set. Next: %s.", schedule.ID, schedule.Due.In(now.Location()).Format(scheduleTimeLayout)), nil
221 }
222
223 // List returns the answer of the tool: one line for each schedule, by due time,
224 // with its ID, its next time, its cron expression or once, and its text on one
225 // line.
226 func (s *Schedules) List() string {
227         location := s.clock().Location()
228         schedules := s.sorted()
229         if len(schedules) == 0 {
230                 return "No schedules."
231         }
232
233         lines := make([]string, 0, len(schedules))
234         for _, schedule := range schedules {
235                 lines = append(lines, strconv.Itoa(schedule.ID)+" | "+schedule.Due.In(location).Format(scheduleTimeLayout)+" | "+cmp.Or(schedule.Cron, "once")+" | "+strings.Join(strings.Fields(schedule.Text), " "))
236         }
237
238         return strings.Join(lines, "\n")
239 }
240
241 // Len returns the number of schedules.
242 func (s *Schedules) Len() int {
243         s.mutex.Lock()
244         defer s.mutex.Unlock()
245
246         return len(s.file.Schedules)
247 }
248
249 // Remove removes the schedule id and returns the answer of the tool.
250 func (s *Schedules) Remove(id int) (string, error) {
251         s.mutex.Lock()
252         defer s.mutex.Unlock()
253
254         index := slices.IndexFunc(s.file.Schedules, func(schedule Schedule) bool {
255                 return schedule.ID == id
256         })
257
258         if index < 0 {
259                 return "", fmt.Errorf("no schedule %d", id)
260         }
261
262         s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
263         err := s.save()
264         if err != nil {
265                 return "", err
266         }
267
268         s.change()
269
270         return fmt.Sprintf("Schedule %d removed.", id), nil
271 }
272
273 // deliver logs the message of each due schedule in l, in due order, as a work
274 // message of scheduleSource, and wakes the loop once with all of them. A
275 // schedule ends its run with Done after its message is durable in the log. A
276 // crash between the two makes the run repeat once. Each run logs one line with
277 // the ID of the schedule and, for a late run, how late it is. The line holds no
278 // text of the schedule.
279 func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error {
280         if l.agent.config.Name != s.owner {
281                 return fmt.Errorf("schedule owner %q does not match agent %q", s.owner, l.agent.config.Name)
282         }
283
284         now := s.clock()
285
286         var turns []Message
287
288         for _, schedule := range s.Due(now) {
289                 message, err := l.Work(scheduleSource, schedule.Message(now), "")
290                 if err != nil {
291                         return fmt.Errorf("log a schedule: %w", err)
292                 }
293
294                 // A stop of the daemon during the flush is no failure: the
295                 // schedule stays due and runs at the next start.
296                 err = l.Flush(ctx)
297                 if ctx.Err() != nil {
298                         return nil
299                 }
300
301                 if err != nil {
302                         return fmt.Errorf("log a schedule: %w", err)
303                 }
304
305                 err = s.Done(schedule, now)
306                 if err != nil {
307                         return err
308                 }
309
310                 attrs := []any{"id", schedule.ID}
311                 if schedule.late(now) {
312                         attrs = append(attrs, "late", now.Sub(schedule.Due))
313                 }
314
315                 s.logger.Info("schedule ran", attrs...)
316
317                 turns = append(turns, message)
318         }
319
320         l.Wake(turns...)
321
322         return nil
323 }
324
325 // Due returns the schedules whose time is not after now, by due time.
326 func (s *Schedules) Due(now time.Time) []Schedule {
327         var due []Schedule
328
329         for _, schedule := range s.sorted() {
330                 if !schedule.Due.After(now) {
331                         due = append(due, schedule)
332                 }
333         }
334
335         return due
336 }
337
338 // Done ends the run of schedule at now: a one-time schedule goes, and a
339 // repeating schedule gets the next time after now. So a repeating schedule that
340 // missed several times runs once. When a remove took the schedule first, Done
341 // changes nothing.
342 func (s *Schedules) Done(schedule Schedule, now time.Time) error {
343         s.mutex.Lock()
344         defer s.mutex.Unlock()
345
346         index := slices.IndexFunc(s.file.Schedules, func(held Schedule) bool {
347                 return held.ID == schedule.ID
348         })
349
350         if index < 0 {
351                 return nil
352         }
353
354         if schedule.Cron == "" {
355                 s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
356
357                 return s.save()
358         }
359
360         cron, err := scheduleParseCron(schedule.Cron)
361         if err != nil {
362                 return fmt.Errorf("schedule %d: %w", schedule.ID, err)
363         }
364
365         next, found := cron.Next(now)
366         if !found {
367                 s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1)
368
369                 return s.save()
370         }
371
372         s.file.Schedules[index].Due = next
373
374         return s.save()
375 }
376
377 // Wait returns how long after now the first schedule is due, or false when
378 // there is no schedule.
379 func (s *Schedules) Wait(now time.Time) (time.Duration, bool) {
380         schedules := s.sorted()
381         if len(schedules) == 0 {
382                 return 0, false
383         }
384
385         return max(schedules[0].Due.Sub(now), 0), true
386 }
387
388 // change wakes Run after a set or a remove.
389 func (s *Schedules) change() {
390         select {
391         case s.changed <- struct{}{}:
392         default:
393         }
394 }
395
396 // sorted returns a copy of the schedules by due time, then by ID.
397 func (s *Schedules) sorted() []Schedule {
398         s.mutex.Lock()
399
400         schedules := slices.Clone(s.file.Schedules)
401         s.mutex.Unlock()
402
403         slices.SortFunc(schedules, func(a, b Schedule) int {
404                 return cmp.Or(a.Due.Compare(b.Due), cmp.Compare(a.ID, b.ID))
405         })
406
407         return schedules
408 }
409
410 // save writes the file whole, with mode 0600. The caller holds the mutex.
411 func (s *Schedules) save() error {
412         data, err := json.Marshal(s.file)
413         if err == nil {
414                 err = writeFileAtomic(s.path, append(data, '\n'))
415         }
416
417         if err != nil {
418                 return fmt.Errorf("save schedules: %w", err)
419         }
420
421         return nil
422 }
423
424 // ScheduleFile is the content of the file of the schedules: the ID of the last
425 // schedule, so that an ID is never used again, and the schedules.
426 type ScheduleFile struct {
427         Next      int        `json:"next"`
428         Schedules []Schedule `json:"schedules"`
429 }
430
431 // Schedule is one schedule: its owner, its ID, the task for the agent, the cron
432 // expression of a repeating schedule, empty for a one-time schedule, and its
433 // next time.
434 type Schedule struct {
435         Owner string    `json:"owner"`
436         ID    int       `json:"id"`
437         Text  string    `json:"text"`
438         Cron  string    `json:"cron"`
439         Due   time.Time `json:"due"`
440 }
441
442 // Message returns the text of a run at now for the log, with the times in the
443 // location of now. The first line starts with [Schedule ID] and gives the due
444 // time and once or the cron expression. Then come the text, the line about a
445 // late run when now is more than scheduleLate after the due time, and the line
446 // about the empty reply.
447 func (s Schedule) Message(now time.Time) string {
448         lines := []string{
449                 "[Schedule " + strconv.Itoa(s.ID) + "] " + s.Due.In(now.Location()).Format(scheduleTimeLayout) + ", " + s.repeats(),
450                 s.Text,
451         }
452
453         if s.late(now) {
454                 lines = append(lines, scheduleLateLine)
455         }
456
457         return strings.Join(append(lines, scheduleEmptyReply), "\n")
458 }
459
460 // late tells if a run at now is late: more than scheduleLate after the due
461 // time.
462 func (s Schedule) late(now time.Time) bool {
463         return now.Sub(s.Due) > scheduleLate
464 }
465
466 // repeats returns once for a one-time schedule, or else repeats and the cron
467 // expression.
468 func (s Schedule) repeats() string {
469         if s.Cron == "" {
470                 return "once"
471         }
472
473         return "repeats " + s.Cron
474 }