2026-08-14 09:40:03 +12:00
|
|
|
package scheduler
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
|
|
|
|
"sync"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func quietScheduler() *Scheduler {
|
|
|
|
|
return New(nil, slog.New(slog.NewTextHandler(io.Discard, nil)), nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The registry's guarantees are the ones worth pinning: a task cannot be registered
|
|
|
|
|
// twice, a run cannot overlap itself, and a panicking job cannot take the gateway down.
|
|
|
|
|
|
|
|
|
|
func TestDuplicateTaskIDsPanic(t *testing.T) {
|
|
|
|
|
// Two tasks sharing an id would share a history, an enabled switch and a Run Now
|
|
|
|
|
// button, and the console would have no way to say which it was describing.
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "one", Name: "One", Run: noop})
|
|
|
|
|
defer func() {
|
|
|
|
|
if recover() == nil {
|
|
|
|
|
t.Fatal("registering a duplicate id should panic at start-up, not be tolerated")
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
sched.Register(Task{ID: "one", Name: "One again", Run: noop})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestATaskNeedsAnIDAndAFunction(t *testing.T) {
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
defer func() {
|
|
|
|
|
if recover() == nil {
|
|
|
|
|
t.Fatal("a task with no function should be refused at registration")
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
sched.Register(Task{ID: "broken"})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 07:34:23 +12:00
|
|
|
func TestQuietTimePausesManualTasks(t *testing.T) {
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
ran := false
|
|
|
|
|
sched.Register(Task{ID: "quiet", Name: "Quiet", Run: func(context.Context) (string, error) {
|
|
|
|
|
ran = true
|
|
|
|
|
return "", nil
|
|
|
|
|
}})
|
|
|
|
|
sched.SetPaused(func() bool { return true })
|
|
|
|
|
if err := sched.RunNow(context.Background(), "quiet"); err == nil {
|
|
|
|
|
t.Fatal("manual task started during quiet time")
|
|
|
|
|
}
|
|
|
|
|
if ran {
|
|
|
|
|
t.Fatal("quiet-time task function ran")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-14 09:40:03 +12:00
|
|
|
func TestAPanickingTaskBecomesAFailedRun(t *testing.T) {
|
|
|
|
|
// A background job is the one place a panic takes the whole process down for a reason
|
|
|
|
|
// nobody is watching for. One housekeeping job with a nil map must not be able to stop
|
|
|
|
|
// the gateway serving television.
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "panics", Name: "Panics", Run: func(context.Context) (string, error) {
|
|
|
|
|
panic("nil map")
|
|
|
|
|
}})
|
|
|
|
|
if err := sched.RunNow(context.Background(), "panics"); err != nil {
|
|
|
|
|
t.Fatalf("RunNow: %v", err)
|
|
|
|
|
}
|
|
|
|
|
status := waitForRun(t, sched, "panics")
|
|
|
|
|
if status.LastRun.Status != "failed" {
|
|
|
|
|
t.Fatalf("expected a failed run, got %q", status.LastRun.Status)
|
|
|
|
|
}
|
|
|
|
|
if status.LastRun.Error == "" {
|
|
|
|
|
t.Fatal("the recovered panic should be recorded as the failure")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunNowRefusesToStackASecondRun(t *testing.T) {
|
|
|
|
|
// The button is pressable while a job is running, and an overnight job started twice
|
|
|
|
|
// would double whatever it does.
|
|
|
|
|
release := make(chan struct{})
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "slow", Name: "Slow", Run: func(context.Context) (string, error) {
|
|
|
|
|
<-release
|
|
|
|
|
return "", nil
|
|
|
|
|
}})
|
|
|
|
|
if err := sched.RunNow(context.Background(), "slow"); err != nil {
|
|
|
|
|
t.Fatalf("first run: %v", err)
|
|
|
|
|
}
|
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
|
|
|
for time.Now().Before(deadline) && !running(sched, "slow") {
|
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
if err := sched.RunNow(context.Background(), "slow"); err == nil {
|
|
|
|
|
t.Fatal("a second RunNow while the first is in flight should be refused")
|
|
|
|
|
}
|
|
|
|
|
close(release)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRunNowRejectsAnUnknownTask(t *testing.T) {
|
|
|
|
|
if err := quietScheduler().RunNow(context.Background(), "nothing"); err == nil {
|
|
|
|
|
t.Fatal("an unknown task id should be an error, not a silent no-op")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestAFailingTaskRecordsItsError(t *testing.T) {
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "fails", Name: "Fails", Run: func(context.Context) (string, error) {
|
|
|
|
|
return "", errors.New("postgres did not answer")
|
|
|
|
|
}})
|
|
|
|
|
_ = sched.RunNow(context.Background(), "fails")
|
|
|
|
|
status := waitForRun(t, sched, "fails")
|
|
|
|
|
if status.LastRun.Error != "postgres did not answer" {
|
|
|
|
|
t.Fatalf("unexpected error %q", status.LastRun.Error)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetIntervalZeroRestoresTheDeclaredCadence(t *testing.T) {
|
|
|
|
|
// Zero is "as the code declares", so a task whose schedule changes in a later release
|
|
|
|
|
// takes effect for every operator who never overrode it.
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "daily", Name: "Daily", Interval: 24 * time.Hour, Run: noop})
|
|
|
|
|
if err := sched.SetInterval(context.Background(), "daily", 2*time.Hour); err != nil {
|
|
|
|
|
t.Fatalf("SetInterval: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := statusOf(t, sched, "daily").Interval; got != int64((2 * time.Hour).Seconds()) {
|
|
|
|
|
t.Fatalf("override not applied, got %d", got)
|
|
|
|
|
}
|
|
|
|
|
if err := sched.SetInterval(context.Background(), "daily", 0); err != nil {
|
|
|
|
|
t.Fatalf("SetInterval: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if got := statusOf(t, sched, "daily").Interval; got != int64((24 * time.Hour).Seconds()) {
|
|
|
|
|
t.Fatalf("expected the declared interval back, got %d", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestIntervalsAreFloored(t *testing.T) {
|
|
|
|
|
// The scheduler only wakes every thirty seconds, so a one-second interval is a promise
|
|
|
|
|
// it cannot keep — and a job set to run every second by a slipped decimal point is one
|
|
|
|
|
// that would sit permanently due.
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "daily", Name: "Daily", Interval: 24 * time.Hour, Run: noop})
|
|
|
|
|
_ = sched.SetInterval(context.Background(), "daily", time.Second)
|
|
|
|
|
if got := statusOf(t, sched, "daily").Interval; got != 60 {
|
|
|
|
|
t.Fatalf("expected a one-minute floor, got %d", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestADisabledTaskAdvertisesNoNextRun(t *testing.T) {
|
|
|
|
|
// The console prints "next run" from this field; a disabled task showing a time would
|
|
|
|
|
// read as one that is going to run anyway.
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "daily", Name: "Daily", Interval: time.Hour, Run: noop})
|
|
|
|
|
_ = sched.SetEnabled(context.Background(), "daily", false)
|
|
|
|
|
if status := statusOf(t, sched, "daily"); status.NextRun != nil {
|
|
|
|
|
t.Fatalf("a disabled task should advertise no next run, got %v", status.NextRun)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSnapshotKeepsRegistrationOrder(t *testing.T) {
|
|
|
|
|
// The registry is written as a readable list, and the console lays it out in that
|
|
|
|
|
// order; a map iteration would reshuffle the page on every poll.
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
for _, id := range []string{"a", "b", "c", "d"} {
|
|
|
|
|
sched.Register(Task{ID: id, Name: id, Run: noop})
|
|
|
|
|
}
|
|
|
|
|
for attempt := 0; attempt < 5; attempt++ {
|
|
|
|
|
snapshot := sched.Snapshot()
|
|
|
|
|
for i, id := range []string{"a", "b", "c", "d"} {
|
|
|
|
|
if snapshot[i].ID != id {
|
|
|
|
|
t.Fatalf("order changed: %v", snapshot)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGroupsAreDistinctAndSorted(t *testing.T) {
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "a", Group: "System", Run: noop})
|
|
|
|
|
sched.Register(Task{ID: "b", Group: "Housekeeping", Run: noop})
|
|
|
|
|
sched.Register(Task{ID: "c", Group: "System", Run: noop})
|
|
|
|
|
sched.Register(Task{ID: "d", Run: noop}) // ungrouped
|
|
|
|
|
groups := sched.Groups()
|
|
|
|
|
if len(groups) != 2 || groups[0] != "Housekeeping" || groups[1] != "System" {
|
|
|
|
|
t.Fatalf("unexpected groups %v", groups)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestNilSchedulerIsSafe(t *testing.T) {
|
|
|
|
|
// Every unit test in the api package builds a Server with no scheduler, and the admin
|
|
|
|
|
// handler calls straight through to it.
|
|
|
|
|
var sched *Scheduler
|
|
|
|
|
if len(sched.Snapshot()) != 0 {
|
|
|
|
|
t.Fatal("a nil scheduler should answer with an empty registry")
|
|
|
|
|
}
|
|
|
|
|
sched.Register(Task{ID: "a", Run: noop})
|
|
|
|
|
sched.Start(context.Background())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func noop(context.Context) (string, error) { return "", nil }
|
|
|
|
|
|
|
|
|
|
func running(sched *Scheduler, id string) bool {
|
|
|
|
|
for _, status := range sched.Snapshot() {
|
|
|
|
|
if status.ID == id {
|
|
|
|
|
return status.Running
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func statusOf(t *testing.T, sched *Scheduler, id string) Status {
|
|
|
|
|
t.Helper()
|
|
|
|
|
for _, status := range sched.Snapshot() {
|
|
|
|
|
if status.ID == id {
|
|
|
|
|
return status
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
t.Fatalf("no task %q", id)
|
|
|
|
|
return Status{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func waitForRun(t *testing.T, sched *Scheduler, id string) Status {
|
|
|
|
|
t.Helper()
|
|
|
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
|
|
|
for time.Now().Before(deadline) {
|
|
|
|
|
status := statusOf(t, sched, id)
|
|
|
|
|
if status.LastRun != nil && !status.Running {
|
|
|
|
|
return status
|
|
|
|
|
}
|
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
|
}
|
|
|
|
|
t.Fatalf("task %q did not finish", id)
|
|
|
|
|
return Status{}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var _ sync.Locker = (*sync.Mutex)(nil)
|
2026-08-15 22:26:17 +12:00
|
|
|
|
|
|
|
|
// The console needs both cadences to draw its control honestly: the one in force and the
|
|
|
|
|
// one the code declares. Reporting only the effective interval made "every ten minutes
|
|
|
|
|
// because that is the default" and "every ten minutes because somebody chose it" identical
|
|
|
|
|
// on the wire, so nothing could offer a way back to the default or mark a task as no longer
|
|
|
|
|
// running as shipped.
|
|
|
|
|
func TestSnapshotReportsTheDeclaredCadenceBesideTheEffectiveOne(t *testing.T) {
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "credits", Name: "Credits", Interval: 10 * time.Minute, Run: noop})
|
|
|
|
|
|
|
|
|
|
before := sched.Snapshot()[0]
|
|
|
|
|
if before.Interval != 600 || before.DefaultInterval != 600 {
|
|
|
|
|
t.Fatalf("unoverridden task: interval %d, default %d, want 600 and 600",
|
|
|
|
|
before.Interval, before.DefaultInterval)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := sched.SetInterval(context.Background(), "credits", time.Hour); err != nil {
|
|
|
|
|
t.Fatalf("SetInterval: %v", err)
|
|
|
|
|
}
|
|
|
|
|
after := sched.Snapshot()[0]
|
|
|
|
|
if after.Interval != 3600 {
|
|
|
|
|
t.Fatalf("effective interval %d, want 3600", after.Interval)
|
|
|
|
|
}
|
|
|
|
|
// The declared cadence must survive the override, or the way back is lost.
|
|
|
|
|
if after.DefaultInterval != 600 {
|
|
|
|
|
t.Fatalf("declared cadence %d, want 600 — an override must not overwrite it",
|
|
|
|
|
after.DefaultInterval)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A task that declares no cadence at all runs only when somebody presses the button, and
|
|
|
|
|
// the console has to be able to say so rather than printing "every 0 seconds".
|
|
|
|
|
func TestATaskWithNoDeclaredCadenceReportsZeroForBoth(t *testing.T) {
|
|
|
|
|
sched := quietScheduler()
|
|
|
|
|
sched.Register(Task{ID: "manual", Name: "Manual", Run: noop})
|
|
|
|
|
|
|
|
|
|
status := sched.Snapshot()[0]
|
|
|
|
|
if status.Interval != 0 || status.DefaultInterval != 0 {
|
|
|
|
|
t.Fatalf("interval %d, default %d, want 0 and 0", status.Interval, status.DefaultInterval)
|
|
|
|
|
}
|
|
|
|
|
}
|