X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/f5c601f1801aaa276f8c335fcb51c373edc0b50c..fc0503be49438775c5d08499046852df59bd7377:/schedule.go?ds=inline diff --git a/schedule.go b/schedule.go index 5fa35da..3c3456d 100644 --- a/schedule.go +++ b/schedule.go @@ -141,6 +141,20 @@ func openSchedules(path, owner string, clock func() time.Time, logger *slog.Logg return s, nil } +// save writes the file whole, with mode 0600. The caller holds the mutex. +func (s *Schedules) save() error { + data, err := json.Marshal(s.file) + if err == nil { + err = writeFileAtomic(s.path, append(data, '\n')) + } + + if err != nil { + return fmt.Errorf("save schedules: %w", err) + } + + return nil +} + // Run starts the runs of the due schedules in l until ctx ends. It waits until // the first due time, at most scheduleClockWaitTimeout at a time, and also // wakes at a set or a remove. So a schedule that came due while the daemon was @@ -177,116 +191,6 @@ func (s *Schedules) Run(ctx context.Context, l *TurnLoop) error { } } -// Set adds a schedule with text and returns the answer of the tool. Exactly one -// of at and cron is not nil. at is a local time in scheduleAtLayout after now, -// and it goes through scheduleWallTime, so a time in the hour that the clock -// repeats is its first occurrence. cron is an expression of scheduleParseCron. -func (s *Schedules) Set(text string, at, cron *string) (string, error) { - text = strings.TrimSpace(text) - if text == "" || len(text) > scheduleTextBytes { - return "", fmt.Errorf("the text must have 1 to %d bytes", scheduleTextBytes) - } - - now := s.clock() - schedule := Schedule{ - Text: text, - Owner: s.owner, - } - - switch { - case (at == nil) == (cron == nil): - return "", errors.New("give either at or cron, and null for the other") - case at != nil: - parsed, err := time.Parse(scheduleAtLayout, *at) - if err != nil { - return "", fmt.Errorf("at must be a local time such as %s", now.Format(scheduleAtLayout)) - } - - schedule.Due = scheduleWallTime(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), now.Location()) - if !schedule.Due.After(now) { - return "", fmt.Errorf("the time %s is not after now, %s", schedule.Due.Format(scheduleTimeLayout), now.Format(scheduleTimeLayout)) - } - default: - parsed, err := scheduleParseCron(*cron) - if err != nil { - return "", err - } - - next, found := parsed.Next(now) - if !found { - return "", fmt.Errorf("the cron expression %q matches no time in %d years", *cron, scheduleCronYears) - } - - schedule.Cron = parsed.String() - schedule.Due = next - } - - s.mutex.Lock() - defer s.mutex.Unlock() - - s.file.Next++ - schedule.ID = s.file.Next - s.file.Schedules = append(s.file.Schedules, schedule) - err := s.save() - if err != nil { - return "", err - } - - s.change() - - return fmt.Sprintf("Schedule %d set. Next: %s.", schedule.ID, schedule.Due.In(now.Location()).Format(scheduleTimeLayout)), nil -} - -// List returns the answer of the tool: one line for each schedule, by due time, -// with its ID, its next time, its cron expression or once, and its text on one -// line. -func (s *Schedules) List() string { - location := s.clock().Location() - schedules := s.sorted() - if len(schedules) == 0 { - return "No schedules." - } - - lines := make([]string, 0, len(schedules)) - for _, schedule := range schedules { - lines = append(lines, strconv.Itoa(schedule.ID)+" | "+schedule.Due.In(location).Format(scheduleTimeLayout)+" | "+cmp.Or(schedule.Cron, "once")+" | "+strings.Join(strings.Fields(schedule.Text), " ")) - } - - return strings.Join(lines, "\n") -} - -// Len returns the number of schedules. -func (s *Schedules) Len() int { - s.mutex.Lock() - defer s.mutex.Unlock() - - return len(s.file.Schedules) -} - -// Remove removes the schedule id and returns the answer of the tool. -func (s *Schedules) Remove(id int) (string, error) { - s.mutex.Lock() - defer s.mutex.Unlock() - - index := slices.IndexFunc(s.file.Schedules, func(schedule Schedule) bool { - return schedule.ID == id - }) - - if index < 0 { - return "", fmt.Errorf("no schedule %d", id) - } - - s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1) - err := s.save() - if err != nil { - return "", err - } - - s.change() - - return fmt.Sprintf("Schedule %d removed.", id), nil -} - // deliver logs the message of each due schedule in l, in due order, as a work // message of scheduleSource, and wakes the loop once with all of them. A // schedule ends its run with Done after its message is durable in the log. A @@ -321,7 +225,7 @@ func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error { err = s.Done(schedule, now) if err != nil { - return err + return fmt.Errorf("acknowledge schedule run: %w", err) } attrs := []any{"id", schedule.ID} @@ -339,6 +243,17 @@ func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error { return nil } +// Wait returns how long after now the first schedule is due, or false when +// there is no schedule. +func (s *Schedules) Wait(now time.Time) (time.Duration, bool) { + schedules := s.sorted() + if len(schedules) == 0 { + return 0, false + } + + return max(schedules[0].Due.Sub(now), 0), true +} + // Due returns the schedules whose time is not after now, by due time. func (s *Schedules) Due(now time.Time) []Schedule { var due []Schedule @@ -371,7 +286,12 @@ func (s *Schedules) Done(schedule Schedule, now time.Time) error { if schedule.Cron == "" { s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1) - return s.save() + err := s.save() + if err != nil { + return fmt.Errorf("save completed one-time schedule: %w", err) + } + + return nil } cron, err := scheduleParseCron(schedule.Cron) @@ -383,23 +303,96 @@ func (s *Schedules) Done(schedule Schedule, now time.Time) error { if !found { s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1) - return s.save() + err = s.save() + if err != nil { + return fmt.Errorf("save exhausted schedule: %w", err) + } + + return nil } s.file.Schedules[index].Due = next - return s.save() + err = s.save() + if err != nil { + return fmt.Errorf("save next schedule run: %w", err) + } + + return nil } -// Wait returns how long after now the first schedule is due, or false when -// there is no schedule. -func (s *Schedules) Wait(now time.Time) (time.Duration, bool) { - schedules := s.sorted() - if len(schedules) == 0 { - return 0, false +// sorted returns a copy of the schedules by due time, then by ID. +func (s *Schedules) sorted() []Schedule { + s.mutex.Lock() + + schedules := slices.Clone(s.file.Schedules) + s.mutex.Unlock() + + slices.SortFunc(schedules, func(a, b Schedule) int { + return cmp.Or(a.Due.Compare(b.Due), cmp.Compare(a.ID, b.ID)) + }) + + return schedules +} + +// Set adds a schedule with text and returns the answer of the tool. Exactly one +// of at and cron is not nil. at is a local time in scheduleAtLayout after now, +// and it goes through scheduleWallTime, so a time in the hour that the clock +// repeats is its first occurrence. cron is an expression of scheduleParseCron. +func (s *Schedules) Set(text string, at, cron *string) (string, error) { + text = strings.TrimSpace(text) + if text == "" || len(text) > scheduleTextBytes { + return "", fmt.Errorf("the text must have 1 to %d bytes", scheduleTextBytes) } - return max(schedules[0].Due.Sub(now), 0), true + now := s.clock() + schedule := Schedule{ + Text: text, + Owner: s.owner, + } + + switch { + case (at == nil) == (cron == nil): + return "", errors.New("give either at or cron, and null for the other") + case at != nil: + parsed, err := time.Parse(scheduleAtLayout, *at) + if err != nil { + return "", fmt.Errorf("at must be a local time such as %s", now.Format(scheduleAtLayout)) + } + + schedule.Due = scheduleWallTime(parsed.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), now.Location()) + if !schedule.Due.After(now) { + return "", fmt.Errorf("the time %s is not after now, %s", schedule.Due.Format(scheduleTimeLayout), now.Format(scheduleTimeLayout)) + } + default: + parsed, err := scheduleParseCron(*cron) + if err != nil { + return "", fmt.Errorf("parse schedule expression: %w", err) + } + + next, found := parsed.Next(now) + if !found { + return "", fmt.Errorf("the cron expression %q matches no time in %d years", *cron, scheduleCronYears) + } + + schedule.Cron = parsed.String() + schedule.Due = next + } + + s.mutex.Lock() + defer s.mutex.Unlock() + + s.file.Next++ + schedule.ID = s.file.Next + s.file.Schedules = append(s.file.Schedules, schedule) + err := s.save() + if err != nil { + return "", fmt.Errorf("save new schedule: %w", err) + } + + s.change() + + return fmt.Sprintf("Schedule %d set. Next: %s.", schedule.ID, schedule.Due.In(now.Location()).Format(scheduleTimeLayout)), nil } // change wakes Run after a set or a remove. @@ -410,32 +403,54 @@ func (s *Schedules) change() { } } -// sorted returns a copy of the schedules by due time, then by ID. -func (s *Schedules) sorted() []Schedule { - s.mutex.Lock() +// List returns the answer of the tool: one line for each schedule, by due time, +// with its ID, its next time, its cron expression or once, and its text on one +// line. +func (s *Schedules) List() string { + location := s.clock().Location() + schedules := s.sorted() + if len(schedules) == 0 { + return "No schedules." + } - schedules := slices.Clone(s.file.Schedules) - s.mutex.Unlock() + lines := make([]string, 0, len(schedules)) + for _, schedule := range schedules { + lines = append(lines, strconv.Itoa(schedule.ID)+" | "+schedule.Due.In(location).Format(scheduleTimeLayout)+" | "+cmp.Or(schedule.Cron, "once")+" | "+strings.Join(strings.Fields(schedule.Text), " ")) + } - slices.SortFunc(schedules, func(a, b Schedule) int { - return cmp.Or(a.Due.Compare(b.Due), cmp.Compare(a.ID, b.ID)) - }) + return strings.Join(lines, "\n") +} - return schedules +// Len returns the number of schedules. +func (s *Schedules) Len() int { + s.mutex.Lock() + defer s.mutex.Unlock() + + return len(s.file.Schedules) } -// save writes the file whole, with mode 0600. The caller holds the mutex. -func (s *Schedules) save() error { - data, err := json.Marshal(s.file) - if err == nil { - err = writeFileAtomic(s.path, append(data, '\n')) +// Remove removes the schedule id and returns the answer of the tool. +func (s *Schedules) Remove(id int) (string, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + index := slices.IndexFunc(s.file.Schedules, func(schedule Schedule) bool { + return schedule.ID == id + }) + + if index < 0 { + return "", fmt.Errorf("no schedule %d", id) } + s.file.Schedules = slices.Delete(s.file.Schedules, index, index+1) + err := s.save() if err != nil { - return fmt.Errorf("save schedules: %w", err) + return "", fmt.Errorf("save removed schedule: %w", err) } - return nil + s.change() + + return fmt.Sprintf("Schedule %d removed.", id), nil } // Message returns the text of a run at now for the log, with the times in the