The Go server now runs sync -> infer -> flush-cache by itself at the times in SYNC_SCHEDULE (comma-separated HH:MM, default 04:00, "off" disables), read in SYNC_TZ (default Europe/Prague). Nothing runs at startup; an invalid value stops the server with an error. - internal/scheduler: time-of-day parsing, Next, and the run loop - banksync.Runner: one lock shared by /sync-bank and the scheduler so runs never overlap, plus the status of the last run - footer shows the last sync since start and the next scheduled run - binary embeds time/tzdata because the Alpine image has no zone data Deliberately not implemented in the legacy Python app, which is being phased out; recorded in CLAUDE.md under "Deliberately Go-only features". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
96 lines
2.6 KiB
Go
96 lines
2.6 KiB
Go
// Package scheduler runs a job at fixed times of day.
|
|
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// TimeOfDay is a wall-clock time without a date.
|
|
type TimeOfDay struct {
|
|
Hour, Minute int
|
|
}
|
|
|
|
func (t TimeOfDay) String() string { return fmt.Sprintf("%02d:%02d", t.Hour, t.Minute) }
|
|
|
|
// ParseTimes parses a comma-separated list of HH:MM times, e.g. "04:00" or
|
|
// "06:00,18:00". "off" yields an empty schedule. The result is sorted and
|
|
// de-duplicated.
|
|
func ParseTimes(s string) ([]TimeOfDay, error) {
|
|
s = strings.TrimSpace(s)
|
|
if strings.EqualFold(s, "off") {
|
|
return nil, nil
|
|
}
|
|
seen := map[TimeOfDay]bool{}
|
|
var times []TimeOfDay
|
|
for _, part := range strings.Split(s, ",") {
|
|
part = strings.TrimSpace(part)
|
|
parsed, err := time.Parse("15:04", part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid time %q: want HH:MM, a comma-separated list of them, or \"off\"", part)
|
|
}
|
|
t := TimeOfDay{Hour: parsed.Hour(), Minute: parsed.Minute()}
|
|
if !seen[t] {
|
|
seen[t] = true
|
|
times = append(times, t)
|
|
}
|
|
}
|
|
sort.Slice(times, func(i, j int) bool {
|
|
if times[i].Hour != times[j].Hour {
|
|
return times[i].Hour < times[j].Hour
|
|
}
|
|
return times[i].Minute < times[j].Minute
|
|
})
|
|
return times, nil
|
|
}
|
|
|
|
// Next returns the earliest scheduled instant strictly after now, with the
|
|
// times read in loc. It returns the zero time when times is empty.
|
|
func Next(now time.Time, times []TimeOfDay, loc *time.Location) time.Time {
|
|
local := now.In(loc)
|
|
var best time.Time
|
|
for dayOffset := 0; dayOffset <= 1; dayOffset++ {
|
|
for _, t := range times {
|
|
c := time.Date(local.Year(), local.Month(), local.Day()+dayOffset, t.Hour, t.Minute, 0, 0, loc)
|
|
if c.After(now) && (best.IsZero() || c.Before(best)) {
|
|
best = c
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// pollInterval bounds each wait so the loop follows the wall clock across
|
|
// clock adjustments and host suspend instead of relying on one long timer.
|
|
const pollInterval = time.Minute
|
|
|
|
// Run calls job at every scheduled time until ctx is cancelled. Nothing runs
|
|
// at startup. Runs do not overlap: the next time is computed after job returns.
|
|
func Run(ctx context.Context, times []TimeOfDay, loc *time.Location, logger *slog.Logger, job func(context.Context)) {
|
|
for {
|
|
next := Next(time.Now(), times, loc)
|
|
if next.IsZero() {
|
|
return
|
|
}
|
|
logger.Info("next scheduled run", "at", next.Format(time.RFC3339))
|
|
for {
|
|
wait := time.Until(next)
|
|
if wait <= 0 {
|
|
break
|
|
}
|
|
timer := time.NewTimer(min(wait, pollInterval))
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
job(ctx)
|
|
}
|
|
}
|