feat(go): scheduled bank sync (Go only)
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>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user