0.1.38 gateway
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
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"})
|
||||
}
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user