"encoding/json"
"errors"
"fmt"
+ "log"
"os"
"slices"
"strconv"
)
// openSchedules reads the schedules of the file at path. A missing file holds
-// none. The clock gives the time and the time zone of the schedules.
-func openSchedules(path string, clock func() time.Time) (*Schedules, error) {
+// none. The clock gives the time and the time zone of the schedules, and logger
+// gets one line for each run.
+func openSchedules(path string, clock func() time.Time, logger *log.Logger) (*Schedules, error) {
s := &Schedules{
path: path,
clock: clock,
+ logger: logger,
check: scheduleCheck,
changed: make(chan struct{}, 1),
}
type Schedules struct {
path string
clock func() time.Time
+ logger *log.Logger
check time.Duration
changed chan struct{}
// 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
-// crash between the two makes the run repeat once.
+// crash between the two makes the run repeat once. Each run logs one line with
+// the ID of the schedule and, for a late run, how late it is. The line holds no
+// text of the schedule.
func (s *Schedules) deliver(ctx context.Context, l *TurnLoop) error {
now := s.clock()
var turns []Message
return err
}
+ if schedule.late(now) {
+ s.logger.Printf("schedule %d runs %s late, because the daemon was stopped at its time", schedule.ID, durationText(now.Sub(schedule.Due)))
+ } else {
+ s.logger.Printf("schedule %d runs", schedule.ID)
+ }
+
turns = append(turns, message)
}
s.Text,
}
- if now.Sub(s.Due) > scheduleLate {
+ if s.late(now) {
lines = append(lines, scheduleLateLine)
}
return strings.Join(append(lines, scheduleEmptyReply), "\n")
}
+// late tells if a run at now is late: more than scheduleLate after the due
+// time.
+func (s Schedule) late(now time.Time) bool {
+ return now.Sub(s.Due) > scheduleLate
+}
+
// repeats returns once for a one-time schedule, or else repeats and the cron
// expression.
func (s Schedule) repeats() string {
import (
"context"
+ "io"
+ "log"
"os"
"path/filepath"
"strings"
require.NoError(t, err)
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
- reopened, err := openSchedules(path, schedules.clock)
+ reopened, err := openSchedules(path, schedules.clock, log.New(io.Discard, "", 0))
require.NoError(t, err)
require.Equal(t, "2 | Monday 2026-11-09 08:00 -03:00 | 0 8 * * 1-5 | Tell the user the news.", reopened.List())
path := filepath.Join(t.TempDir(), scheduleFileName)
require.NoError(t, os.WriteFile(path, []byte(`{"next":1,"schedules":[{"id":1,"text":"x","cron":"* * * * *","due":"2026-11-09T09:00:00-03:00"}]}`), 0o600))
- _, err := openSchedules(path, time.Now)
+ _, err := openSchedules(path, time.Now, log.New(io.Discard, "", 0))
require.ErrorContains(t, err, "schedule 1: the runs of")
}
now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
}
- schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now)
+ logs := &SyncBuffer{}
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, log.New(logs, "", 0))
require.NoError(t, err)
server := newModelFake(t)
require.Equal(t, "[Schedule 1] Sunday 2026-11-08 20:05 -03:00, once\nRemind the user to drink water.\n"+scheduleEmptyReply, texts[len(texts)-1])
require.Equal(t, scheduleSource, test.events.LastEvent("work").Source)
require.Equal(t, "No schedules.", schedules.List())
+ require.Equal(t, "schedule 1 runs\n", logs.String())
}
func TestSchedulesRunMissedSchedulesOnceAtTheStart(t *testing.T) {
now: time.Date(2026, time.November, 8, 20, 30, 0, 0, time.FixedZone("", -3*60*60)),
}
- schedules, err := openSchedules(path, clock.Now)
+ logs := &SyncBuffer{}
+ schedules, err := openSchedules(path, clock.Now, log.New(logs, "", 0))
require.NoError(t, err)
server := newModelFake(t)
"[Schedule 2] Sunday 2026-11-08 10:00 -03:00, repeats 0 * * * *\nTell the user the time.\n" + scheduleLateLine + "\n" + scheduleEmptyReply,
}, texts[len(texts)-2:])
require.Equal(t, "2 | Sunday 2026-11-08 21:00 -03:00 | 0 * * * * | Tell the user the time.", schedules.List())
+ require.Equal(t, "schedule 1 runs 11h30m0s late, because the daemon was stopped at its time\nschedule 2 runs 10h30m0s late, because the daemon was stopped at its time\n", logs.String())
}
func TestDaemonRunsADueSchedule(t *testing.T) {
now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
}
- schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now)
+ schedules, err := openSchedules(filepath.Join(t.TempDir(), scheduleFileName), clock.Now, log.New(io.Discard, "", 0))
require.NoError(t, err)
schedules.check = time.Millisecond
now: time.Date(2026, time.November, 8, 20, 0, 0, 0, time.FixedZone("", -3*60*60)),
}
- schedules, err := openSchedules(path, clock.Now)
+ schedules, err := openSchedules(path, clock.Now, log.New(io.Discard, "", 0))
require.NoError(t, err)
return schedules, path