package registry import ( "database/sql" "errors" "fmt" "os" "path/filepath" "reflect" "testing" ) func openTimersReg(t *testing.T) *Registry { t.Helper() r, err := Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close() }) return r } func TestTimersUpsertPreservesRunnerFields(t *testing.T) { r := openTimersReg(t) if err := r.UpsertTimer(Timer{Host: "h", Name: "sync", Kind: "schedule", Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: 100, CreatedAt: 1}); err != nil { t.Fatal(err) } if _, err := r.MarkTimerFired("h", "sync", timerRegistration(t, r, "h", "sync"), 100, "204", 300, 0, 21700, 21700); err != nil { t.Fatal(err) } // Converge sees the same declaration again: spec/path unchanged, next_at // passed along as "whatever it is now" (0 here means "don't touch"). if err := r.UpsertTimer(Timer{Host: "h", Name: "sync", Kind: "schedule", Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: 0, CreatedAt: 99}); err != nil { t.Fatal(err) } got, err := r.GetTimer("h", "sync") if err != nil { t.Fatal(err) } if got.LastAt != 100 || got.LastStatus != "204" || got.LastMs != 300 || got.NextAt != 21700 || got.CreatedAt != 1 { t.Fatalf("runner fields clobbered: %+v", got) } // A changed spec DOES move next_at. if err := r.UpsertTimer(Timer{Host: "h", Name: "sync", Kind: "schedule", Spec: "every 12h0m0s", Path: "/jobs/sync", NextAt: 43200}); err != nil { t.Fatal(err) } got, _ = r.GetTimer("h", "sync") if got.NextAt != 43200 || got.Spec != "every 12h0m0s" || got.LastStatus != "204" { t.Fatalf("after spec change: %+v", got) } if err := r.UpsertTimer(Timer{Host: "h", Name: "sync", Kind: "once", Spec: "at 5", Path: "/p", NextAt: 5}); !errors.Is(err, ErrTimerKind) { t.Fatalf("kind change = %v, want ErrTimerKind", err) } } func TestTimersDueOrderingAndFireNow(t *testing.T) { r := openTimersReg(t) for _, tm := range []Timer{ {Host: "b", Name: "late", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 500}, {Host: "a", Name: "soon", Kind: "once", Spec: "at 10", Path: "/p", NextAt: 10}, {Host: "a", Name: "done", Kind: "once", Spec: "at 1", Path: "/p", NextAt: 0}, {Host: "c", Name: "future", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 9999}, } { if err := r.UpsertTimer(tm); err != nil { t.Fatal(err) } } due, err := r.DueTimers(600, 10, nil) if err != nil { t.Fatal(err) } if len(due) != 2 || due[0].Name != "soon" || due[1].Name != "late" { t.Fatalf("due = %+v", due) } if due, _ := r.DueTimers(600, 1, nil); len(due) != 1 { t.Fatal("limit ignored") } if err := r.FireTimerNow("c", "future", 600); err != nil { t.Fatal(err) } if due, _ := r.DueTimers(600, 10, []string{}); len(due) != 3 { // an empty exclude set is no filter t.Fatalf("after FireTimerNow: %d due", len(due)) } if err := r.FireTimerNow("nope", "x", 600); err != nil { t.Fatalf("FireTimerNow on absent = %v, want nil", err) } } func TestTimersPruneAndExcept(t *testing.T) { r := openTimersReg(t) for _, tm := range []Timer{ {Host: "alive", Name: "s1", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 1}, {Host: "alive", Name: "s2", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 1}, {Host: "alive", Name: "o1", Kind: "once", Spec: "at 1", Path: "/p", NextAt: 0, LastAt: 10}, {Host: "alive", Name: "o2", Kind: "once", Spec: "at 1", Path: "/p", NextAt: 0, LastAt: 500}, {Host: "gone", Name: "s1", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 1}, } { if err := r.UpsertTimer(tm); err != nil { t.Fatal(err) } } // o1 has last_at=10 via insert — MarkTimerFired is the real path but insert suffices to seed. n, err := r.PruneTimers(map[string]bool{"alive": true}, 100) if err != nil || n != 2 { // gone/s1 and alive/o1 t.Fatalf("prune = %d %v", n, err) } n, err = r.DeleteTimersExcept("alive", "schedule", map[string]bool{"s2": true}) if err != nil || n != 1 { t.Fatalf("except = %d %v", n, err) } left, _ := r.TimersFor("alive") if len(left) != 2 || left[0].Name != "o2" || left[1].Name != "s2" { t.Fatalf("left = %+v", left) } if c, _ := r.CountTimers("alive", "once"); c != 1 { t.Fatalf("count = %d", c) } if _, err := r.GetTimer("gone", "s1"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("gone = %v", err) } if err := r.DeleteTimer("alive", "s2"); err != nil { t.Fatal(err) } if err := r.DeleteTimer("alive", "s2"); err != nil { t.Fatal("delete not idempotent") } } // due_at is the occurrence a row is delivering (X-Carlos-Schedule-At). It // rides next_at on an upsert that moves it, follows the runner's word on // MarkTimerFired, and IS "now" after FireTimerNow — and a registry written // before the column existed gains it on open, with 0 on the old rows. func TestTimersDueAtFollowsTheOccurrence(t *testing.T) { r := openTimersReg(t) if err := r.UpsertTimer(Timer{Host: "h", Name: "s", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 3600, DueAt: 3600}); err != nil { t.Fatal(err) } // A retry: next moves to the retry instant, due stays the occurrence. if _, err := r.MarkTimerFired("h", "s", timerRegistration(t, r, "h", "s"), 3600, "503", 1, 1, 3660, 3600); err != nil { t.Fatal(err) } got, _ := r.GetTimer("h", "s") if got.NextAt != 3660 || got.DueAt != 3600 { t.Fatalf("after retry: %+v", got) } // An advance: both move together. if _, err := r.MarkTimerFired("h", "s", timerRegistration(t, r, "h", "s"), 3660, "204", 1, 0, 7200, 7200); err != nil { t.Fatal(err) } if got, _ = r.GetTimer("h", "s"); got.NextAt != 7200 || got.DueAt != 7200 { t.Fatalf("after advance: %+v", got) } // An unchanged upsert (NextAt 0) leaves both alone; a spec change moves both. if err := r.UpsertTimer(Timer{Host: "h", Name: "s", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p"}); err != nil { t.Fatal(err) } if got, _ = r.GetTimer("h", "s"); got.NextAt != 7200 || got.DueAt != 7200 { t.Fatalf("after unchanged upsert: %+v", got) } if err := r.UpsertTimer(Timer{Host: "h", Name: "s", Kind: "schedule", Spec: "every 2h0m0s", Path: "/p", NextAt: 9000, DueAt: 9000}); err != nil { t.Fatal(err) } if got, _ = r.GetTimer("h", "s"); got.NextAt != 9000 || got.DueAt != 9000 { t.Fatalf("after spec change: %+v", got) } // `run`: now is its own occurrence. if err := r.FireTimerNow("h", "s", 8000); err != nil { t.Fatal(err) } if got, _ = r.GetTimer("h", "s"); got.NextAt != 8000 || got.DueAt != 8000 { t.Fatalf("after FireTimerNow: %+v", got) } } func TestTimersPreDueAtRegistryGainsTheColumn(t *testing.T) { path := filepath.Join(t.TempDir(), "registry.db") reg, err := Open(path) if err != nil { t.Fatal(err) } if err := reg.Close(); err != nil { t.Fatal(err) } old, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } if _, err := old.Exec(`ALTER TABLE timers DROP COLUMN due_at`); err != nil { t.Fatalf("simulate a pre-due_at registry: %v", err) } if _, err := old.Exec(`INSERT INTO timers (host, name, kind, spec, path, next_at, created_at) VALUES ('h', 'legacy', 'schedule', 'every 1h0m0s', '/p', 3600, 1)`); err != nil { t.Fatal(err) } if err := old.Close(); err != nil { t.Fatal(err) } // Open twice: the ALTER must be idempotent, not a one-shot. for i := 0; i < 2; i++ { reg, err = Open(path) if err != nil { t.Fatalf("open %d of a pre-due_at registry: %v", i+1, err) } got, err := reg.GetTimer("h", "legacy") if err != nil { t.Fatal(err) } if got.NextAt != 3600 || got.DueAt != 0 { t.Fatalf("migrated row = %+v, want next_at kept and due_at 0", got) } if err := reg.Close(); err != nil { t.Fatal(err) } } } // The batch is capped, and the runner skips hosts whose delivery is still // running: without the exclude filter a busy host with a batch's worth of due // rows takes every slot of every batch and another host's due timer is never // SEEN, let alone fired. The filter belongs in the query, before the LIMIT. func TestDueTimersExcludesBusyHostsBeforeTheLimit(t *testing.T) { r := openTimersReg(t) for i := 0; i < 8; i++ { if err := r.UpsertTimer(Timer{Host: "busy", Name: "j" + string(rune('a'+i)), Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 100}); err != nil { t.Fatal(err) } } // Due LATER than every one of the busy host's rows, so ordering alone // never rescues it. if err := r.UpsertTimer(Timer{Host: "quiet", Name: "j", Kind: "schedule", Spec: "every 1h0m0s", Path: "/p", NextAt: 200}); err != nil { t.Fatal(err) } due, err := r.DueTimers(600, 4, nil) if err != nil { t.Fatal(err) } for _, tm := range due { if tm.Host == "quiet" { t.Fatalf("fixture: the quiet host must be crowded out without the filter, got %+v", due) } } due, err = r.DueTimers(600, 4, []string{"busy"}) if err != nil { t.Fatal(err) } if len(due) != 1 || due[0].Host != "quiet" { t.Fatalf("excluding the busy host = %+v, want the quiet host's one row", due) } // Every host excluded is an empty batch, not a filter that silently // stops applying. if due, _ := r.DueTimers(600, 4, []string{"busy", "quiet"}); len(due) != 0 { t.Fatalf("all hosts excluded = %+v, want none", due) } } func TestEarliestTimersPerHost(t *testing.T) { r := openTestRegistry(t) rows := []Timer{ // Two rows for one host: the earliest wins. {Host: "a.example", Name: "late", Kind: "schedule", Spec: "every 1h", Path: "/x", NextAt: 5000, DueAt: 5000}, {Host: "a.example", Name: "soon", Kind: "schedule", Spec: "every 5m", Path: "/y", NextAt: 1200, DueAt: 1200}, // A second host is reported independently. {Host: "b.example", Name: "only", Kind: "schedule", Spec: "every 5m", Path: "/z", NextAt: 3000, DueAt: 3000}, // next_at == 0 means "not armed" and must never appear. {Host: "c.example", Name: "parked", Kind: "schedule", Spec: "every 5m", Path: "/p", NextAt: 0, DueAt: 0}, } for _, tm := range rows { if err := r.UpsertTimer(tm); err != nil { t.Fatalf("UpsertTimer %s/%s: %v", tm.Host, tm.Name, err) } } got, err := r.EarliestTimers() if err != nil { t.Fatalf("EarliestTimers: %v", err) } want := map[string]int64{"a.example": 1200, "b.example": 3000} if !reflect.DeepEqual(got, want) { t.Errorf("EarliestTimers = %v, want %v", got, want) } } func TestEarliestTimersEmpty(t *testing.T) { r := openTestRegistry(t) got, err := r.EarliestTimers() if err != nil { t.Fatalf("EarliestTimers: %v", err) } if len(got) != 0 { t.Errorf("EarliestTimers on an empty table = %v, want empty", got) } } func timerRegistration(t *testing.T, r *Registry, host, name string) string { t.Helper() row, err := r.GetTimer(host, name) if err != nil { t.Fatal(err) } return row.Registration } func TestTimerRegistrationProtectsMutations(t *testing.T) { for _, mutation := range []string{"identical", "spec", "path", "manual", "recreate"} { t.Run(mutation, func(t *testing.T) { r := openTimersReg(t) tm := Timer{Host: "h", Name: "s", Kind: "schedule", Spec: "every 1h0m0s", Path: "/jobs/sync", NextAt: 100, DueAt: 100, Attempts: 4, LastAt: 80, LastStatus: "503", LastMs: 1} if err := r.UpsertTimer(tm); err != nil { t.Fatal(err) } before, err := r.GetTimer(tm.Host, tm.Name) if err != nil { t.Fatal(err) } switch mutation { case "spec": tm.Spec, tm.NextAt, tm.DueAt = "every 2h0m0s", 0, 0 case "path": tm.Path, tm.NextAt, tm.DueAt = "/jobs/other", 0, 0 case "recreate": if err := r.DeleteTimer(tm.Host, tm.Name); err != nil { t.Fatal(err) } tm.Attempts = 0 } if mutation == "manual" { err = r.FireTimerNow(tm.Host, tm.Name, 100) } else { err = r.UpsertTimer(tm) } if err != nil { t.Fatal(err) } replacement, err := r.GetTimer(tm.Host, tm.Name) if err != nil { t.Fatal(err) } if replacement.Registration == "" || replacement.Registration == before.Registration || replacement.Attempts != 0 { t.Fatalf("new registration: %+v", replacement) } if _, err := r.MarkTimerFired(tm.Host, tm.Name, before.Registration, 100, "204", 3, 0, 3700, 3700); err != nil { t.Fatal(err) } if err := r.DeleteTimerRegistration(tm.Host, tm.Name, before.Registration); err != nil { t.Fatal(err) } after, err := r.GetTimer(tm.Host, tm.Name) if err != nil { t.Fatal(err) } if *after != *replacement { t.Fatalf("stale mutation changed registration: %+v -> %+v", replacement, after) } if err := r.DeleteTimerRegistration(tm.Host, tm.Name, after.Registration); err != nil { t.Fatal(err) } if _, err := r.GetTimer(tm.Host, tm.Name); !errors.Is(err, os.ErrNotExist) { t.Fatalf("current deletion: %v", err) } }) } } func TestTimerRegistrationLegacyMigration(t *testing.T) { path := filepath.Join(t.TempDir(), "registry.db") r, err := Open(path) if err != nil { t.Fatal(err) } if _, err := r.sql.Exec(`ALTER TABLE timers DROP COLUMN registration`); err != nil { t.Fatal(err) } if _, err := r.sql.Exec(`INSERT INTO timers (host,name,kind,spec,path,next_at,created_at) VALUES ('h','s','once','at 100','/jobs/sync',100,1)`); err != nil { t.Fatal(err) } if err := r.Close(); err != nil { t.Fatal(err) } for i := 0; i < 2; i++ { r, err = Open(path) if err != nil { t.Fatal(err) } row, err := r.GetTimer("h", "s") if err != nil { t.Fatal(err) } if row.Registration != "" { t.Fatalf("legacy registration: %q", row.Registration) } if _, err := r.MarkTimerFired("h", "s", row.Registration, 100, "503", 3, 1, 160, 100); err != nil { t.Fatal(err) } if err := r.Close(); err != nil { t.Fatal(err) } } r, err = Open(path) if err != nil { t.Fatal(err) } defer r.Close() row, err := r.GetTimer("h", "s") if err != nil { t.Fatal(err) } if row.NextAt != 160 || row.DueAt != 100 || row.Attempts != 1 { t.Fatalf("legacy completion: %+v", row) } if err := r.FireTimerNow("h", "s", 100); err != nil { t.Fatal(err) } if _, err := r.MarkTimerFired("h", "s", "", 100, "204", 3, 0, 0, 0); err != nil { t.Fatal(err) } row, err = r.GetTimer("h", "s") if err != nil { t.Fatal(err) } if row.Registration == "" || row.NextAt != 100 || row.Attempts != 0 { t.Fatalf("rearmed legacy: %+v", row) } } func TestUnchangedTimerUpsertConcurrentWithCompletion(t *testing.T) { r := openTimersReg(t) tm := Timer{Host: "h", Name: "s", Kind: "schedule", Spec: "every 1h0m0s", Path: "/jobs/sync", NextAt: 100, DueAt: 100} if err := r.UpsertTimer(tm); err != nil { t.Fatal(err) } registration := timerRegistration(t, r, tm.Host, tm.Name) tm.NextAt, tm.DueAt = 0, 0 for i := int64(1); i <= 100; i++ { start := make(chan struct{}) errs := make(chan error, 9) for n := 0; n < 8; n++ { go func() { <-start; errs <- r.UpsertTimer(tm) }() } go func() { <-start _, err := r.MarkTimerFired(tm.Host, tm.Name, registration, i, "503", 1, 1, 100+i, 100) errs <- err }() close(start) for n := 0; n < 9; n++ { if err := <-errs; err != nil { t.Fatal(err) } } row, err := r.GetTimer(tm.Host, tm.Name) if err != nil { t.Fatal(err) } if row.NextAt != 100+i || row.DueAt != 100 || row.LastAt != i || row.Attempts != 1 || row.Registration != registration { t.Fatalf("completion overwritten at iteration %d: %+v", i, row) } } } func TestReplaceTimersSwapsAHostsRowsExactly(t *testing.T) { r := openTimersReg(t) for _, tm := range []Timer{ {Host: "a.test", Name: "old", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1}, {Host: "b.test", Name: "other", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1}, } { if err := r.UpsertTimer(tm); err != nil { t.Fatal(err) } } rows := []Timer{ {Host: "a.test", Name: "nightly", Kind: "schedule", Spec: "0 3 * * *", Path: "/n", NextAt: 5000, LastAt: 4000, LastStatus: "ok", LastMs: 12, Attempts: 2, CreatedAt: 10, DueAt: 4900, Registration: "reg-nightly"}, {Host: "a.test", Name: "once-1", Kind: "once", Spec: "2026-10-06T00:00:00Z", Path: "/o", NextAt: 6000, CreatedAt: 11, DueAt: 6000, Registration: "reg-once"}, } if err := r.ReplaceTimers("a.test", rows); err != nil { t.Fatal(err) } got, err := r.TimersFor("a.test") if err != nil { t.Fatal(err) } if len(got) != 2 { t.Fatalf("a.test has %d timers, want 2: %+v", len(got), got) } n := got[0] // ordered by name: nightly, once-1 if n.Name != "nightly" || n.NextAt != 5000 || n.LastAt != 4000 || n.LastStatus != "ok" || n.LastMs != 12 || n.Attempts != 2 || n.CreatedAt != 10 || n.DueAt != 4900 || n.Registration != "reg-nightly" { t.Fatalf("bookkeeping not preserved: %+v", n) } if other, _ := r.TimersFor("b.test"); len(other) != 1 { t.Fatalf("b.test has %d timers, want 1: another host's rows were touched", len(other)) } } func TestReplaceTimersIsAllOrNothing(t *testing.T) { r := openTimersReg(t) if err := r.UpsertTimer(Timer{Host: "a.test", Name: "keep", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1}); err != nil { t.Fatal(err) } // Two rows with one name: the delete and the first insert succeed, the // second insert breaks the primary key. Without a transaction the host // would be left with "dup" and without "keep". dup := []Timer{ {Host: "a.test", Name: "dup", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 1, CreatedAt: 1}, {Host: "a.test", Name: "dup", Kind: "schedule", Spec: "@every 2m", Path: "/t", NextAt: 1, CreatedAt: 1}, } if err := r.ReplaceTimers("a.test", dup); err == nil { t.Fatal("two rows with one name were accepted") } got, _ := r.TimersFor("a.test") if len(got) != 1 || got[0].Name != "keep" { t.Fatalf("a failed replace changed the rows: %+v", got) } } func TestReplaceTimersRefusesARowForAnotherHost(t *testing.T) { r := openTimersReg(t) rows := []Timer{{Host: "elsewhere.test", Name: "y", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 1, CreatedAt: 1}} if err := r.ReplaceTimers("a.test", rows); err == nil { t.Fatal("a row for another host was accepted") } } func TestReplaceTimersWithNoRowsClearsTheHost(t *testing.T) { r := openTimersReg(t) if err := r.UpsertTimer(Timer{Host: "a.test", Name: "gone", Kind: "schedule", Spec: "@every 1m", Path: "/t", NextAt: 100, CreatedAt: 1}); err != nil { t.Fatal(err) } if err := r.ReplaceTimers("a.test", nil); err != nil { t.Fatal(err) } if got, _ := r.TimersFor("a.test"); len(got) != 0 { t.Fatalf("rows left: %+v", got) } } // Moves Task 6 fix round 1: DueUnheldTimers leaves out every row whose // route is held (moves spec §5.2) BEFORE the limit, so a held host's rows // cannot fill the batch and hide another host's due timer. DueTimers itself // is unchanged: with the hold wiring off the runner still asks it. func TestDueUnheldTimersExcludesHeldHostsBeforeTheLimit(t *testing.T) { r := openTimersReg(t) for _, h := range []string{"held.test", "free.test"} { if err := r.Upsert(Route{Host: h, Kind: "instance", Socket: "/s"}); err != nil { t.Fatal(err) } } if err := r.SetHold("held.test", "moving", holdOpA); err != nil { t.Fatal(err) } for i, name := range []string{"t0", "t1", "t2"} { if err := r.UpsertTimer(Timer{Host: "held.test", Name: name, Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: int64(10 + i)}); err != nil { t.Fatal(err) } } // Due after every held row; and a row whose host has no route at all, // which neither query drops (the converger prunes those). if err := r.UpsertTimer(Timer{Host: "free.test", Name: "t", Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: 50}); err != nil { t.Fatal(err) } if err := r.UpsertTimer(Timer{Host: "gone.test", Name: "t", Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: 60}); err != nil { t.Fatal(err) } due, err := r.DueTimers(100, 2, nil) if err != nil { t.Fatal(err) } if len(due) != 2 || due[0].Host != "held.test" || due[1].Host != "held.test" { t.Fatalf("DueTimers = %+v, want the two earliest held rows (main's answer)", due) } un, err := r.DueUnheldTimers(100, 2, nil) if err != nil { t.Fatal(err) } if len(un) != 2 || un[0].Host != "free.test" || un[1].Host != "gone.test" { t.Fatalf("DueUnheldTimers = %+v, want free.test then gone.test", un) } // The busy-host exclusion still applies on top. un, err = r.DueUnheldTimers(100, 2, []string{"free.test"}) if err != nil { t.Fatal(err) } if len(un) != 1 || un[0].Host != "gone.test" { t.Fatalf("DueUnheldTimers excluding free.test = %+v", un) } // Once the hold lifts the rows are due again, in order. if ok, _ := r.ReleaseHold("held.test", holdOpA); !ok { t.Fatal("release") } un, _ = r.DueUnheldTimers(100, 2, nil) if len(un) != 2 || un[0].Host != "held.test" { t.Fatalf("after the release DueUnheldTimers = %+v", un) } } // Final review F5: both due queries leave out every row of a gated route // (an uncommitted import's) before the limit. The runner skips such a row // without advancing it, so 64 of them, due first, would otherwise fill the // runner's whole batch on every tick and hide another host's due timer for // ever. A row whose host has no route is still returned. func TestDueTimersExcludeGatedRoutesBeforeTheLimit(t *testing.T) { r := openTimersReg(t) const batch = 64 for i := range 4 { host := fmt.Sprintf("gated%d.test", i) if err := r.Upsert(Route{Host: host, Kind: "instance", Socket: "/s", ImportGate: ImportGateClosed}); err != nil { t.Fatal(err) } for n := range batch / 4 { if err := r.UpsertTimer(Timer{Host: host, Name: fmt.Sprintf("t%d", n), Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: int64(10 + n)}); err != nil { t.Fatal(err) } } } if err := r.Upsert(Route{Host: "free.test", Kind: "instance", Socket: "/s"}); err != nil { t.Fatal(err) } if err := r.UpsertTimer(Timer{Host: "free.test", Name: "t", Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: 90}); err != nil { t.Fatal(err) } if err := r.UpsertTimer(Timer{Host: "gone.test", Name: "t", Kind: "schedule", Spec: "every 1h0m0s", Path: "/j", NextAt: 95}); err != nil { t.Fatal(err) } for name, due := range map[string]func(int64, int, []string) ([]Timer, error){ "DueTimers": r.DueTimers, "DueUnheldTimers": r.DueUnheldTimers, } { got, err := due(100, batch, nil) if err != nil { t.Fatal(err) } var hosts []string for _, tm := range got { hosts = append(hosts, tm.Host) } if len(hosts) != 2 || hosts[0] != "free.test" || hosts[1] != "gone.test" { t.Fatalf("%s hosts = %q, want free.test then gone.test", name, hosts) } } }