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