Preserve timer replacements across in-flight delivery completion
Co-Authored-By: Codex <noreply@openai.com>
7 files changed,
+520
−30
internal/host/schedule_rearm_wake_test.go+119 −0internal/registry/registry.go+3 −0internal/registry/timers.go+34 −21internal/registry/timers_test.go+169 −3internal/schedule/converge_test.go+12 −3internal/schedule/runner.go+4 −3internal/schedule/runner_test.go+179 −0
CI passed — run details
diff --git a/internal/host/schedule_rearm_wake_test.go b/internal/host/schedule_rearm_wake_test.go| new file mode 100644 |
| index 000000000..560861d41 |
| --- /dev/null |
| +++ b/internal/host/schedule_rearm_wake_test.go |
| @@ -0,0 +1,119 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "net/http" |
| + "strconv" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/schedule" |
| +) |
| + |
| +func TestSleepingInstanceRearmsItsNextDeliveryBeforeReturning(t *testing.T) { |
| + const host = "rearm.bab.oncarlos.com" |
| + const idle = 200 * time.Millisecond |
| + entered := make(chan struct{}, 1) |
| + release := make(chan struct{}) |
| + events := make(chan string, 4) |
| + var calls atomic.Int32 |
| + var reg *registry.Registry |
| + now := time.Now().UTC() |
| + var clock atomic.Int64 |
| + clock.Store(now.UnixNano()) |
| + next := now.Add(time.Hour).Unix() |
| + var firstError atomic.Pointer[error] |
| + var instReg *registry.Registry |
| + instReg, inst, svc, dd := wakeFixture(t, host, idle, func(w http.ResponseWriter, req *http.Request) { |
| + if req.Method != http.MethodPost || req.URL.Path != "/jobs/reminders" || !strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ") { |
| + w.WriteHeader(http.StatusForbidden) |
| + return |
| + } |
| + if calls.Add(1) == 1 { |
| + if err := reg.UpsertTimer(registry.Timer{Host: host, Name: "next-due", Kind: schedule.KindOnce, |
| + Spec: "at " + strconv.FormatInt(next, 10), Path: "/jobs/reminders", NextAt: next, DueAt: next}); err != nil { |
| + firstError.Store(&err) |
| + } |
| + entered <- struct{}{} |
| + <-release |
| + } |
| + w.WriteHeader(http.StatusNoContent) |
| + }) |
| + reg = instReg |
| + t.Cleanup(func() { |
| + select { |
| + case <-release: |
| + default: |
| + close(release) |
| + } |
| + }) |
| + runner := &schedule.Runner{Reg: reg, Deliver: &activatorDeliverer{svc: svc}, Token: scheduleToken(dd, svc, reg), |
| + Now: func() time.Time { return time.Unix(0, clock.Load()) }, Timeout: 10 * time.Second, |
| + Event: func(_ registry.Route, _, msg string) { events <- msg }, |
| + } |
| + due := now.Unix() - 1 |
| + if err := reg.UpsertTimer(registry.Timer{Host: host, Name: "next-due", Kind: schedule.KindOnce, |
| + Spec: fmt.Sprintf("at %d", due), Path: "/jobs/reminders", NextAt: due, DueAt: due}); err != nil { |
| + t.Fatal(err) |
| + } |
| + runner.Tick(context.Background()) |
| + select { |
| + case <-entered: |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("sleeping instance never received its due delivery") |
| + } |
| + if err := firstError.Load(); err != nil { |
| + t.Fatal(*err) |
| + } |
| + time.Sleep(3 * idle) |
| + if !inst.running() || !svc.Owns(host) { |
| + t.Fatal("instance slept while its scheduled handler was still running") |
| + } |
| + close(release) |
| + select { |
| + case <-events: |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("first delivery did not finish") |
| + } |
| + timer, err := reg.GetTimer(host, "next-due") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if timer.NextAt != next { |
| + t.Fatalf("completion erased the replacement delivery: next_at=%d, want %d", timer.NextAt, next) |
| + } |
| + deadline := time.Now().Add(10 * time.Second) |
| + for inst.running() || svc.Owns(host) { |
| + if time.Now().After(deadline) { |
| + t.Fatal("instance did not hibernate between deliveries") |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + clock.Store(time.Unix(next, 0).UnixNano()) |
| + for calls.Load() < 2 { |
| + runner.Tick(context.Background()) |
| + if time.Now().After(deadline) { |
| + t.Fatal("replacement delivery never woke the instance") |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + select { |
| + case <-events: |
| + case <-time.After(10 * time.Second): |
| + t.Fatal("second delivery did not finish") |
| + } |
| + timer, err = reg.GetTimer(host, "next-due") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if timer.NextAt != 0 || timer.LastStatus != "204" { |
| + t.Fatalf("second delivery not completed: %+v", timer) |
| + } |
| + if inst.startCount() != 2 { |
| + t.Fatalf("got %d wakes, want 2", inst.startCount()) |
| + } |
| +} |
diff --git a/internal/registry/registry.go b/internal/registry/registry.go| index 7dfc050cd..2d1f935aa 100644 |
| --- a/internal/registry/registry.go |
| +++ b/internal/registry/registry.go |
| @@ -550,6 +550,9 @@ CREATE INDEX IF NOT EXISTS timers_due ON timers (next_at) WHERE next_at > 0;`); |
| if _, err := r.sql.Exec(`ALTER TABLE timers ADD COLUMN due_at INTEGER NOT NULL DEFAULT 0`); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| } |
| + if _, err := r.sql.Exec(`ALTER TABLE timers ADD COLUMN registration TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| + return err |
| + } |
| if err := r.migrateAccounts(); err != nil { |
| return err |
| } |
diff --git a/internal/registry/timers.go b/internal/registry/timers.go| index 12cdde238..4ea3281c5 100644 |
| --- a/internal/registry/timers.go |
| +++ b/internal/registry/timers.go |
| @@ -28,16 +28,17 @@ type Timer struct { |
| // when the row advances to a new occurrence (MarkTimerFired with a new |
| // due, FireTimerNow, or an upsert that moves next_at). Zero on rows |
| // written before the column existed; readers fall back to NextAt. |
| - DueAt int64 |
| + DueAt int64 |
| + Registration string `json:"-"` |
| } |
| var ErrTimerKind = errors.New("registry: timer exists with a different kind") |
| -const timerCols = `host, name, kind, spec, path, next_at, last_at, last_status, last_ms, attempts, created_at, due_at` |
| +const timerCols = `host, name, kind, spec, path, next_at, last_at, last_status, last_ms, attempts, created_at, due_at, registration` |
| func scanTimer(row interface{ Scan(...any) error }) (Timer, error) { |
| var t Timer |
| - err := row.Scan(&t.Host, &t.Name, &t.Kind, &t.Spec, &t.Path, &t.NextAt, &t.LastAt, &t.LastStatus, &t.LastMs, &t.Attempts, &t.CreatedAt, &t.DueAt) |
| + err := row.Scan(&t.Host, &t.Name, &t.Kind, &t.Spec, &t.Path, &t.NextAt, &t.LastAt, &t.LastStatus, &t.LastMs, &t.Attempts, &t.CreatedAt, &t.DueAt, &t.Registration) |
| return t, err |
| } |
| @@ -45,24 +46,27 @@ func scanTimer(row interface{ Scan(...any) error }) (Timer, error) { |
| // 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. |
| func (r *Registry) UpsertTimer(t Timer) error { |
| - existing, err := r.GetTimer(t.Host, t.Name) |
| - switch { |
| - case errors.Is(err, os.ErrNotExist): |
| - _, err := r.sql.Exec(`INSERT INTO timers (`+timerCols+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, |
| - t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt, t.DueAt) |
| + 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)))) |
| +ON CONFLICT(host, name) DO UPDATE SET |
| + spec=excluded.spec, path=excluded.path, |
| + next_at=CASE WHEN `+rearm+` THEN excluded.next_at ELSE timers.next_at END, |
| + due_at=CASE WHEN `+rearm+` THEN excluded.due_at ELSE timers.due_at END, |
| + attempts=CASE WHEN `+rearm+` THEN 0 ELSE timers.attempts END, |
| + registration=CASE WHEN `+rearm+` THEN excluded.registration ELSE timers.registration END |
| +WHERE timers.kind=excluded.kind`, |
| + t.Host, t.Name, t.Kind, t.Spec, t.Path, t.NextAt, t.LastAt, t.LastStatus, t.LastMs, t.Attempts, t.CreatedAt, t.DueAt) |
| + if err != nil { |
| return err |
| - case err != nil: |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| return err |
| } |
| - if existing.Kind != t.Kind { |
| + if n == 0 { |
| return ErrTimerKind |
| } |
| - next, due := existing.NextAt, existing.DueAt |
| - if existing.Spec != t.Spec || existing.Path != t.Path || t.NextAt != 0 { |
| - next, due = t.NextAt, t.DueAt |
| - } |
| - _, err = r.sql.Exec(`UPDATE timers SET spec=?, path=?, next_at=?, due_at=? WHERE host=? AND name=?`, t.Spec, t.Path, next, due, t.Host, t.Name) |
| - return err |
| + return nil |
| } |
| func (r *Registry) GetTimer(host, name string) (*Timer, error) { |
| @@ -81,6 +85,11 @@ func (r *Registry) DeleteTimer(host, name string) error { |
| return err |
| } |
| +func (r *Registry) DeleteTimerRegistration(host, name, registration string) error { |
| + _, err := r.sql.Exec(`DELETE FROM timers WHERE host=? AND name=? AND registration=?`, host, name, registration) |
| + return err |
| +} |
| + |
| func (r *Registry) queryTimers(q string, args ...any) ([]Timer, error) { |
| rows, err := r.sql.Query(q, args...) |
| if err != nil { |
| @@ -130,7 +139,7 @@ func (r *Registry) DueTimers(now int64, limit int, excludeHosts []string) ([]Tim |
| // delivered (a `carlos schedule run` is its own occurrence, not an early |
| // delivery of the next natural one). |
| func (r *Registry) FireTimerNow(host, name string, now int64) error { |
| - _, err := r.sql.Exec(`UPDATE timers SET next_at=?, due_at=? WHERE host=? AND name=?`, now, now, host, name) |
| + _, err := r.sql.Exec(`UPDATE timers SET next_at=?, due_at=?, attempts=0, registration=lower(hex(randomblob(16))) WHERE host=? AND name=?`, now, now, host, name) |
| return err |
| } |
| @@ -138,10 +147,14 @@ func (r *Registry) FireTimerNow(host, name string, now int64) error { |
| // due is the occurrence THAT next delivery is for: the same value as next |
| // when the row advances, and the row's current DueAt when next is a retry |
| // of the occurrence just attempted — the runner decides which. |
| -func (r *Registry) MarkTimerFired(host, name string, at int64, status string, ms int64, attempts int, next, due int64) error { |
| - _, err := r.sql.Exec(`UPDATE timers SET last_at=?, last_status=?, last_ms=?, attempts=?, next_at=?, due_at=? WHERE host=? AND name=?`, |
| - at, status, ms, attempts, next, due, host, name) |
| - return err |
| +func (r *Registry) MarkTimerFired(host, name, registration string, at int64, status string, ms int64, attempts int, next, due int64) (bool, error) { |
| + res, err := r.sql.Exec(`UPDATE timers SET last_at=?, last_status=?, last_ms=?, attempts=?, next_at=?, due_at=? WHERE host=? AND name=? AND registration=?`, |
| + at, status, ms, attempts, next, due, host, name, registration) |
| + if err != nil { |
| + return false, err |
| + } |
| + n, err := res.RowsAffected() |
| + return n != 0, err |
| } |
| // SetTimerStatus writes last_status alone — converge's one word on a row |
diff --git a/internal/registry/timers_test.go b/internal/registry/timers_test.go| index 57b2eb85c..004cada04 100644 |
| --- a/internal/registry/timers_test.go |
| +++ b/internal/registry/timers_test.go |
| @@ -24,7 +24,7 @@ func TestTimersUpsertPreservesRunnerFields(t *testing.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", 100, "204", 300, 0, 21700, 21700); err != nil { |
| + 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 |
| @@ -135,7 +135,7 @@ func TestTimersDueAtFollowsTheOccurrence(t *testing.T) { |
| t.Fatal(err) |
| } |
| // A retry: next moves to the retry instant, due stays the occurrence. |
| - if err := r.MarkTimerFired("h", "s", 3600, "503", 1, 1, 3660, 3600); err != nil { |
| + 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") |
| @@ -143,7 +143,7 @@ func TestTimersDueAtFollowsTheOccurrence(t *testing.T) { |
| t.Fatalf("after retry: %+v", got) |
| } |
| // An advance: both move together. |
| - if err := r.MarkTimerFired("h", "s", 3660, "204", 1, 0, 7200, 7200); err != nil { |
| + 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 { |
| @@ -288,3 +288,169 @@ func TestEarliestTimersEmpty(t *testing.T) { |
| 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) |
| + } |
| + } |
| +} |
diff --git a/internal/schedule/converge_test.go b/internal/schedule/converge_test.go| index c8375bf39..95603d9e4 100644 |
| --- a/internal/schedule/converge_test.go |
| +++ b/internal/schedule/converge_test.go |
| @@ -131,7 +131,7 @@ func TestConvergeMaterialisesDeclarationPerExecInstance(t *testing.T) { |
| // A second pass with nothing changed: no new reports, next_at untouched. |
| reports = nil |
| - reg.MarkTimerFired("a.bab.oncarlos.com", "sync", now.Unix(), "204", 10, 0, 999, 999) |
| + reg.MarkTimerFired("a.bab.oncarlos.com", "sync", scheduleTimerRegistration(t, reg, "a.bab.oncarlos.com", "sync"), now.Unix(), "204", 10, 0, 999, 999) |
| if err := c.Pass(ctx); err != nil { |
| t.Fatal(err) |
| } |
| @@ -190,7 +190,7 @@ func TestConvergeRunSignalFiresOnceAcrossInstances(t *testing.T) { |
| t.Fatalf("%s not fired: %+v", h, tm) |
| } |
| } |
| - reg.MarkTimerFired("a.bab.oncarlos.com", "sync", now.Unix(), "204", 1, 0, 777, 777) |
| + reg.MarkTimerFired("a.bab.oncarlos.com", "sync", scheduleTimerRegistration(t, reg, "a.bab.oncarlos.com", "sync"), now.Unix(), "204", 1, 0, 777, 777) |
| c2.Pass(ctx) // same etag: must not re-fire |
| if tm, _ := reg.GetTimer("a.bab.oncarlos.com", "sync"); tm.NextAt != 777 { |
| t.Fatal("run signal replayed on an unchanged etag") |
| @@ -511,7 +511,7 @@ func TestConvergeRunSignalGoingAbsentIsNotAFire(t *testing.T) { |
| if err := c.Pass(ctx); err != nil { |
| t.Fatal(err) |
| } |
| - reg.MarkTimerFired("a.bab.oncarlos.com", "sync", now.Unix(), "204", 1, 0, 777, 777) |
| + reg.MarkTimerFired("a.bab.oncarlos.com", "sync", scheduleTimerRegistration(t, reg, "a.bab.oncarlos.com", "sync"), now.Unix(), "204", 1, 0, 777, 777) |
| if tm, _ := reg.GetTimer("a.bab.oncarlos.com", "sync"); tm.NextAt != 777 { |
| t.Fatalf("setup: run signal did not fire: %+v", tm) |
| } |
| @@ -597,3 +597,12 @@ func TestConvergeParksAScheduleThatNeverFires(t *testing.T) { |
| t.Fatalf("a parked row is due: %+v", due) |
| } |
| } |
| + |
| +func scheduleTimerRegistration(t *testing.T, reg *registry.Registry, host, name string) string { |
| + t.Helper() |
| + row, err := reg.GetTimer(host, name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return row.Registration |
| +} |
diff --git a/internal/schedule/runner.go b/internal/schedule/runner.go| index 888a5a784..9aa716af6 100644 |
| --- a/internal/schedule/runner.go |
| +++ b/internal/schedule/runner.go |
| @@ -312,7 +312,7 @@ func (r *Runner) fire(ctx context.Context, rt registry.Route, t registry.Timer) |
| 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) |
| - r.Reg.DeleteTimer(rt.Host, t.Name) |
| + r.Reg.DeleteTimerRegistration(rt.Host, t.Name, t.Registration) |
| return |
| } |
| start := r.now() |
| @@ -379,12 +379,13 @@ func (r *Runner) record(rt registry.Route, t registry.Timer, sp Spec, at time.Ti |
| } else if !isSuccess(o.Status) { |
| level = "warn" |
| } |
| - if err := r.Reg.MarkTimerFired(rt.Host, t.Name, at.Unix(), status, took.Milliseconds(), attempts, next, due); err != nil { |
| + applied, err := r.Reg.MarkTimerFired(rt.Host, t.Name, t.Registration, at.Unix(), status, took.Milliseconds(), attempts, next, due) |
| + if err != nil { |
| log.Printf("schedule: %s/%s: record fire: %v", rt.Host, t.Name, err) |
| } |
| if r.Event != nil { |
| msg := fmt.Sprintf("schedule %s fired POST %s → %s in %s", t.Name, t.Path, status, took.Truncate(time.Millisecond)) |
| - if attempts > 0 { |
| + if attempts > 0 && applied { |
| msg += fmt.Sprintf(" (attempt %d of %d; retrying at %s)", attempts, MaxAttempts, time.Unix(next, 0).UTC().Format(time.RFC3339)) |
| } |
| r.Event(rt, level, msg) |
diff --git a/internal/schedule/runner_test.go b/internal/schedule/runner_test.go| index a6880a9f9..7ae3b4300 100644 |
| --- a/internal/schedule/runner_test.go |
| +++ b/internal/schedule/runner_test.go |
| @@ -5,6 +5,8 @@ import ( |
| "errors" |
| "fmt" |
| "net/http" |
| + "net/http/httptest" |
| + "os" |
| "path/filepath" |
| "strings" |
| "sync" |
| @@ -672,3 +674,180 @@ func TestDeliveriesAreParallelToTheBoundAndAllComplete(t *testing.T) { |
| t.Fatalf("delivered to %d hosts, want %d", len(fired), hosts) |
| } |
| } |
| + |
| +type timerMutationDeliverer struct { |
| + mutate func() |
| + status int |
| +} |
| + |
| +func (d timerMutationDeliverer) Deliver(context.Context, registry.Route, *http.Request) (int, error) { |
| + d.mutate() |
| + return d.status, nil |
| +} |
| + |
| +func TestDeliveryCompletionPreservesReplacementRegistration(t *testing.T) { |
| + for _, mutation := range []string{"identical", "timing", "path", "cancel", "recreate", "manual"} { |
| + for _, status := range []int{204, 503} { |
| + t.Run(fmt.Sprintf("%s/%d", mutation, status), 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" |
| + mux := http.NewServeMux() |
| + Mount(mux, host, reg, func() (string, error) { return "tok", nil }, runner.Now) |
| + put := func(when time.Time, path string) { |
| + req := httptest.NewRequest(http.MethodPut, "/schedule/remind", strings.NewReader(fmt.Sprintf(`{"at":%q,"path":%q}`, when.Format(time.RFC3339), path))) |
| + req.Header.Set("Authorization", "Bearer tok") |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, req) |
| + if w.Code != http.StatusOK { |
| + t.Errorf("PUT: %d %s", w.Code, w.Body.String()) |
| + } |
| + } |
| + put(now, "/jobs/remind") |
| + before, err := reg.GetTimer(host, "remind") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var replacement *registry.Timer |
| + var events []string |
| + runner.Event = func(_ registry.Route, _, msg string) { events = append(events, msg) } |
| + runner.Deliver = timerMutationDeliverer{status: status, mutate: func() { |
| + switch mutation { |
| + case "identical": |
| + put(now, "/jobs/remind") |
| + case "timing": |
| + put(now.Add(time.Hour), "/jobs/remind") |
| + case "path": |
| + put(now, "/jobs/changed") |
| + case "cancel", "recreate": |
| + req := httptest.NewRequest(http.MethodDelete, "/schedule/remind", nil) |
| + req.Header.Set("Authorization", "Bearer tok") |
| + w := httptest.NewRecorder() |
| + mux.ServeHTTP(w, req) |
| + if w.Code != http.StatusNoContent { |
| + t.Errorf("DELETE: %d", w.Code) |
| + } |
| + if mutation == "recreate" { |
| + put(now, "/jobs/remind") |
| + } |
| + case "manual": |
| + if err := reg.FireTimerNow(host, "remind", now.Unix()); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + replacement, err = reg.GetTimer(host, "remind") |
| + if mutation != "cancel" && err != nil { |
| + t.Error(err) |
| + } |
| + }} |
| + runner.tick() |
| + if len(events) != 1 || strings.Contains(events[0], "retrying at") { |
| + t.Fatalf("superseded delivery event: %v", events) |
| + } |
| + after, err := reg.GetTimer(host, "remind") |
| + if mutation == "cancel" { |
| + if !errors.Is(err, os.ErrNotExist) { |
| + t.Fatalf("cancelled timer: %+v, %v", after, err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if replacement == nil { |
| + t.Fatal("replacement missing") |
| + } |
| + if after.Registration == before.Registration || after.Registration == "" { |
| + t.Fatalf("registration did not change: %+v", after) |
| + } |
| + if *after != *replacement { |
| + t.Fatalf("completion changed replacement: got %+v, want %+v", after, replacement) |
| + } |
| + runner.Deliver = &fakeDeliverer{status: 204} |
| + now = time.Unix(after.NextAt, 0) |
| + runner.tick() |
| + finished, err := reg.GetTimer(host, "remind") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if finished.NextAt != 0 || finished.LastStatus != "204" { |
| + t.Fatalf("replacement did not fire: %+v", finished) |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +func TestDeliveryCompletionSurvivesUnchangedDeclaration(t *testing.T) { |
| + for _, status := range []int{204, 503} { |
| + t.Run(fmt.Sprint(status), func(t *testing.T) { |
| + reg, runner := runnerFixture(t, nil) |
| + now := at("2026-08-23T13:07:00Z") |
| + runner.Now = func() time.Time { return now } |
| + tm := registry.Timer{Host: "a.bab.oncarlos.com", Name: "sync", Kind: KindSchedule, Spec: "every 6h0m0s", Path: "/jobs/sync", NextAt: now.Unix(), DueAt: now.Unix()} |
| + if err := reg.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + before, err := reg.GetTimer(tm.Host, tm.Name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tm.NextAt, tm.DueAt = 0, 0 |
| + runner.Deliver = timerMutationDeliverer{status: status, mutate: func() { |
| + if err := reg.UpsertTimer(tm); err != nil { |
| + t.Error(err) |
| + } |
| + }} |
| + runner.tick() |
| + after, err := reg.GetTimer(tm.Host, tm.Name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after.Registration != before.Registration || after.LastStatus != fmt.Sprint(status) { |
| + t.Fatalf("completion missing: %+v", after) |
| + } |
| + if status == 503 && (after.Attempts != 1 || after.DueAt != before.DueAt || after.NextAt != now.Add(time.Minute).Unix()) { |
| + t.Fatalf("retry changed: %+v", after) |
| + } |
| + if err := reg.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + converged, err := reg.GetTimer(tm.Host, tm.Name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if *converged != *after { |
| + t.Fatalf("converge changed completed state: got %+v, want %+v", converged, after) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestInvalidTimerSnapshotCannotDeleteReplacement(t *testing.T) { |
| + reg, runner := runnerFixture(t, &fakeDeliverer{status: 204}) |
| + tm := registry.Timer{Host: "a.bab.oncarlos.com", Name: "remind", Kind: KindOnce, Spec: "invalid", Path: "/jobs/remind", NextAt: 100} |
| + if err := reg.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + old, err := reg.GetTimer(tm.Host, tm.Name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tm.Spec = "at 100" |
| + if err := reg.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + replacement, err := reg.GetTimer(tm.Host, tm.Name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + runner.fire(context.Background(), registry.Route{Host: tm.Host}, *old) |
| + after, err := reg.GetTimer(tm.Host, tm.Name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if *after != *replacement { |
| + t.Fatalf("invalid snapshot changed replacement: got %+v, want %+v", after, replacement) |
| + } |
| +} |