635c33d381
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
140 lines
4.5 KiB
Go
140 lines
4.5 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func discardScheduler() *Scheduler { return New(log.New(io.Discard, "", 0)) }
|
|
|
|
func noopJob(context.Context) error { return nil }
|
|
|
|
// farFuture returns an HH:MM roughly n hours ahead in Budapest, so a registered daily job parks on a
|
|
// long timer (never fires during a sub-second test) yet is a valid, distinct schedule to switch to.
|
|
func farFuture(hoursAhead int) string {
|
|
return time.Now().In(getBudapestLocation()).Add(time.Duration(hoursAhead) * time.Hour).Format("15:04")
|
|
}
|
|
|
|
// Group C — UpdateDaily mutates the schedule and delivers the reschedule signal. Asserted on a
|
|
// NOT-started scheduler so no goroutine competes for the buffered signal (race-free).
|
|
func TestUpdateDaily_MutatesAndSignals(t *testing.T) {
|
|
s := discardScheduler()
|
|
s.Daily("db-dump", "02:30", noopJob)
|
|
|
|
if !s.UpdateDaily("db-dump", "22:00") {
|
|
t.Fatal("UpdateDaily returned false for a valid change")
|
|
}
|
|
if got := s.jobs[0].Schedule; got != "22:00" {
|
|
t.Errorf("Schedule = %q, want 22:00", got)
|
|
}
|
|
// The buffered reschedule signal must be present (no goroutine drained it).
|
|
select {
|
|
case <-s.jobs[0].resched:
|
|
default:
|
|
t.Error("no reschedule signal was sent by UpdateDaily")
|
|
}
|
|
}
|
|
|
|
// Group C — unknown or non-daily job name, and an invalid time, both return false without panicking
|
|
// and leave everything untouched.
|
|
func TestUpdateDaily_UnknownAndInvalid(t *testing.T) {
|
|
s := discardScheduler()
|
|
s.Daily("db-dump", "02:30", noopJob)
|
|
|
|
if s.UpdateDaily("does-not-exist", "03:00") {
|
|
t.Error("UpdateDaily on an unknown job returned true")
|
|
}
|
|
if s.UpdateDaily("db-dump", "25:99") {
|
|
t.Error("UpdateDaily with an invalid time returned true")
|
|
}
|
|
if got := s.jobs[0].Schedule; got != "02:30" {
|
|
t.Errorf("Schedule was mutated by a rejected update: %q", got)
|
|
}
|
|
}
|
|
|
|
// Group C — a running daily goroutine picks up the new time on the next pass: it CONSUMES the
|
|
// reschedule signal from the select and recomputes. Red-proof: remove the `case <-job.resched`
|
|
// from runDailyJob's select and this fails (the signal stays buffered — never consumed).
|
|
func TestUpdateDaily_GoroutineConsumesReschedule(t *testing.T) {
|
|
s := discardScheduler()
|
|
s.Daily("db-dump", farFuture(6), noopJob) // parked on a ~6h timer
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
s.Start(ctx)
|
|
|
|
if !s.UpdateDaily("db-dump", farFuture(8)) {
|
|
t.Fatal("UpdateDaily returned false")
|
|
}
|
|
time.Sleep(150 * time.Millisecond) // let the goroutine wake on resched and recompute
|
|
cancel()
|
|
s.Stop() // waits for the goroutine to exit — after this, reading resched is race-free
|
|
|
|
select {
|
|
case <-s.jobs[0].resched:
|
|
t.Fatal("reschedule signal still buffered — runDailyJob never consumed it (no resched case)")
|
|
default:
|
|
// 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()
|
|
})
|
|
}
|
|
}
|