]> git.giacomolli.uk - onechat/blobdiff - schedule.go
Share general review and documentation rules
[onechat] / schedule.go
index 5fa35da63cb00d6cbbb13aaf6aba6ae055120a23..3c3456ddbcc6363be07b9398b01b737c00653366 100644 (file)
@@ -141,6 +141,20 @@ func openSchedules(path, owner string, clock func() time.Time, logger *slog.Logg
        return s, nil
 }
 
        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
 // 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
 // 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 {
 
                err = s.Done(schedule, now)
                if err != nil {
-                       return err
+                       return fmt.Errorf("acknowledge schedule run: %w", err)
                }
 
                attrs := []any{"id", schedule.ID}
                }
 
                attrs := []any{"id", schedule.ID}
@@ -339,6 +243,17 @@ func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error {
        return nil
 }
 
        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
 // 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)
 
        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)
        }
 
        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)
 
        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
 
        }
 
        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.
 }
 
 // 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 {
        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
 }
 
 // Message returns the text of a run at now for the log, with the times in the