Fence stale timer delivery and advertise safe rearming
5 files changed,
+221
−10
internal/registry/timers.go+1 −3internal/schedule/control.go+12 −1internal/schedule/control_test.go+63 −0internal/schedule/runner.go+17 −3internal/schedule/runner_test.go+128 −3
CI failed — run details
diff --git a/internal/registry/timers.go b/internal/registry/timers.go| index 4ea3281c5..cec61318c 100644 |
| --- a/internal/registry/timers.go |
| +++ b/internal/registry/timers.go |
| @@ -42,9 +42,7 @@ func scanTimer(row interface{ Scan(...any) error }) (Timer, error) { |
| return t, err |
| } |
| -// UpsertTimer inserts t, or updates an existing row's spec/path — moving |
| -// next_at (and due_at with it) only when spec or path changed or t.NextAt |
| -// is non-zero — and never touches the runner's columns on update. |
| +// UpsertTimer inserts t, or updates an existing row's spec/path. A changed spec/path or non-zero NextAt starts a new registration and resets attempts; unchanged declarations preserve delivery state. |
| func (r *Registry) UpsertTimer(t Timer) error { |
| const rearm = `(timers.spec != excluded.spec OR timers.path != excluded.path OR excluded.next_at != 0)` |
| res, err := r.sql.Exec(`INSERT INTO timers (`+timerCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,lower(hex(randomblob(16)))) |
diff --git a/internal/schedule/control.go b/internal/schedule/control.go| index 14e663f5f..acd30bd2f 100644 |
| --- a/internal/schedule/control.go |
| +++ b/internal/schedule/control.go |
| @@ -56,6 +56,13 @@ func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (stri |
| } |
| } |
| + mux.HandleFunc("GET /schedule-capabilities", authed(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Content-Type", "application/json") |
| + json.NewEncoder(w).Encode(struct { |
| + RegistrationFence bool `json:"registration_fence"` |
| + }{RegistrationFence: true}) |
| + })) |
| + |
| mux.HandleFunc("PUT /schedule/{name}", authed(func(w http.ResponseWriter, r *http.Request) { |
| name := r.PathValue("name") |
| if !ValidName(name) { |
| @@ -112,7 +119,11 @@ func Mount(mux *http.ServeMux, host string, store TimerStore, token func() (stri |
| } |
| } |
| sp := Spec{Kind: KindOnce, At: atT.UTC()} |
| - t := registry.Timer{Host: host, Name: name, Kind: KindOnce, Spec: sp.String(), Path: body.Path, NextAt: atT.Unix(), DueAt: atT.Unix(), CreatedAt: now().Unix()} |
| + next := atT.Unix() |
| + if next <= 0 { |
| + next = max(int64(1), now().Unix()) |
| + } |
| + t := registry.Timer{Host: host, Name: name, Kind: KindOnce, Spec: sp.String(), Path: body.Path, NextAt: next, DueAt: atT.Unix(), CreatedAt: now().Unix()} |
| if err := store.UpsertTimer(t); err != nil { |
| http.Error(w, "registry error", http.StatusInternalServerError) |
| return |
diff --git a/internal/schedule/control_test.go b/internal/schedule/control_test.go| index 23909e324..d58667c2b 100644 |
| --- a/internal/schedule/control_test.go |
| +++ b/internal/schedule/control_test.go |
| @@ -193,3 +193,66 @@ func TestControlCapsOncePerHost(t *testing.T) { |
| } |
| func itoa(n int64) string { return strconv.FormatInt(n, 10) } |
| + |
| +func TestControlScheduleCapabilities(t *testing.T) { |
| + _, mux := controlFixture(t) |
| + for _, auth := range []string{"", "Bearer wrong", "Basic tok"} { |
| + rec := do(mux, "GET", "/schedule-capabilities", auth, "") |
| + if rec.Code != http.StatusUnauthorized || strings.Contains(rec.Body.String(), "registration_fence") { |
| + t.Fatalf("unauthorized capability response: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + } |
| + rec := do(mux, "GET", "/schedule-capabilities", "Bearer tok", "") |
| + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "application/json" { |
| + t.Fatalf("capability response: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + var got map[string]bool |
| + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 1 || !got["registration_fence"] { |
| + t.Fatalf("capabilities: %v", got) |
| + } |
| + old := http.NewServeMux() |
| + old.HandleFunc("GET /schedule", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) |
| + rec = do(old, "GET", "/schedule-capabilities", "Bearer tok", "") |
| + if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "registration_fence") { |
| + t.Fatalf("old capability response: %d %s", rec.Code, rec.Body.String()) |
| + } |
| +} |
| + |
| +func TestControlNonpositiveOccurrenceArmsTimer(t *testing.T) { |
| + for _, requested := range []int64{0, -1, -62135596800} { |
| + for _, current := range []int64{1787490420, 0, -1} { |
| + t.Run(strconv.FormatInt(requested, 10)+"/"+strconv.FormatInt(current, 10), func(t *testing.T) { |
| + reg, _ := controlFixture(t) |
| + mux := http.NewServeMux() |
| + Mount(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, func() time.Time { return time.Unix(current, 0) }) |
| + body := `{"at":"` + time.Unix(requested, 0).UTC().Format(time.RFC3339) + `","path":"/jobs/remind"}` |
| + rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", body) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("PUT: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + row, err := reg.GetTimer("a.bab.oncarlos.com", "remind") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.NextAt != max(int64(1), current) || row.DueAt != requested || row.Spec != "at "+strconv.FormatInt(requested, 10) { |
| + t.Fatalf("normalized timer: %+v", row) |
| + } |
| + first := row.Registration |
| + rec = do(mux, "PUT", "/schedule/remind", "Bearer tok", body) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("repeat PUT: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + row, err = reg.GetTimer("a.bab.oncarlos.com", "remind") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.Registration == first { |
| + t.Fatal("repeat PUT did not rearm") |
| + } |
| + }) |
| + } |
| + } |
| +} |
diff --git a/internal/schedule/runner.go b/internal/schedule/runner.go| index 9aa716af6..cfc6dfd46 100644 |
| --- a/internal/schedule/runner.go |
| +++ b/internal/schedule/runner.go |
| @@ -308,7 +308,16 @@ func (r *Runner) slots() chan struct{} { |
| // sleeping. Nothing in the agent calls it. |
| func (r *Runner) wait() { r.inflight.Wait() } |
| +func (r *Runner) timerStillDue(t registry.Timer) bool { |
| + current, err := r.Reg.GetTimer(t.Host, t.Name) |
| + return err == nil && current.Registration == t.Registration && current.NextAt == t.NextAt && current.NextAt > 0 && current.NextAt <= r.now().Unix() |
| +} |
| + |
| func (r *Runner) fire(ctx context.Context, rt registry.Route, t registry.Timer) { |
| + if !r.timerStillDue(t) { |
| + return |
| + } |
| + |
| sp, err := ParseSpec(t.Spec) |
| if err != nil { |
| log.Printf("schedule: %s/%s: bad spec %q: %v — dropping the row", rt.Host, t.Name, t.Spec, err) |
| @@ -328,6 +337,9 @@ func (r *Runner) fire(ctx context.Context, rt registry.Route, t registry.Timer) |
| r.record(rt, t, sp, start, Outcome{Status: "bad path: " + err.Error()}, 0) |
| return |
| } |
| + if !r.timerStillDue(t) { |
| + return |
| + } |
| dctx, cancel := context.WithTimeout(ctx, r.timeout()) |
| req = req.WithContext(dctx) |
| status, derr := r.Deliver.Deliver(dctx, rt, req) |
| @@ -421,13 +433,15 @@ func classify(status int, err error, timeout time.Duration) Outcome { |
| // Classify is the exported policy for the host package's tests. |
| func Classify(status int, err error) Outcome { return classify(status, err, DeliveryTimeout) } |
| -// dueOf is the occurrence a row is delivering: DueAt, or NextAt for a row |
| -// written before due_at existed (whose NextAt, at attempt 0, IS the |
| -// occurrence — and once pinned by a retry it stays the occurrence). |
| func dueOf(t registry.Timer) int64 { |
| if t.DueAt != 0 { |
| return t.DueAt |
| } |
| + if t.Kind == KindOnce { |
| + if sp, err := ParseSpec(t.Spec); err == nil && sp.Kind == KindOnce && sp.At.Unix() == 0 { |
| + return 0 |
| + } |
| + } |
| return t.NextAt |
| } |
diff --git a/internal/schedule/runner_test.go b/internal/schedule/runner_test.go| index 7ae3b4300..3602c996f 100644 |
| --- a/internal/schedule/runner_test.go |
| +++ b/internal/schedule/runner_test.go |
| @@ -351,7 +351,8 @@ func TestOneSlowHostDoesNotBlockTheOthersOrRefire(t *testing.T) { |
| d := &blockingDeliverer{fakeDeliverer: fakeDeliverer{status: 204}, slow: "slow.bab.oncarlos.com", gate: make(chan struct{})} |
| reg, r := runnerFixture(t, d) |
| now := at("2026-08-23T13:07:00Z") |
| - r.Now = func() time.Time { return now } |
| + clock := &testClock{t: now} |
| + r.Now = clock.now |
| hosts := []string{"slow.bab.oncarlos.com", "b.bab.oncarlos.com", "c.bab.oncarlos.com", "d.bab.oncarlos.com", "e.bab.oncarlos.com", "f.bab.oncarlos.com", "g.bab.oncarlos.com"} |
| for i, h := range hosts { |
| if err := reg.Upsert(registry.Route{Host: h, Kind: "instance", Account: "bab", App: "app", Status: "ready"}); err != nil { |
| @@ -397,7 +398,7 @@ func TestOneSlowHostDoesNotBlockTheOthersOrRefire(t *testing.T) { |
| } |
| // The parked host's row is still due; a second tick must skip it. |
| - now = now.Add(time.Minute) |
| + clock.advance(time.Minute) |
| r.Tick(context.Background()) |
| close(d.gate) |
| r.wait() |
| @@ -408,7 +409,7 @@ func TestOneSlowHostDoesNotBlockTheOthersOrRefire(t *testing.T) { |
| t.Fatalf("slow row after release: %+v", tm) |
| } |
| // And once released, it is fair game again. |
| - now = at("2026-08-23T14:00:01Z") |
| + clock.advance(at("2026-08-23T14:00:01Z").Sub(clock.now())) |
| r.tick() |
| if n := d.firedHosts()[d.slow]; n != 2 { |
| t.Fatalf("released host delivered %d times, want 2", n) |
| @@ -851,3 +852,127 @@ func TestInvalidTimerSnapshotCannotDeleteReplacement(t *testing.T) { |
| t.Fatalf("invalid snapshot changed replacement: got %+v, want %+v", after, replacement) |
| } |
| } |
| + |
| +func TestBatchSkipsTimerChangedByEarlierDelivery(t *testing.T) { |
| + for _, mutation := range []string{"cancel", "rearm"} { |
| + t.Run(mutation, func(t *testing.T) { |
| + reg, runner := runnerFixture(t, nil) |
| + now := at("2026-08-23T13:07:00Z") |
| + runner.Now = func() time.Time { return now } |
| + const host = "a.bab.oncarlos.com" |
| + for _, name := range []string{"first", "later"} { |
| + if err := reg.UpsertTimer(registry.Timer{Host: host, Name: name, Kind: KindOnce, Spec: "at 100", Path: "/jobs/" + name, NextAt: 100}); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + calls := 0 |
| + runner.Deliver = timerMutationDeliverer{status: 204, mutate: func() { |
| + calls++ |
| + if mutation == "cancel" { |
| + if err := reg.DeleteTimer(host, "later"); err != nil { |
| + t.Error(err) |
| + } |
| + } else { |
| + if err := reg.FireTimerNow(host, "later", now.Add(time.Hour).Unix()); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + }} |
| + runner.tick() |
| + if calls != 1 { |
| + t.Fatalf("delivered %d timers, want first only", calls) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestConcurrentTicksSkipSnapshotChangedDuringTokenLookup(t *testing.T) { |
| + for _, mutation := range []string{"cancel", "rearm", "retry", "retry-still-due", "done"} { |
| + t.Run(mutation, func(t *testing.T) { |
| + deliver := &fakeDeliverer{status: 204} |
| + reg, runner := runnerFixture(t, deliver) |
| + now := at("2026-08-23T13:07:00Z") |
| + runner.Now = func() time.Time { return now } |
| + tm := registry.Timer{Host: "a.bab.oncarlos.com", Name: "remind", Kind: KindOnce, Spec: "at 100", Path: "/jobs/remind", NextAt: 100} |
| + if err := reg.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.GetTimer(tm.Host, tm.Name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + entered, release := make(chan struct{}), make(chan struct{}) |
| + var tokenOnce sync.Once |
| + runner.Token = func(string) (string, error) { tokenOnce.Do(func() { close(entered) }); <-release; return "tok", nil } |
| + runner.Tick(context.Background()) |
| + <-entered |
| + switch mutation { |
| + case "cancel": |
| + err = reg.DeleteTimer(tm.Host, tm.Name) |
| + case "rearm": |
| + err = reg.FireTimerNow(tm.Host, tm.Name, now.Add(time.Hour).Unix()) |
| + case "retry": |
| + _, err = reg.MarkTimerFired(tm.Host, tm.Name, before.Registration, now.Unix(), "503", 0, 1, now.Add(time.Minute).Unix(), 100) |
| + case "retry-still-due": |
| + _, err = reg.MarkTimerFired(tm.Host, tm.Name, before.Registration, now.Unix(), "503", 0, 1, now.Unix()-1, 100) |
| + case "done": |
| + _, err = reg.MarkTimerFired(tm.Host, tm.Name, before.Registration, now.Unix(), "204", 0, 0, 0, 0) |
| + } |
| + if err != nil { |
| + close(release) |
| + runner.wait() |
| + t.Fatal(err) |
| + } |
| + runner.Tick(context.Background()) |
| + close(release) |
| + runner.wait() |
| + runner.fire(context.Background(), registry.Route{Host: tm.Host}, *before) |
| + if len(deliver.calls) != 0 { |
| + t.Fatalf("delivered stale snapshot %d times", len(deliver.calls)) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestNonpositiveOccurrenceDeliveredAndRetriedWithOriginalTimestamp(t *testing.T) { |
| + for _, requested := range []int64{0, -1, -62135596800} { |
| + t.Run(fmt.Sprint(requested), func(t *testing.T) { |
| + deliver := &fakeDeliverer{status: 503} |
| + reg, runner := runnerFixture(t, deliver) |
| + now := at("2026-08-23T13:07:00Z") |
| + runner.Now = func() time.Time { return now } |
| + mux := http.NewServeMux() |
| + Mount(mux, "a.bab.oncarlos.com", reg, func() (string, error) { return "tok", nil }, runner.Now) |
| + rec := do(mux, "PUT", "/schedule/remind", "Bearer tok", `{"at":"`+time.Unix(requested, 0).UTC().Format(time.RFC3339)+`","path":"/jobs/remind"}`) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("PUT: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + runner.tick() |
| + row, err := reg.GetTimer("a.bab.oncarlos.com", "remind") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.Attempts != 1 || row.NextAt != now.Add(time.Minute).Unix() || row.DueAt != requested { |
| + t.Fatalf("retry: %+v", row) |
| + } |
| + now = now.Add(time.Minute) |
| + deliver.status = 204 |
| + runner.tick() |
| + if len(deliver.calls) != 2 { |
| + t.Fatalf("deliveries: %d", len(deliver.calls)) |
| + } |
| + for _, req := range deliver.calls { |
| + if got := req.Header.Get("X-Carlos-Schedule-At"); got != fmt.Sprint(requested) { |
| + t.Fatalf("occurrence: %q, want %d", got, requested) |
| + } |
| + } |
| + row, err = reg.GetTimer("a.bab.oncarlos.com", "remind") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if row.NextAt != 0 || row.LastStatus != "204" { |
| + t.Fatalf("completed: %+v", row) |
| + } |
| + }) |
| + } |
| +} |