v0.295.0: a box that was off at its backup time catches up once (R-871, decision 109); the missed-backup banner (decision 110); a late daily timer after a host suspend is skipped
gates / gates (push) Successful in 31s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 09:27:16 +02:00
parent e730a629fd
commit 635c33d381
24 changed files with 2074 additions and 18 deletions
+47 -7
View File
@@ -53,6 +53,23 @@ type Scheduler struct {
cancel context.CancelFunc
wg sync.WaitGroup
started bool
// nowFn / afterFn are seams for the late-fire guard's tests (nil = time.Now / time.After).
nowFn func() time.Time
afterFn func(d time.Duration) <-chan time.Time
}
// DailyLateLimit (R-871, v0.295.0): a daily job whose timer fires more than this after its scheduled wall-clock
// time is SKIPPED, not run late. Go's timers run on CLOCK_MONOTONIC, which does not count a host suspend, so on a
// laptop that slept through the night the 02:30 timer fires hours late — and would start the app-update leg at
// noon while the household uses the box. A skipped backup leg is made up by the catch-up (internal/nightchain),
// which the resume watch triggers; the update leg waits for a real night. Pinned by TestDaily_LateFireIsSkipped.
const DailyLateLimit = 60 * time.Minute
func (s *Scheduler) now() time.Time {
if s.nowFn != nil {
return s.nowFn()
}
return time.Now()
}
// SetDebug enables or disables debug logging.
@@ -289,8 +306,8 @@ func (s *Scheduler) runDailyJob(job *Job) {
schedule := job.Schedule
s.mu.Unlock()
nextRun := nextDailyRun(schedule)
waitDuration := time.Until(nextRun)
nextRun := nextDailyRunAt(schedule, s.now())
waitDuration := nextRun.Sub(s.now())
if waitDuration < 0 {
waitDuration = 0
@@ -298,18 +315,36 @@ func (s *Scheduler) runDailyJob(job *Job) {
s.dbg("daily job %s: next run at %s (waiting %s)", job.Name, nextRun.Format("2006-01-02 15:04:05 MST"), waitDuration.Round(time.Second))
timer := time.NewTimer(waitDuration)
var fire <-chan time.Time
var timer *time.Timer
if s.afterFn != nil {
fire = s.afterFn(waitDuration)
} else {
timer = time.NewTimer(waitDuration)
fire = timer.C
}
stop := func() {
if timer != nil {
timer.Stop()
}
}
select {
case <-s.ctx.Done():
timer.Stop()
stop()
s.dbg("daily job %s: context cancelled, stopping", job.Name)
return
case <-job.resched:
// Runtime reschedule: abandon the current timer and recompute against the new Schedule.
timer.Stop()
stop()
s.dbg("daily job %s: rescheduled — recomputing next run", job.Name)
continue
case <-timer.C:
case <-fire:
// The wall clock, not the timer, decides: a host suspend stops the timer's clock (R-871).
if late := s.now().Round(0).Sub(nextRun); late > DailyLateLimit {
s.logger.Printf("[INFO] [scheduler] Daily job %s SKIPPED: its timer fired %s after %s (the host was suspended) — not run late; a missed backup leg is made up by the catch-up, everything else waits for its next time",
job.Name, late.Round(time.Minute), nextRun.Format("2006-01-02 15:04 MST"))
continue
}
s.executeJob(job, false)
}
}
@@ -387,6 +422,11 @@ func NextDailyRun(timeStr string) time.Time {
// nextDailyRun calculates the next occurrence of the daily schedule in Europe/Budapest timezone.
func nextDailyRun(timeStr string) time.Time {
return nextDailyRunAt(timeStr, time.Now())
}
// nextDailyRunAt is nextDailyRun for a given "now" (the scheduler's clock seam).
func nextDailyRunAt(timeStr string, at time.Time) time.Time {
hour, min, err := parseDailyTime(timeStr)
if err != nil {
// Should not happen — validated at registration
@@ -395,7 +435,7 @@ func nextDailyRun(timeStr string) time.Time {
loc := getBudapestLocation()
now := time.Now().In(loc)
now := at.In(loc)
next := time.Date(now.Year(), now.Month(), now.Day(), hour, min, 0, 0, loc)
// If the time has already passed today, schedule for tomorrow.