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
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:
@@ -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.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -79,3 +80,60 @@ func TestUpdateDaily_GoroutineConsumesReschedule(t *testing.T) {
|
||||
// drained by the goroutine → immediacy works
|
||||
}
|
||||
}
|
||||
|
||||
// R-871 (v0.295.0): a daily timer that fires long after its wall-clock time (a host suspend: the timer's clock stops)
|
||||
// SKIPS the job; one that fires on time runs it.
|
||||
// COMPANION RED-PROOF: drop the `late > DailyLateLimit` branch → "a 6-hour-late fire ran the job".
|
||||
func TestDaily_LateFireIsSkipped(t *testing.T) {
|
||||
loc := getBudapestLocation()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
firedAt time.Time
|
||||
wantRun bool
|
||||
}{
|
||||
{"on time", time.Date(2026, 10, 5, 2, 30, 1, 0, loc), true},
|
||||
{"after a suspend", time.Date(2026, 10, 5, 8, 31, 0, 0, loc), false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := discardScheduler()
|
||||
var mu sync.Mutex
|
||||
now := time.Date(2026, 10, 5, 1, 0, 0, 0, loc)
|
||||
s.nowFn = func() time.Time { mu.Lock(); defer mu.Unlock(); return now }
|
||||
fired := make(chan time.Time, 1)
|
||||
armed := make(chan struct{}, 1)
|
||||
calls := 0
|
||||
s.afterFn = func(time.Duration) <-chan time.Time {
|
||||
mu.Lock()
|
||||
calls++
|
||||
first := calls == 1
|
||||
mu.Unlock()
|
||||
if !first {
|
||||
return make(chan time.Time) // never: park after the first fire
|
||||
}
|
||||
armed <- struct{}{} // the next run (02:30) is computed against 01:00
|
||||
return fired
|
||||
}
|
||||
ran := make(chan struct{}, 1)
|
||||
s.Daily("offbox-backup", "02:30", func(context.Context) error { ran <- struct{}{}; return nil })
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.Start(ctx)
|
||||
<-armed
|
||||
mu.Lock()
|
||||
now = tc.firedAt
|
||||
mu.Unlock()
|
||||
fired <- tc.firedAt
|
||||
select {
|
||||
case <-ran:
|
||||
if !tc.wantRun {
|
||||
t.Fatal("a 6-hour-late fire ran the job (the app-update leg would start at noon)")
|
||||
}
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
if tc.wantRun {
|
||||
t.Fatal("an on-time fire did not run the job")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
s.Stop()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user