Catch up every period within 24 hours, oldest first (F20-01)
Catch-up handled only each schedule's most recent fire time, so an earlier unclaimed period still within CatchUpWindow was skipped for good. Across the Chicago spring-forward change, 01:30 on 8 March and 01:30 on 9 March 2026 are 23 hours apart; booting at 9 March 01:35 ran only 9 March. The scheduler now handles, in time order, every fire time after done that passed within CatchUpWindow, plus the most recent one even when it is older (recorded as missed, as before). Each period keeps its own claim; a failed claim stops that schedule's queue and is retried in order. Older fire times are still never looked at. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,13 +15,14 @@
|
|||||||
//
|
//
|
||||||
// # Catch-up
|
// # Catch-up
|
||||||
//
|
//
|
||||||
// When the scheduler starts, and whenever it wakes late, it looks only at
|
// When the scheduler starts, and whenever it wakes late, it handles, oldest
|
||||||
// each schedule's most recent fire time. If that time passed within
|
// first, every fire time of each schedule that passed within CatchUpWindow,
|
||||||
// CatchUpWindow and the period is unclaimed, the period runs once. If it
|
// and the most recent fire time even when it passed longer ago. Each
|
||||||
// passed longer ago, the period is claimed without running and logged as
|
// unclaimed period within CatchUpWindow runs once. A most recent one that
|
||||||
// missed, so it can never run later, not after a restart and not after a
|
// passed longer ago is claimed without running and logged as missed, so it
|
||||||
// settings change moves the fire time. Fire times before the most recent
|
// can never run later, not after a restart and not after a settings change
|
||||||
// one are never looked at.
|
// moves the fire time. Other fire times older than CatchUpWindow are never
|
||||||
|
// looked at.
|
||||||
//
|
//
|
||||||
// # Claim failures
|
// # Claim failures
|
||||||
//
|
//
|
||||||
@@ -71,6 +72,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -316,7 +318,7 @@ func (s *Scheduler) Register(sch Schedule) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start reads the settings and starts the scheduler, which first catches
|
// Start reads the settings and starts the scheduler, which first catches
|
||||||
// up on each schedule's most recent period and then fires schedules as
|
// up on each schedule's recent periods and then fires schedules as
|
||||||
// their times come. Settings whose zone or rules are invalid are an error
|
// their times come. Settings whose zone or rules are invalid are an error
|
||||||
// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits
|
// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits
|
||||||
// for it.
|
// for it.
|
||||||
@@ -496,22 +498,21 @@ func (s *Scheduler) compileChange(ctx context.Context, settings household.Settin
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// target is one schedule's period, named by the fire time it belongs to
|
// target is one schedule's periods still to handle, oldest first, each
|
||||||
// and the household-local date of its period. A target whose fire time
|
// named by its fire time and the household-local date of its period. A
|
||||||
// could not be looked up has lookup set instead, with the stretch
|
// target whose fire times could not be looked up has lookup set instead,
|
||||||
// (done, until] to look it up in again.
|
// with the stretch (done, until] to look them up in again.
|
||||||
type target struct {
|
type target struct {
|
||||||
it item
|
it item
|
||||||
loc *time.Location
|
loc *time.Location
|
||||||
at time.Time
|
fires []fireTime
|
||||||
date store.LocalDate
|
|
||||||
|
|
||||||
lookup bool
|
lookup bool
|
||||||
done, until time.Time
|
done, until time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// fire handles, for every schedule, its most recent fire time at or before
|
// fire handles, for every schedule, its catch-up fire times after done and
|
||||||
// until if it is after done; now is the current instant. It returns the
|
// at or before until; now is the current instant. It returns the
|
||||||
// targets whose lookup or claim failed, to be handled again.
|
// targets whose lookup or claim failed, to be handled again.
|
||||||
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
|
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
|
||||||
var failed []target
|
var failed []target
|
||||||
@@ -526,21 +527,36 @@ func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Tim
|
|||||||
return failed
|
return failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// fireItem handles it's most recent fire time at or before until if it is
|
// fireItem handles, oldest first, it's fire times after done and at or
|
||||||
// after done. It reports false, with the target to handle again, when the
|
// before until that passed within CatchUpWindow of now, and the most recent
|
||||||
// fire time could not be looked up or its period's claim failed.
|
// of them even when it passed longer ago. It reports false, with the target
|
||||||
|
// to handle again, when the fire times could not be looked up or a period's
|
||||||
|
// claim failed.
|
||||||
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
|
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
|
||||||
f, due, err := fireBefore(ctx, it.rule, loc, until)
|
fs, err := fireBetween(ctx, it.rule, loc, done, until)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
|
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
|
||||||
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
|
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
|
||||||
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
|
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
|
||||||
}
|
}
|
||||||
if !due || !f.at.After(done) {
|
i := 0
|
||||||
return target{}, true
|
for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow {
|
||||||
|
i++
|
||||||
}
|
}
|
||||||
t := target{it: it, loc: loc, at: f.at, date: f.date}
|
return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now)
|
||||||
return t, s.handle(ctx, t, now)
|
}
|
||||||
|
|
||||||
|
// handleAll handles the periods of t in order, stopping at the first whose
|
||||||
|
// claim failed. It reports false, with that period and those after it,
|
||||||
|
// when one did.
|
||||||
|
func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) {
|
||||||
|
for i, f := range t.fires {
|
||||||
|
if !s.handle(ctx, t.it, f, now) {
|
||||||
|
t.fires = t.fires[i:]
|
||||||
|
return t, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target{}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// retry handles targets again at now and returns those whose lookup or
|
// retry handles targets again at now and returns those whose lookup or
|
||||||
@@ -557,18 +573,18 @@ func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time)
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !s.handle(ctx, t, now) {
|
if again, ok := s.handleAll(ctx, t, now); !ok {
|
||||||
failed = append(failed, t)
|
failed = append(failed, again)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return failed
|
return failed
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle claims the period of t and runs it, or records it as missed when
|
// handle claims the period of it's fire time f and runs it, or records it
|
||||||
// its fire time is more than CatchUpWindow before now. It reports false
|
// as missed when f is more than CatchUpWindow before now. It reports false
|
||||||
// when the claim failed and the period is still unclaimed.
|
// when the claim failed and the period is still unclaimed.
|
||||||
func (s *Scheduler) handle(ctx context.Context, t target, now time.Time) bool {
|
func (s *Scheduler) handle(ctx context.Context, it item, f fireTime, now time.Time) bool {
|
||||||
it, at, date := t.it, t.at, t.date
|
at, date := f.at, f.date
|
||||||
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
||||||
if it.rule.NextWeek {
|
if it.rule.NextWeek {
|
||||||
period.Week = household.WeekStart(addDays(date, 7))
|
period.Week = household.WeekStart(addDays(date, 7))
|
||||||
@@ -634,12 +650,12 @@ func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error)
|
|||||||
return next, !next.IsZero(), nil
|
return next, !next.IsZero(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// window is how many days either side of a date fireBefore and fireAfter
|
// window is how many days either side of a date fireBetween and fireAfter
|
||||||
// search: more than a week, so a weekly rule always has a fire time on
|
// search: more than a week, so a weekly rule always has a fire time on
|
||||||
// each side.
|
// each side.
|
||||||
const window = 8
|
const window = 8
|
||||||
|
|
||||||
// atWindow is how many days either side of a date fireBefore and fireAfter
|
// atWindow is how many days either side of a date fireBetween and fireAfter
|
||||||
// look up a DailyAt rule's fire times. A period firing further off is
|
// look up a DailyAt rule's fire times. A period firing further off is
|
||||||
// looked up on a later pass, which comes within maxWait.
|
// looked up on a later pass, which comes within maxWait.
|
||||||
const atWindow = 2
|
const atWindow = 2
|
||||||
@@ -650,19 +666,21 @@ type fireTime struct {
|
|||||||
date store.LocalDate
|
date store.LocalDate
|
||||||
}
|
}
|
||||||
|
|
||||||
// fireBefore returns the latest fire time of r in zone loc at or before t.
|
// fireBetween returns the fire times of r in zone loc after from and at or
|
||||||
func fireBefore(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
|
// before t, oldest first.
|
||||||
|
func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) {
|
||||||
fs, err := fireTimes(ctx, r, loc, t)
|
fs, err := fireTimes(ctx, r, loc, t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fireTime{}, false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var best fireTime
|
var out []fireTime
|
||||||
for _, f := range fs {
|
for _, f := range fs {
|
||||||
if !f.at.After(t) && (best.at.IsZero() || f.at.After(best.at)) {
|
if f.at.After(from) && !f.at.After(t) {
|
||||||
best = f
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return best, !best.at.IsZero(), nil
|
slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) })
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fireAfter returns the earliest fire time of r in zone loc after t.
|
// fireAfter returns the earliest fire time of r in zone loc after t.
|
||||||
|
|||||||
@@ -403,6 +403,37 @@ func TestCatchUpOnlyMostRecentPeriod(t *testing.T) {
|
|||||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"})
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) {
|
||||||
|
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08,
|
||||||
|
// so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart.
|
||||||
|
e := newEnv(t)
|
||||||
|
e.update(household.SettingsPatch{ReviewTime: ptr("01:30")})
|
||||||
|
var rec recorder
|
||||||
|
review := scheduler.Schedule{
|
||||||
|
Name: scheduler.RotationCheck,
|
||||||
|
Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) },
|
||||||
|
Run: rec.run,
|
||||||
|
}
|
||||||
|
// Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8
|
||||||
|
// 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m
|
||||||
|
// ago, is not looked at.
|
||||||
|
r := e.start(at(2026, 3, 9, 1, 35), review)
|
||||||
|
wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"})
|
||||||
|
wantFired(t, rec.periods(),
|
||||||
|
time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST
|
||||||
|
time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT
|
||||||
|
)
|
||||||
|
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"})
|
||||||
|
r.stop()
|
||||||
|
|
||||||
|
// A restart within the window finds both periods claimed.
|
||||||
|
var again recorder
|
||||||
|
review.Run = again.run
|
||||||
|
r2 := e.start(at(2026, 3, 9, 1, 40), review)
|
||||||
|
r2.runUntil(at(2026, 3, 9, 23, 0))
|
||||||
|
wantKeys(t, "after restart", again.keys(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
func TestSpringForward(t *testing.T) {
|
func TestSpringForward(t *testing.T) {
|
||||||
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08.
|
// Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08.
|
||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
|
|||||||
Reference in New Issue
Block a user