Catch up every period within 24 hours, oldest first (F20-01) (#184)
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #184.
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