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