internal/scheduler runs registered schedules (a name, a daily or weekly rule read from the household settings, and a run function) in the household zone with an injectable clock. Each fire time's period is claimed by inserting schedule_runs(schedule, period_key) with ON CONFLICT DO NOTHING, so a period runs at most once across restarts and instances. At boot only each schedule's most recent fire time is considered: within 24 h it runs once, older it is claimed as missed so it never runs later. Settings changes move the next fire time through SettingsService.Subscribe. DST follows household.LocalTime. The server starts it with the worker context and waits for it before closing the database; the concrete schedules come in later Feature 20 tasks. Maestro task f7024755-c3d3-46f3-b798-e242ad676c1e. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
503 lines
15 KiB
Go
503 lines
15 KiB
Go
// Package scheduler runs the household's timed work: each registered
|
|
// schedule fires once per period at a household-local time read from the
|
|
// household settings.
|
|
//
|
|
// # Periods and the claim
|
|
//
|
|
// Every fire time belongs to one period, named by a period key derived in
|
|
// the household zone from the schedule's cadence (see Rule.PeriodKey): the
|
|
// local date "2026-10-09" for a daily rule, and the ISO week "2026-W41" of
|
|
// the fire date, or of the week after it, for a weekly rule. Before running,
|
|
// the scheduler inserts schedule_runs(schedule, period_key); the insert is
|
|
// the claim, and a period already claimed, by an earlier run, an earlier
|
|
// process or another instance, is skipped. A period therefore runs at most
|
|
// once: a Run that fails is logged and its period is not retried.
|
|
//
|
|
// # Catch-up
|
|
//
|
|
// When the scheduler starts, and whenever it wakes late, it looks only at
|
|
// each schedule's most recent fire time. If that time passed within
|
|
// CatchUpWindow and the period is unclaimed, the period runs once. If it
|
|
// passed longer ago, the period is claimed without running and logged as
|
|
// missed, so it can never run later, not after a restart and not after a
|
|
// settings change moves the fire time. Fire times before the most recent
|
|
// one are never looked at.
|
|
//
|
|
// # Time zone and DST
|
|
//
|
|
// Fire times are wall-clock times in the household zone, never the process
|
|
// zone, turned into instants by household.LocalTime. Its rule is the
|
|
// scheduler's DST rule: a time that does not exist on a spring-forward day
|
|
// fires later by the length of the gap (02:30 fires at 03:30 local), and a
|
|
// time that occurs twice on a fall-back day fires once, at its first
|
|
// occurrence. Either way the period key is that local date's, so the
|
|
// period still runs exactly once.
|
|
//
|
|
// # Settings changes
|
|
//
|
|
// The scheduler subscribes to the household settings. A change takes effect
|
|
// at once without a restart: fire times up to the change follow the old
|
|
// settings and later ones the new, so moving a fire time to earlier the
|
|
// same day does not fire the passed time.
|
|
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
)
|
|
|
|
// Schedule names, as the schedule_runs.schedule CHECK constraint allows.
|
|
const (
|
|
SuggestionsWeekly = "suggestions_weekly"
|
|
PlanDraft = "plan_draft"
|
|
ShoppingChatAutosend = "shopping_chat_autosend"
|
|
DayOfReminder = "day_of_reminder"
|
|
RotationCheck = "rotation_check"
|
|
WeekRollover = "week_rollover"
|
|
AIRunsPurge = "ai_runs_purge"
|
|
)
|
|
|
|
// names are the schedule names Register accepts.
|
|
var names = map[string]bool{
|
|
SuggestionsWeekly: true,
|
|
PlanDraft: true,
|
|
ShoppingChatAutosend: true,
|
|
DayOfReminder: true,
|
|
RotationCheck: true,
|
|
WeekRollover: true,
|
|
AIRunsPurge: true,
|
|
}
|
|
|
|
// CatchUpWindow is how long after its fire time a period that has not run
|
|
// still runs. A period found later than that is recorded as missed.
|
|
const CatchUpWindow = 24 * time.Hour
|
|
|
|
// maxWait bounds one sleep, so the scheduler checks the wall clock at least
|
|
// this often even if the host was suspended or its clock stepped.
|
|
const maxWait = time.Hour
|
|
|
|
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
|
|
// day, or on Weekday only when Weekly is set. Build one with Daily, Weekly
|
|
// or WeeklyForNextWeek.
|
|
type Rule struct {
|
|
Weekly bool
|
|
Weekday time.Weekday
|
|
Time string
|
|
// NextWeek keys a weekly period by the ISO week after the fire date,
|
|
// for work done ahead for the coming week.
|
|
NextWeek bool
|
|
}
|
|
|
|
// Daily fires every day at hhmm; its period is the local date.
|
|
func Daily(hhmm string) Rule {
|
|
return Rule{Time: hhmm}
|
|
}
|
|
|
|
// Weekly fires on day at hhmm; its period is the ISO week of the fire date.
|
|
func Weekly(day time.Weekday, hhmm string) Rule {
|
|
return Rule{Weekly: true, Weekday: day, Time: hhmm}
|
|
}
|
|
|
|
// WeeklyForNextWeek fires on day at hhmm; its period is the ISO week after
|
|
// the fire date's.
|
|
func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule {
|
|
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
|
|
}
|
|
|
|
// PeriodKey returns the period key of a fire on household-local date
|
|
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
|
|
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
|
|
// when NextWeek is set. It panics if fireDate is the zero LocalDate.
|
|
func (r Rule) PeriodKey(fireDate store.LocalDate) string {
|
|
if !r.Weekly {
|
|
return fireDate.String()
|
|
}
|
|
if r.NextWeek {
|
|
return household.PeriodKey(addDays(fireDate, 7))
|
|
}
|
|
return household.PeriodKey(fireDate)
|
|
}
|
|
|
|
// validate reports whether r can fire.
|
|
func (r Rule) validate() error {
|
|
if r.Weekly && (r.Weekday < time.Sunday || r.Weekday > time.Saturday) {
|
|
return fmt.Errorf("invalid weekday %d (want 0-6, Sunday = 0)", r.Weekday)
|
|
}
|
|
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
|
|
return errors.New("a daily rule has no weekday or next week")
|
|
}
|
|
// Any date checks the time's form.
|
|
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
|
|
return err
|
|
}
|
|
|
|
// Period is one period of a schedule, as given to its Run.
|
|
type Period struct {
|
|
// Key is the period key claimed in schedule_runs.
|
|
Key string
|
|
// FireAt is the instant the period fired, in the household zone.
|
|
FireAt time.Time
|
|
// Date is the household-local date of FireAt.
|
|
Date store.LocalDate
|
|
// Week is the ISO Monday of the week Key names; for a daily rule, of
|
|
// the week containing Date.
|
|
Week store.LocalDate
|
|
}
|
|
|
|
// Schedule is registered work that runs once per period.
|
|
type Schedule struct {
|
|
// Name is one of the schedule name constants.
|
|
Name string
|
|
// Rule returns when the schedule fires under settings. It is called
|
|
// again after every settings change.
|
|
Rule func(settings household.Settings) Rule
|
|
// Run does the period's work. It runs on the scheduler's goroutine,
|
|
// one schedule at a time, so it must return promptly: long work is
|
|
// enqueued as a job. ctx is cancelled when the scheduler stops. An
|
|
// error is logged; the period stays claimed and is not retried.
|
|
Run func(ctx context.Context, p Period) error
|
|
}
|
|
|
|
// Settings is the household settings source: *household.SettingsService.
|
|
type Settings interface {
|
|
Get(ctx context.Context) (household.Settings, error)
|
|
Subscribe(fn func(household.Settings)) (unsubscribe func())
|
|
}
|
|
|
|
// Scheduler runs the registered schedules.
|
|
type Scheduler struct {
|
|
st *store.Store
|
|
settings Settings
|
|
logger *slog.Logger
|
|
|
|
// now is the clock and after the timer; tests replace both.
|
|
now func() time.Time
|
|
after func(d time.Duration) <-chan time.Time
|
|
|
|
// wake holds one token when the settings changed since the loop last
|
|
// looked; latest is the settings to apply.
|
|
wake chan struct{}
|
|
latestMu sync.Mutex
|
|
latest household.Settings
|
|
|
|
mu sync.Mutex
|
|
schedules []Schedule
|
|
started bool
|
|
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// New returns a scheduler over st reading the household settings from
|
|
// settings.
|
|
func New(st *store.Store, settings Settings, logger *slog.Logger) (*Scheduler, error) {
|
|
if st == nil {
|
|
return nil, errors.New("scheduler: nil store")
|
|
}
|
|
if settings == nil {
|
|
return nil, errors.New("scheduler: nil settings")
|
|
}
|
|
if logger == nil {
|
|
return nil, errors.New("scheduler: nil logger")
|
|
}
|
|
return &Scheduler{
|
|
st: st,
|
|
settings: settings,
|
|
logger: logger,
|
|
now: time.Now,
|
|
after: time.After,
|
|
wake: make(chan struct{}, 1),
|
|
}, nil
|
|
}
|
|
|
|
// Register adds sch. It must be called before Start, once per name.
|
|
func (s *Scheduler) Register(sch Schedule) error {
|
|
if !names[sch.Name] {
|
|
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
|
|
}
|
|
if sch.Rule == nil {
|
|
return fmt.Errorf("scheduler: register %s: nil rule", sch.Name)
|
|
}
|
|
if sch.Run == nil {
|
|
return fmt.Errorf("scheduler: register %s: nil run", sch.Name)
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.started {
|
|
return fmt.Errorf("scheduler: register %s: already started", sch.Name)
|
|
}
|
|
for _, have := range s.schedules {
|
|
if have.Name == sch.Name {
|
|
return fmt.Errorf("scheduler: register %s: already registered", sch.Name)
|
|
}
|
|
}
|
|
s.schedules = append(s.schedules, sch)
|
|
return nil
|
|
}
|
|
|
|
// Start reads the settings and starts the scheduler, which first catches
|
|
// up on each schedule's most recent period and then fires schedules as
|
|
// 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
|
|
// for it.
|
|
func (s *Scheduler) Start(ctx context.Context) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.started {
|
|
return errors.New("scheduler: already started")
|
|
}
|
|
// Subscribing first means a change saved after the read below still
|
|
// wakes the loop.
|
|
unsubscribe := s.settings.Subscribe(s.changed)
|
|
settings, err := s.settings.Get(ctx)
|
|
if err != nil {
|
|
unsubscribe()
|
|
return fmt.Errorf("scheduler: %w", err)
|
|
}
|
|
p, err := compile(s.schedules, settings)
|
|
if err != nil {
|
|
unsubscribe()
|
|
return err
|
|
}
|
|
s.started = true
|
|
s.wg.Add(1)
|
|
go func() {
|
|
defer s.wg.Done()
|
|
defer unsubscribe()
|
|
s.loop(ctx, p)
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
// Wait blocks until the scheduler has stopped.
|
|
func (s *Scheduler) Wait() {
|
|
s.wg.Wait()
|
|
}
|
|
|
|
// changed is the settings subscriber. It runs on the updating goroutine, so
|
|
// it only records the settings and wakes the loop.
|
|
func (s *Scheduler) changed(settings household.Settings) {
|
|
s.latestMu.Lock()
|
|
s.latest = settings
|
|
s.latestMu.Unlock()
|
|
select {
|
|
case s.wake <- struct{}{}:
|
|
default: // the loop already has a token and reads latest then
|
|
}
|
|
}
|
|
|
|
// plan is the schedules compiled against one version of the settings.
|
|
type plan struct {
|
|
loc *time.Location
|
|
items []item
|
|
}
|
|
|
|
type item struct {
|
|
sch Schedule
|
|
rule Rule
|
|
}
|
|
|
|
// compile builds the plan for settings.
|
|
func compile(schedules []Schedule, settings household.Settings) (*plan, error) {
|
|
loc, err := household.LoadZone(settings.Timezone)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("scheduler: household zone: %w", err)
|
|
}
|
|
p := &plan{loc: loc, items: make([]item, len(schedules))}
|
|
for i, sch := range schedules {
|
|
rule := sch.Rule(settings)
|
|
if err := rule.validate(); err != nil {
|
|
return nil, fmt.Errorf("scheduler: %s: %w", sch.Name, err)
|
|
}
|
|
p.items[i] = item{sch: sch, rule: rule}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// loop fires schedules until ctx is cancelled. done is the instant up to
|
|
// which fire times have been handled; it starts at the zero time so the
|
|
// first pass catches up.
|
|
func (s *Scheduler) loop(ctx context.Context, p *plan) {
|
|
var done time.Time
|
|
for {
|
|
now := s.now()
|
|
if p != nil {
|
|
s.fire(ctx, p, done, now)
|
|
}
|
|
done = now
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
|
|
var timeout <-chan time.Time
|
|
if p != nil {
|
|
if next, ok := p.next(now); ok {
|
|
timeout = s.after(min(next.Sub(now), maxWait))
|
|
}
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-timeout:
|
|
case <-s.wake:
|
|
// Fire times up to the change follow the old settings.
|
|
now := s.now()
|
|
if p != nil {
|
|
s.fire(ctx, p, done, now)
|
|
}
|
|
done = now
|
|
s.latestMu.Lock()
|
|
settings := s.latest
|
|
s.latestMu.Unlock()
|
|
np, err := compile(s.schedules, settings)
|
|
if err != nil {
|
|
// Settings are validated before they are saved, so this
|
|
// is a bug; firing at a guessed time would be worse than
|
|
// not firing until the settings are fixed.
|
|
s.logger.ErrorContext(ctx, "scheduler: settings unusable; no schedule fires until they change", "error", err)
|
|
}
|
|
p = np
|
|
}
|
|
}
|
|
}
|
|
|
|
// fire handles, for every schedule, its most recent fire time at or before
|
|
// now if it is after done.
|
|
func (s *Scheduler) fire(ctx context.Context, p *plan, done, now time.Time) {
|
|
for _, it := range p.items {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
at, ok := fireBefore(it.rule, p.loc, now)
|
|
if !ok || !at.After(done) {
|
|
continue
|
|
}
|
|
s.handle(ctx, it, p.loc, at, now)
|
|
}
|
|
}
|
|
|
|
// handle claims the period of it fired at at and runs it, or records it as
|
|
// missed when at is more than CatchUpWindow before now.
|
|
func (s *Scheduler) handle(ctx context.Context, it item, loc *time.Location, at, now time.Time) {
|
|
date := household.Today(at, loc)
|
|
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
|
if it.rule.NextWeek {
|
|
period.Week = household.WeekStart(addDays(date, 7))
|
|
} else {
|
|
period.Week = household.WeekStart(date)
|
|
}
|
|
log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339))
|
|
|
|
claimed, err := s.claim(ctx, it.sch.Name, period.Key, now)
|
|
if err != nil {
|
|
log.ErrorContext(ctx, "scheduler: claim period failed; period not run", "error", err)
|
|
return
|
|
}
|
|
if !claimed {
|
|
return
|
|
}
|
|
if late := now.Sub(at); late > CatchUpWindow {
|
|
log.WarnContext(ctx, "scheduler: period missed; recorded without running", "late", late.String())
|
|
return
|
|
}
|
|
if err := it.sch.Run(ctx, period); err != nil {
|
|
log.ErrorContext(ctx, "scheduler: run failed", "error", err)
|
|
return
|
|
}
|
|
log.InfoContext(ctx, "scheduler: ran")
|
|
}
|
|
|
|
// claim inserts schedule_runs(name, key) and reports whether this call
|
|
// inserted it.
|
|
func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time) (bool, error) {
|
|
n, err := db.New(s.st.Writer).ClaimScheduleRun(ctx, db.ClaimScheduleRunParams{
|
|
ID: store.NewID(),
|
|
Schedule: name,
|
|
PeriodKey: key,
|
|
CreatedAt: store.Timestamp{Time: now.UTC().Truncate(time.Millisecond)}.String(),
|
|
})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return n == 1, nil
|
|
}
|
|
|
|
// next returns the earliest fire time of any schedule after now, or false
|
|
// when there is no schedule.
|
|
func (p *plan) next(now time.Time) (time.Time, bool) {
|
|
var next time.Time
|
|
for _, it := range p.items {
|
|
at, ok := fireAfter(it.rule, p.loc, now)
|
|
if ok && (next.IsZero() || at.Before(next)) {
|
|
next = at
|
|
}
|
|
}
|
|
return next, !next.IsZero()
|
|
}
|
|
|
|
// window is how many days either side of a date fireBefore and fireAfter
|
|
// search: more than a week, so a weekly rule always has a fire time on
|
|
// each side.
|
|
const window = 8
|
|
|
|
// fireBefore returns the latest fire time of r in zone loc at or before t.
|
|
func fireBefore(r Rule, loc *time.Location, t time.Time) (time.Time, bool) {
|
|
var best time.Time
|
|
for _, at := range fireTimes(r, loc, t) {
|
|
if !at.After(t) && (best.IsZero() || at.After(best)) {
|
|
best = at
|
|
}
|
|
}
|
|
return best, !best.IsZero()
|
|
}
|
|
|
|
// fireAfter returns the earliest fire time of r in zone loc after t.
|
|
func fireAfter(r Rule, loc *time.Location, t time.Time) (time.Time, bool) {
|
|
var best time.Time
|
|
for _, at := range fireTimes(r, loc, t) {
|
|
if at.After(t) && (best.IsZero() || at.Before(best)) {
|
|
best = at
|
|
}
|
|
}
|
|
return best, !best.IsZero()
|
|
}
|
|
|
|
// fireTimes returns r's fire times on the local dates within window days
|
|
// of t's. A rule that fails to resolve was validated at compile, so it
|
|
// cannot.
|
|
func fireTimes(r Rule, loc *time.Location, t time.Time) []time.Time {
|
|
today := household.Today(t, loc)
|
|
var out []time.Time
|
|
for i := -window; i <= window; i++ {
|
|
d := addDays(today, i)
|
|
if r.Weekly && weekday(d) != r.Weekday {
|
|
continue
|
|
}
|
|
at, err := household.LocalTime(loc, d, r.Time)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
|
|
}
|
|
out = append(out, at)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// addDays returns d plus n calendar days.
|
|
func addDays(d store.LocalDate, n int) store.LocalDate {
|
|
y, m, day := d.Date()
|
|
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
|
|
}
|
|
|
|
// weekday returns the day of the week of d.
|
|
func weekday(d store.LocalDate) time.Weekday {
|
|
y, m, day := d.Date()
|
|
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday()
|
|
}
|