Merge branch 'fix/scheduled-reminder-delivery' into main
18 files changed,
+1693
−56
.gitignore+2 −0cmd/calendar/main.go+0 −5internal/calendar/app.go+23 −10internal/calendar/handlers.go+1 −0internal/calendar/handlers_jobs.go+15 −7internal/calendar/routes.go+1 −1internal/calendar/schedule_control.go+98 −0internal/calendar/schedule_restart_test.go+172 −0internal/calendar/schedule_restore.go+89 −0internal/calendar/scheduled_recovery_test.go+256 −0internal/calendar/scheduled_work.go+264 −0internal/calendar/scheduled_work_test.go+297 −0internal/calendar/store_reminder_next.go+11 −30internal/calendartest/reminder_test.go+15 −3internal/calendartest/schedule_control_test.go+116 −0internal/calendartest/schedule_delivery_test.go+200 −0internal/calendartest/schedule_progress_test.go+49 −0internal/calendartest/schedule_recovery_test.go+84 −0
CI passed — run details
diff --git a/.gitignore b/.gitignore| index 1188c78..680725f 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -41,3 +41,5 @@ Thumbs.db |
| # The JS test harness (Playwright) is installed, never vendored. |
| node_modules/ |
| + |
| +writing/ |
diff --git a/cmd/calendar/main.go b/cmd/calendar/main.go| index abcafd7..bc2aa78 100644 |
| --- a/cmd/calendar/main.go |
| +++ b/cmd/calendar/main.go |
| @@ -76,11 +76,6 @@ func main() { |
| opts.Mux = app.Handler() |
| opts.DBPath = "" |
| - // The scheduled-wake poll. The activator asks a running instance |
| - // this before hibernating it and wakes it at the answer, which is |
| - // what makes a reminder fire on a machine that is asleep almost all |
| - // the time. Without it a 09:00 reminder arrives whenever somebody |
| - // next happens to load a page. |
| opts.NextDue = app.NextDue |
| // The same page a handler's own error path renders, so a panic and |
| // a handled failure look identical to whoever hits one. |
diff --git a/internal/calendar/app.go b/internal/calendar/app.go| index 86e14bf..62ab714 100644 |
| --- a/internal/calendar/app.go |
| +++ b/internal/calendar/app.go |
| @@ -8,6 +8,7 @@ import ( |
| "fmt" |
| "log/slog" |
| "net/http" |
| + "sync" |
| "time" |
| rauth "github.com/carlosframework/rastrillo/auth" |
| @@ -42,7 +43,10 @@ type App struct { |
| auth *rauth.Auth |
| // h is held for the test harness; requests reach it through mux. |
| - h *handlers |
| + h *handlers |
| + scheduled *scheduledWork |
| + closeOnce sync.Once |
| + closeErr error |
| } |
| // New resolves every fail-closed setting, applies the schema, and wires |
| @@ -158,6 +162,14 @@ func New(cfg Config) (*App, error) { |
| } |
| h.homeEnabled = true |
| } |
| + a.scheduled = newScheduledWork(store.nextDueAt, cfg.Env == "development", func(err error) { |
| + logger.Error("calendar: scheduled work registration failed", "stage", "schedule.register", "err", scrubErr(err)) |
| + }) |
| + if err := a.scheduled.start(); err != nil { |
| + d.Close() |
| + return nil, err |
| + } |
| + h.scheduled = a.scheduled |
| a.mux = a.routes(h, authPlugin, list, origin) |
| return a, nil |
| } |
| @@ -165,12 +177,8 @@ func New(cfg Config) (*App, error) { |
| // NextDue answers the platform's scheduled-wake poll: the next moment |
| // this instance has work. rastrillo mounts it as GET /api/next-due, |
| // outside app middleware, and the activator asks before hibernating. |
| -// |
| -// Without it, a reminder due at 09:00 on an instance nobody visits |
| -// would arrive whenever somebody next loaded a page. With it, the |
| -// platform wakes the process at 09:00 to send it. |
| func (a *App) NextDue() time.Time { |
| - return a.store.NextDueAt(context.Background()) |
| + return a.scheduled.probe() |
| } |
| // Handler is the app's router, for rastrillo.Options.Mux and for |
| @@ -180,8 +188,13 @@ func (a *App) Handler() *http.ServeMux { return a.mux } |
| // Close releases the database. Safe to call on a partially built app. |
| func (a *App) Close() error { |
| - if a.db == nil { |
| - return nil |
| - } |
| - return a.db.Close() |
| + a.closeOnce.Do(func() { |
| + if a.scheduled != nil { |
| + a.scheduled.close() |
| + } |
| + if a.db != nil { |
| + a.closeErr = a.db.Close() |
| + } |
| + }) |
| + return a.closeErr |
| } |
diff --git a/internal/calendar/handlers.go b/internal/calendar/handlers.go| index 871769d..8ff2dd6 100644 |
| --- a/internal/calendar/handlers.go |
| +++ b/internal/calendar/handlers.go |
| @@ -10,6 +10,7 @@ import ( |
| // handlers holds what every handler needs. One value, built in New. |
| type handlers struct { |
| + scheduled *scheduledWork |
| homeEnabled bool |
| store *Store |
| logger *slog.Logger |
diff --git a/internal/calendar/handlers_jobs.go b/internal/calendar/handlers_jobs.go| index 503db9c..0c22617 100644 |
| --- a/internal/calendar/handlers_jobs.go |
| +++ b/internal/calendar/handlers_jobs.go |
| @@ -27,7 +27,12 @@ func (h *handlers) drainReminders(w http.ResponseWriter, r *http.Request) { |
| http.Error(w, "forbidden", http.StatusForbidden) |
| return |
| } |
| - sent, err := h.drain(r.Context(), time.Now()) |
| + started := time.Now() |
| + sent, progress, err := h.drain(r.Context(), started) |
| + registerErr := h.scheduled.finishDrain(started, progress, err) |
| + if err == nil { |
| + err = registerErr |
| + } |
| if err != nil { |
| // 5xx so the platform retries. The occurrence key makes a |
| // retry safe. |
| @@ -49,18 +54,21 @@ const drainDeadline = 45 * time.Second |
| // the budget is gone, then booking mail with what is left (booking |
| // spec §8). Reminders get the first half so a queue of booking mail |
| // behind a slow relay cannot starve them. |
| -func (h *handlers) drain(ctx context.Context, now time.Time) (int, error) { |
| +func (h *handlers) drain(ctx context.Context, now time.Time) (int, int, error) { |
| + progress := 0 |
| deadline := now.Add(drainDeadline) |
| ctx, cancel := context.WithDeadline(ctx, deadline) |
| defer cancel() |
| if n, err := h.store.ExpireHolds(ctx, now, 200); err != nil { |
| - return 0, err |
| + return 0, progress, err |
| } else if n > 0 { |
| + progress += n |
| h.logger.Info("calendar: holds expired", "n", n) |
| } |
| if n, err := h.store.ForgetOldIPs(ctx, now, 500); err != nil { |
| - return 0, err |
| + return 0, progress, err |
| } else if n > 0 { |
| + progress += n |
| h.logger.Info("calendar: ip fingerprints forgotten", "n", n) |
| } |
| // Each phase's deadline is a CONTEXT deadline, not only a loop |
| @@ -73,14 +81,14 @@ func (h *handlers) drain(ctx context.Context, now time.Time) (int, error) { |
| sent, err := h.drainReminderQueue(rctx, now, half) |
| rcancel() |
| if err != nil && !errors.Is(err, context.DeadlineExceeded) { |
| - return sent, err |
| + return sent, progress + sent, err |
| } |
| // The reminder phase running out is a handover, not a failure: |
| // booking mail still gets the second half. |
| mctx, mcancel := context.WithDeadline(ctx, deadline.Add(-keepReserve)) |
| defer mcancel() |
| mailed, err := h.drainBookingMail(mctx, now, deadline.Add(-keepReserve)) |
| - return sent + mailed, err |
| + return sent + mailed, progress + sent + mailed, err |
| } |
| // drainReminderQueue sends every reminder that has come due, until the |
| @@ -109,7 +117,7 @@ func (h *handlers) drainReminderQueue(ctx context.Context, now, deadline time.Ti |
| // that says what it is beats an unbounded one that does not. |
| if seen[due.Queue.OccurrenceKey] { |
| h.logger.Error("calendar: the same occurrence was claimed twice in one drain; stopping the pass") |
| - return sent, nil |
| + return sent, errors.New("calendar: the same occurrence was claimed twice in one drain; stopping the pass") |
| } |
| seen[due.Queue.OccurrenceKey] = true |
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index 2c51ea0..019f81b 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -163,7 +163,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| mux.Handle("GET /static/", assets.Handler()) |
| mux.HandleFunc("GET /static/credits.css", serveCreditsCSS) |
| a.mountTick(mux, h) |
| - mux.Handle("/", r) |
| + mux.Handle("/", a.scheduled.middleware(r)) |
| return mux |
| } |
diff --git a/internal/calendar/schedule_control.go b/internal/calendar/schedule_control.go| new file mode 100644 |
| index 0000000..62882c5 |
| --- /dev/null |
| +++ b/internal/calendar/schedule_control.go |
| @@ -0,0 +1,98 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "io" |
| + "net" |
| + "net/http" |
| + "os" |
| + |
| + "github.com/carlosframework/rastrillo/carlos" |
| +) |
| + |
| +const scheduleUnsupportedMessage = "calendar: this platform does not support safe scheduled work; update the platform before starting Calendar" |
| + |
| +type unsupportedScheduling struct{} |
| + |
| +func (unsupportedScheduling) Error() string { return scheduleUnsupportedMessage } |
| + |
| +func scheduleControl(ctx context.Context, path string, value any) error { |
| + socket := os.Getenv("CARLOS_CONTROL_SOCKET") |
| + if socket == "" { |
| + return carlos.ErrNotOnCarlos |
| + } |
| + token := os.Getenv("CARLOS_ADMIN_TOKEN") |
| + if token == "" { |
| + return carlos.ErrUnauthorized |
| + } |
| + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| + return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| + }} |
| + defer transport.CloseIdleConnections() |
| + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://carlos"+path, nil) |
| + if err != nil { |
| + return err |
| + } |
| + req.Header.Set("Authorization", "Bearer "+token) |
| + response, err := (&http.Client{Transport: transport}).Do(req) |
| + if err != nil { |
| + return err |
| + } |
| + defer response.Body.Close() |
| + if response.StatusCode == http.StatusUnauthorized { |
| + return carlos.ErrUnauthorized |
| + } |
| + if path == "/schedule-capabilities" && response.StatusCode == http.StatusNotFound { |
| + return unsupportedScheduling{} |
| + } |
| + if response.StatusCode != http.StatusOK { |
| + return &carlos.StatusError{Status: response.StatusCode, Name: scheduledWorkName} |
| + } |
| + return json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(value) |
| +} |
| + |
| +func scheduleCapability(ctx context.Context) error { |
| + var capability struct { |
| + RegistrationFence bool `json:"registration_fence"` |
| + } |
| + if err := scheduleControl(ctx, "/schedule-capabilities", &capability); err != nil { |
| + return err |
| + } |
| + if !capability.RegistrationFence { |
| + return unsupportedScheduling{} |
| + } |
| + return nil |
| +} |
| + |
| +type remoteSchedule struct { |
| + Name string `json:"name"` |
| + Kind string `json:"kind"` |
| + Path string `json:"path"` |
| + NextAt int64 `json:"next_at"` |
| + DueAt int64 `json:"due_at"` |
| + Attempts int `json:"attempts"` |
| +} |
| + |
| +func scheduleArmed(ctx context.Context) (remoteSchedule, error) { |
| + var response struct { |
| + Timers []remoteSchedule `json:"timers"` |
| + } |
| + if err := scheduleControl(ctx, "/schedule", &response); err != nil { |
| + return remoteSchedule{}, err |
| + } |
| + for _, timer := range response.Timers { |
| + if timer.Name == scheduledWorkName { |
| + return timer, nil |
| + } |
| + } |
| + return remoteSchedule{}, nil |
| +} |
| + |
| +func permanentScheduleError(err error) bool { |
| + var unsupported unsupportedScheduling |
| + var status *carlos.StatusError |
| + return errors.As(err, &unsupported) || errors.Is(err, carlos.ErrNotOnCarlos) || errors.Is(err, carlos.ErrUnauthorized) || |
| + errors.Is(err, carlos.ErrDeclaredSchedule) || (errors.As(err, &status) && status.Status >= 400 && status.Status < 500 && status.Status != 408 && status.Status != 429) |
| +} |
diff --git a/internal/calendar/schedule_restart_test.go b/internal/calendar/schedule_restart_test.go| new file mode 100644 |
| index 0000000..14313aa |
| --- /dev/null |
| +++ b/internal/calendar/schedule_restart_test.go |
| @@ -0,0 +1,172 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type remoteTimerFixture struct { |
| + mu sync.Mutex |
| + timer remoteSchedule |
| + writes int |
| +} |
| + |
| +func restartRegistrar(t *testing.T, due time.Time, clock *atomic.Int64, remote *remoteTimerFixture) *scheduledWork { |
| + t.Helper() |
| + s := newScheduledWork(func(context.Context) (time.Time, error) { return due, nil }, false, nil) |
| + s.now = func() time.Time { return time.Unix(clock.Load(), 0) } |
| + s.capability = func(context.Context) error { return nil } |
| + s.armed = func(context.Context) (remoteSchedule, error) { |
| + remote.mu.Lock() |
| + defer remote.mu.Unlock() |
| + return remote.timer, nil |
| + } |
| + s.put = func(_ context.Context, _ string, at time.Time, _ string) error { |
| + remote.mu.Lock() |
| + defer remote.mu.Unlock() |
| + remote.timer = remoteSchedule{NextAt: at.Unix(), DueAt: at.Unix(), Kind: "once", Path: "/jobs/reminders"} |
| + remote.writes++ |
| + return nil |
| + } |
| + if err := s.start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(s.close) |
| + return s |
| +} |
| + |
| +func TestDrainBackoffSurvivesRestartThroughPersistedLateness(t *testing.T) { |
| + var clock atomic.Int64 |
| + clock.Store(time.Now().Unix()) |
| + due := time.Unix(clock.Load(), 0).Add(-4 * time.Minute) |
| + remote := &remoteTimerFixture{} |
| + first := restartRegistrar(t, due, &clock, remote) |
| + if err := first.finishDrain(first.now(), 0, context.DeadlineExceeded); err != nil { |
| + t.Fatal(err) |
| + } |
| + first.close() |
| + original := remote.timer.NextAt |
| + writes := remote.writes |
| + clock.Add(30) |
| + second := restartRegistrar(t, due, &clock, remote) |
| + if remote.writes != writes || remote.timer.NextAt != original { |
| + t.Fatal("boot replaced existing future backoff") |
| + } |
| + if got := second.probe(); got.Unix() != original { |
| + t.Fatalf("boot probe bypassed backoff: %v", got) |
| + } |
| + second.close() |
| + clock.Add(12 * 60) |
| + third := restartRegistrar(t, due, &clock, remote) |
| + if remote.writes != writes { |
| + t.Fatal("boot replaced the delivery currently waking the process") |
| + } |
| + if err := third.finishDrain(third.now(), 0, context.DeadlineExceeded); err != nil { |
| + t.Fatal(err) |
| + } |
| + if remote.timer.NextAt < clock.Load()+4*60 { |
| + t.Fatalf("failure restarted at one minute: %d", remote.timer.NextAt-clock.Load()) |
| + } |
| +} |
| + |
| +func TestSuccessfulDrainWithoutProgressBacksOffButSweepProgressDoesNot(t *testing.T) { |
| + due := time.Now().Add(-time.Minute) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return due, nil }) |
| + now := s.now() |
| + if err := s.finishDrain(now, 0, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := r.snapshot() |
| + if calls[len(calls)-1].Before(now.Add(time.Minute)) { |
| + t.Fatalf("no-progress success immediately rearmed: %v", calls) |
| + } |
| + if err := s.finishDrain(s.now(), 500, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + calls = r.snapshot() |
| + if calls[len(calls)-1].After(time.Now()) { |
| + t.Fatal("real sweep progress delayed remaining backlog") |
| + } |
| +} |
| + |
| +func TestLateCommittedReplacementIsRepairedWithoutResettingPlatformRetries(t *testing.T) { |
| + var clock atomic.Int64 |
| + clock.Store(time.Now().Unix()) |
| + due := time.Unix(clock.Load(), 0).Add(time.Hour) |
| + remote := &remoteTimerFixture{} |
| + s := restartRegistrar(t, due, &clock, remote) |
| + remote.mu.Lock() |
| + remote.timer.NextAt = due.Add(time.Hour).Unix() |
| + remote.timer.DueAt = remote.timer.NextAt |
| + remote.mu.Unlock() |
| + if _, err := s.synchronize(false, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if remote.timer.NextAt != due.Unix() || remote.writes != 2 { |
| + t.Fatalf("late stale write survived inspection: %+v", remote) |
| + } |
| + clock.Store(due.Unix() + 60) |
| + remote.mu.Lock() |
| + remote.timer.NextAt = clock.Load() + 8*60 |
| + remote.timer.Attempts = 4 |
| + remote.mu.Unlock() |
| + if _, err := s.synchronize(false, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if remote.writes != 2 { |
| + t.Fatal("legitimate platform retry was reset") |
| + } |
| +} |
| + |
| +func TestNextDueBusyRegistrationReturnsImmediately(t *testing.T) { |
| + s, _ := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Now().Add(time.Hour), nil }) |
| + s.mu.Lock() |
| + started := time.Now() |
| + done := make(chan time.Time, 1) |
| + go func() { done <- s.probe() }() |
| + select { |
| + case due := <-done: |
| + if due.Before(started) || due.After(time.Now()) { |
| + t.Fatal(due) |
| + } |
| + case <-time.After(100 * time.Millisecond): |
| + s.mu.Unlock() |
| + t.Fatal("probe waited for registration mutex") |
| + } |
| + s.mu.Unlock() |
| +} |
| + |
| +func TestNextDueHasOneOverallBudgetForSequentialControlCalls(t *testing.T) { |
| + s, _ := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Now().Add(time.Hour), nil }) |
| + s.mu.Lock() |
| + s.force = true |
| + s.capability = func(ctx context.Context) error { |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-time.After(1500 * time.Millisecond): |
| + return nil |
| + } |
| + } |
| + s.put = func(ctx context.Context, _ string, _ time.Time, _ string) error { <-ctx.Done(); return ctx.Err() } |
| + s.query = func(ctx context.Context) (time.Time, error) { |
| + select { |
| + case <-ctx.Done(): |
| + return time.Time{}, ctx.Err() |
| + case <-time.After(1500 * time.Millisecond): |
| + return time.Now().Add(time.Hour), nil |
| + } |
| + } |
| + s.mu.Unlock() |
| + started := time.Now() |
| + due := s.probe() |
| + if elapsed := time.Since(started); elapsed >= 4500*time.Millisecond { |
| + t.Fatalf("probe exceeded pre-stop budget: %v", elapsed) |
| + } |
| + if due.IsZero() || due.After(time.Now()) { |
| + t.Fatalf("failed probe did not preserve wake: %v", due) |
| + } |
| +} |
diff --git a/internal/calendar/schedule_restore.go b/internal/calendar/schedule_restore.go| new file mode 100644 |
| index 0000000..aaeee1b |
| --- /dev/null |
| +++ b/internal/calendar/schedule_restore.go |
| @@ -0,0 +1,89 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/carlos" |
| +) |
| + |
| +func (s *scheduledWork) matches(remote remoteSchedule) bool { |
| + if remote.NextAt <= 0 || remote.Kind != "once" || remote.Path != "/jobs/reminders" { |
| + return false |
| + } |
| + occurrence := remote.DueAt |
| + if occurrence == 0 { |
| + occurrence = remote.NextAt |
| + } |
| + if occurrence != s.registeredAt.Unix() { |
| + return false |
| + } |
| + return remote.NextAt == occurrence || (remote.Attempts > 0 && remote.NextAt <= s.now().Add(15*time.Minute).Unix()) |
| +} |
| + |
| +func (s *scheduledWork) restore(parent context.Context, due time.Time) (bool, error) { |
| + ctx, cancel := context.WithTimeout(parent, 2*time.Second) |
| + err := s.capability(ctx) |
| + cancel() |
| + if errors.Is(err, carlos.ErrNotOnCarlos) && s.development { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, err |
| + } |
| + ctx, cancel = context.WithTimeout(parent, 2*time.Second) |
| + remote, err := s.armed(ctx) |
| + cancel() |
| + if err != nil { |
| + return false, err |
| + } |
| + if due.IsZero() || remote.NextAt <= 0 || remote.Kind != "once" || remote.Path != "/jobs/reminders" { |
| + return false, nil |
| + } |
| + occurrence := remote.DueAt |
| + if occurrence == 0 { |
| + occurrence = remote.NextAt |
| + } |
| + now := s.now() |
| + if remote.NextAt != occurrence && (remote.Attempts <= 0 || remote.NextAt > now.Add(15*time.Minute).Unix()) { |
| + return false, nil |
| + } |
| + if due.After(now) { |
| + at := due |
| + if latest := now.Add(carlos.MaxAhead - time.Minute); at.After(latest) { |
| + at = latest |
| + } |
| + if occurrence != at.Unix() { |
| + return false, nil |
| + } |
| + } else { |
| + if occurrence < due.Unix() || remote.NextAt > now.Add(15*time.Minute).Unix() { |
| + return false, nil |
| + } |
| + if remote.NextAt > now.Unix() { |
| + s.notBefore = time.Unix(remote.NextAt, 0) |
| + due = s.notBefore |
| + } |
| + } |
| + s.known, s.force = true, false |
| + s.registered, s.observed = due, due |
| + s.registeredAt = time.Unix(occurrence, 0) |
| + s.checked = now |
| + return true, nil |
| +} |
| + |
| +func (s *scheduledWork) probe() time.Time { |
| + if !s.mu.TryLock() { |
| + return time.Now() |
| + } |
| + defer s.mu.Unlock() |
| + ctx, cancel := context.WithTimeout(s.ctx, 4*time.Second) |
| + defer cancel() |
| + due, err := s.synchronizeLocked(ctx, false, true) |
| + if err != nil { |
| + s.request() |
| + return s.now() |
| + } |
| + return due |
| +} |
diff --git a/internal/calendar/scheduled_recovery_test.go b/internal/calendar/scheduled_recovery_test.go| new file mode 100644 |
| index 0000000..2b546fd |
| --- /dev/null |
| +++ b/internal/calendar/scheduled_recovery_test.go |
| @@ -0,0 +1,256 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestScheduledWorkTransientBootFailureServesAndRecovers(t *testing.T) { |
| + for _, queryFailure := range []bool{false, true} { |
| + t.Run(map[bool]string{false: "control", true: "query"}[queryFailure], func(t *testing.T) { |
| + var fail atomic.Bool |
| + fail.Store(true) |
| + due := time.Now().Add(time.Hour) |
| + s := newScheduledWork(func(context.Context) (time.Time, error) { |
| + if queryFailure && fail.Load() { |
| + return time.Time{}, context.DeadlineExceeded |
| + } |
| + return due, nil |
| + }, false, nil) |
| + s.retryDelay = time.Millisecond |
| + s.capability = func(context.Context) error { return nil } |
| + s.armed = func(context.Context) (remoteSchedule, error) { return remoteSchedule{}, nil } |
| + written := make(chan struct{}, 1) |
| + s.put = func(context.Context, string, time.Time, string) error { |
| + if fail.Load() { |
| + return context.DeadlineExceeded |
| + } |
| + select { |
| + case written <- struct{}{}: |
| + default: |
| + } |
| + return nil |
| + } |
| + if err := s.start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(s.close) |
| + fail.Store(false) |
| + select { |
| + case <-written: |
| + case <-time.After(time.Second): |
| + t.Fatal("transient boot error never recovered") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestScheduledWorkCloseBeforeStartAndAfterPermanentFailure(t *testing.T) { |
| + for _, start := range []bool{false, true} { |
| + s := newScheduledWork(func(context.Context) (time.Time, error) { return time.Now(), nil }, false, nil) |
| + s.capability = func(context.Context) error { return unsupportedScheduling{} } |
| + if start { |
| + if err := s.start(); err == nil { |
| + t.Fatal("unsupported platform accepted") |
| + } |
| + } |
| + done := make(chan struct{}) |
| + go func() { s.close(); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(time.Second): |
| + t.Fatal("Close hung") |
| + } |
| + } |
| +} |
| + |
| +func TestScheduledWorkDrainFailuresBackOffEveryRegistrationPath(t *testing.T) { |
| + now := time.Now().Truncate(time.Second) |
| + due := now.Add(-time.Minute) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return due, nil }) |
| + s.mu.Lock() |
| + s.now = func() time.Time { return now } |
| + s.mu.Unlock() |
| + for _, minutes := range []int{1, 2, 4, 8, 15, 15} { |
| + if err := s.finishDrain(s.now(), 0, context.DeadlineExceeded); err != nil { |
| + t.Fatal(err) |
| + } |
| + expected := now.Add(time.Duration(minutes) * time.Minute) |
| + for _, inspect := range []bool{false, true} { |
| + got, err := s.synchronize(false, inspect) |
| + if err != nil || !got.Equal(expected) { |
| + t.Fatalf("failure floor bypassed: %v %v, want %v", got, err, expected) |
| + } |
| + } |
| + calls := r.snapshot() |
| + if !calls[len(calls)-1].Equal(expected) { |
| + t.Fatalf("backoff registration = %v, want %v", calls, expected) |
| + } |
| + } |
| + if err := s.finishDrain(s.now(), 1, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := r.snapshot() |
| + if !calls[len(calls)-1].Equal(now) { |
| + t.Fatalf("success did not reset backoff: %v", calls) |
| + } |
| +} |
| + |
| +func TestScheduledWorkEpochIsDueWorkNotCancellation(t *testing.T) { |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Unix(0, 0), nil }) |
| + calls := r.snapshot() |
| + if calls[0].Unix() <= 0 || calls[0].After(time.Now()) { |
| + t.Fatalf("epoch disarmed the timer: %v", calls) |
| + } |
| + if _, err := s.sync(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(r.snapshot()) != 1 { |
| + t.Fatal("clamping defeated unchanged registration cache") |
| + } |
| +} |
| + |
| +func TestScheduledWorkPublicRequestsDoNotWaitForControlSocket(t *testing.T) { |
| + var due atomic.Int64 |
| + due.Store(time.Now().Add(time.Hour).Unix()) |
| + s, _ := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Unix(due.Load(), 0), nil }) |
| + entered := make(chan struct{}, 1) |
| + s.mu.Lock() |
| + s.put = func(ctx context.Context, _ string, _ time.Time, _ string) error { |
| + select { |
| + case entered <- struct{}{}: |
| + default: |
| + } |
| + <-ctx.Done() |
| + return ctx.Err() |
| + } |
| + s.mu.Unlock() |
| + due.Add(1) |
| + s.request() |
| + select { |
| + case <-entered: |
| + case <-time.After(time.Second): |
| + t.Fatal("worker did not start control request") |
| + } |
| + h := s.middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })) |
| + start := time.Now() |
| + var wg sync.WaitGroup |
| + for range 50 { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/book", nil)) |
| + }() |
| + } |
| + done := make(chan struct{}) |
| + go func() { wg.Wait(); close(done) }() |
| + select { |
| + case <-done: |
| + case <-time.After(500 * time.Millisecond): |
| + t.Fatal("public traffic queued behind registration") |
| + } |
| + if time.Since(start) > 500*time.Millisecond { |
| + t.Fatal("public latency followed the control timeout") |
| + } |
| +} |
| + |
| +func TestScheduledWorkReconcilesConsumedTimersWithoutResettingArmedRows(t *testing.T) { |
| + var armed atomic.Bool |
| + armed.Store(true) |
| + due := time.Now().Add(-time.Minute) |
| + s := newScheduledWork(func(context.Context) (time.Time, error) { return due, nil }, false, nil) |
| + s.reconcileEvery = 5 * time.Millisecond |
| + s.capability = func(context.Context) error { return nil } |
| + s.armed = func(context.Context) (remoteSchedule, error) { return remoteSchedule{}, nil } |
| + checks := make(chan struct{}, 20) |
| + s.armed = func(context.Context) (remoteSchedule, error) { |
| + select { |
| + case checks <- struct{}{}: |
| + default: |
| + } |
| + if !armed.Load() { |
| + return remoteSchedule{}, nil |
| + } |
| + return remoteSchedule{NextAt: s.registeredAt.Unix(), DueAt: s.registeredAt.Unix(), Path: "/jobs/reminders", Kind: "once"}, nil |
| + } |
| + var writes atomic.Int64 |
| + s.put = func(context.Context, string, time.Time, string) error { writes.Add(1); armed.Store(true); return nil } |
| + if err := s.start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(s.close) |
| + for range 3 { |
| + select { |
| + case <-checks: |
| + case <-time.After(time.Second): |
| + t.Fatal("no periodic reconciliation") |
| + } |
| + } |
| + if writes.Load() != 1 { |
| + t.Fatal("armed or in-flight row was rearmed") |
| + } |
| + armed.Store(false) |
| + deadline := time.After(time.Second) |
| + for writes.Load() < 2 { |
| + select { |
| + case <-deadline: |
| + t.Fatal("consumed row remained cached") |
| + case <-time.After(time.Millisecond): |
| + } |
| + } |
| + if got, err := s.synchronize(false, true); err != nil || !got.Equal(due) { |
| + t.Fatalf("repaired due = %v %v", got, err) |
| + } |
| + if writes.Load() != 2 { |
| + t.Fatal("healthy replacement registration was reset") |
| + } |
| +} |
| + |
| +func TestScheduledWorkDetectsRollbackBeforeAnotherWrite(t *testing.T) { |
| + var due atomic.Int64 |
| + due.Store(time.Now().Add(time.Hour).Unix()) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Unix(due.Load(), 0), nil }) |
| + s.mu.Lock() |
| + s.capability = func(context.Context) error { return unsupportedScheduling{} } |
| + s.mu.Unlock() |
| + due.Add(60) |
| + _, err := s.sync(false) |
| + var unsupported unsupportedScheduling |
| + if !errors.As(err, &unsupported) { |
| + t.Fatalf("rollback was not distinguished: %v", err) |
| + } |
| + if len(r.snapshot()) != 1 { |
| + t.Fatal("wrote unsafe rearm after platform rollback") |
| + } |
| +} |
| + |
| +func TestScheduledWorkInspectionFailureDoesNotResetAnArmedRetry(t *testing.T) { |
| + due := time.Now().Add(time.Hour) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return due, nil }) |
| + var fail atomic.Bool |
| + fail.Store(true) |
| + s.mu.Lock() |
| + s.armed = func(context.Context) (remoteSchedule, error) { |
| + if fail.Load() { |
| + return remoteSchedule{}, context.DeadlineExceeded |
| + } |
| + return remoteSchedule{NextAt: s.registeredAt.Unix(), DueAt: s.registeredAt.Unix(), Path: "/jobs/reminders", Kind: "once"}, nil |
| + } |
| + s.mu.Unlock() |
| + if _, err := s.synchronize(false, true); err == nil { |
| + t.Fatal("inspection failure swallowed") |
| + } |
| + fail.Store(false) |
| + if _, err := s.synchronize(false, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(r.snapshot()) != 1 { |
| + t.Fatal("inspection retry replaced healthy armed row") |
| + } |
| +} |
diff --git a/internal/calendar/scheduled_work.go b/internal/calendar/scheduled_work.go| new file mode 100644 |
| index 0000000..a8c6556 |
| --- /dev/null |
| +++ b/internal/calendar/scheduled_work.go |
| @@ -0,0 +1,264 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/carlos" |
| +) |
| + |
| +const scheduledWorkName = "calendar-next-due" |
| + |
| +type scheduledWork struct { |
| + mu sync.Mutex |
| + lifeMu sync.Mutex |
| + started, closed bool |
| + finishOnce sync.Once |
| + ctx context.Context |
| + cancel context.CancelFunc |
| + done chan struct{} |
| + wake chan struct{} |
| + query func(context.Context) (time.Time, error) |
| + put func(context.Context, string, time.Time, string) error |
| + remove func(context.Context, string) error |
| + capability func(context.Context) error |
| + armed func(context.Context) (remoteSchedule, error) |
| + now func() time.Time |
| + report func(error) |
| + development bool |
| + known bool |
| + registered time.Time |
| + registeredAt time.Time |
| + rawDue time.Time |
| + observed time.Time |
| + force bool |
| + allowRestore bool |
| + failures int |
| + notBefore time.Time |
| + checked time.Time |
| + retryDelay time.Duration |
| + reconcileEvery time.Duration |
| +} |
| + |
| +func newScheduledWork(query func(context.Context) (time.Time, error), development bool, report func(error)) *scheduledWork { |
| + ctx, cancel := context.WithCancel(context.Background()) |
| + return &scheduledWork{ctx: ctx, cancel: cancel, done: make(chan struct{}), wake: make(chan struct{}, 1), query: query, |
| + put: carlos.ScheduleAt, remove: carlos.ScheduleCancel, capability: scheduleCapability, armed: scheduleArmed, now: time.Now, |
| + report: report, development: development, retryDelay: time.Second, reconcileEvery: time.Minute, allowRestore: true} |
| +} |
| + |
| +func (s *scheduledWork) start() error { |
| + s.lifeMu.Lock() |
| + defer s.lifeMu.Unlock() |
| + if s.closed { |
| + return context.Canceled |
| + } |
| + if s.started { |
| + return nil |
| + } |
| + s.started = true |
| + if _, err := s.sync(true); err != nil && permanentScheduleError(err) { |
| + s.cancel() |
| + s.finishOnce.Do(func() { close(s.done) }) |
| + return err |
| + } |
| + go s.retry() |
| + return nil |
| +} |
| + |
| +func (s *scheduledWork) close() { |
| + s.cancel() |
| + s.lifeMu.Lock() |
| + s.closed = true |
| + if !s.started { |
| + s.finishOnce.Do(func() { close(s.done) }) |
| + } |
| + s.lifeMu.Unlock() |
| + <-s.done |
| + s.mu.Lock() |
| + s.mu.Unlock() |
| +} |
| + |
| +func (s *scheduledWork) request() { |
| + select { |
| + case s.wake <- struct{}{}: |
| + default: |
| + } |
| +} |
| + |
| +func (s *scheduledWork) sync(force bool) (time.Time, error) { |
| + due, err := s.synchronize(force, false) |
| + if err != nil { |
| + s.request() |
| + } |
| + return due, err |
| +} |
| + |
| +func (s *scheduledWork) finishDrain(start time.Time, progress int, drainErr error) error { |
| + s.mu.Lock() |
| + s.allowRestore = false |
| + ctx, cancel := context.WithTimeout(s.ctx, 2*time.Second) |
| + due, queryErr := s.query(ctx) |
| + cancel() |
| + if queryErr != nil { |
| + due = s.rawDue |
| + } |
| + failed := drainErr != nil || queryErr != nil || (progress == 0 && !due.IsZero() && !due.After(start)) |
| + if !failed { |
| + s.failures = 0 |
| + s.notBefore = time.Time{} |
| + } else { |
| + s.failures = min(s.failures+1, 5) |
| + delay := min(time.Minute<<(s.failures-1), 15*time.Minute) |
| + if !due.IsZero() { |
| + delay = max(delay, min(max(s.now().Sub(due)/4, time.Minute), 15*time.Minute)) |
| + } |
| + s.notBefore = s.now().Add(delay) |
| + } |
| + s.mu.Unlock() |
| + _, err := s.sync(true) |
| + if queryErr != nil { |
| + return queryErr |
| + } |
| + return err |
| +} |
| + |
| +func (s *scheduledWork) synchronize(force, inspect bool) (due time.Time, err error) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + ctx, cancel := context.WithTimeout(s.ctx, 4*time.Second) |
| + defer cancel() |
| + return s.synchronizeLocked(ctx, force, inspect) |
| +} |
| + |
| +func (s *scheduledWork) synchronizeLocked(ctx context.Context, force, inspect bool) (due time.Time, err error) { |
| + if err := ctx.Err(); err != nil { |
| + return s.observed, err |
| + } |
| + s.force = s.force || force |
| + attempted := false |
| + defer func() { |
| + if err != nil { |
| + s.force = s.force || attempted |
| + if s.report != nil { |
| + s.report(err) |
| + } |
| + } |
| + }() |
| + queryCtx, cancel := context.WithTimeout(ctx, 2*time.Second) |
| + due, err = s.query(queryCtx) |
| + cancel() |
| + if err != nil { |
| + return s.observed, err |
| + } |
| + s.rawDue = due |
| + if !s.known && s.allowRestore { |
| + if restored, restoreErr := s.restore(ctx, due); restoreErr != nil { |
| + return due, restoreErr |
| + } else if restored { |
| + return s.observed, nil |
| + } |
| + } |
| + if !s.notBefore.IsZero() && (due.IsZero() || due.Before(s.notBefore)) { |
| + due = s.notBefore |
| + } |
| + s.observed = due |
| + if s.known && !s.force && due.Equal(s.registered) { |
| + if !inspect && s.now().Sub(s.checked) < s.reconcileEvery { |
| + return due, nil |
| + } |
| + controlCtx, cancel := context.WithTimeout(ctx, 2*time.Second) |
| + var armed remoteSchedule |
| + armed, err = s.armed(controlCtx) |
| + cancel() |
| + if errors.Is(err, carlos.ErrNotOnCarlos) && s.development { |
| + err = nil |
| + armed = remoteSchedule{NextAt: s.registeredAt.Unix(), DueAt: s.registeredAt.Unix(), Path: "/jobs/reminders", Kind: "once"} |
| + } |
| + if err != nil { |
| + return due, err |
| + } |
| + s.checked = s.now() |
| + if due.IsZero() || s.matches(armed) { |
| + return due, nil |
| + } |
| + s.force = true |
| + } |
| + capabilityCtx, cancel := context.WithTimeout(ctx, 2*time.Second) |
| + err = s.capability(capabilityCtx) |
| + cancel() |
| + if errors.Is(err, carlos.ErrNotOnCarlos) && s.development { |
| + err = nil |
| + } else if err != nil { |
| + return due, err |
| + } |
| + controlCtx, cancel := context.WithTimeout(ctx, 2*time.Second) |
| + defer cancel() |
| + attempted = true |
| + if due.IsZero() { |
| + err = s.remove(controlCtx, scheduledWorkName) |
| + } else { |
| + at := due |
| + now := s.now() |
| + if at.Before(now) || at.Unix() <= 0 { |
| + at = now |
| + } |
| + if latest := now.Add(carlos.MaxAhead - time.Minute); at.After(latest) { |
| + at = latest |
| + } |
| + err = s.put(controlCtx, scheduledWorkName, at, "/jobs/reminders") |
| + if err == nil { |
| + s.registeredAt = time.Unix(at.Unix(), 0) |
| + } |
| + } |
| + if errors.Is(err, carlos.ErrNotOnCarlos) && s.development { |
| + err = nil |
| + } |
| + if err == nil { |
| + s.known, s.force = true, false |
| + s.registered = due |
| + s.checked = s.now() |
| + } |
| + return due, err |
| +} |
| + |
| +func (s *scheduledWork) retry() { |
| + defer s.finishOnce.Do(func() { close(s.done) }) |
| + timer := time.NewTimer(s.reconcileEvery) |
| + defer timer.Stop() |
| + retrying := false |
| + delay := s.retryDelay |
| + for { |
| + inspect := false |
| + select { |
| + case <-s.ctx.Done(): |
| + return |
| + case <-s.wake: |
| + if retrying { |
| + continue |
| + } |
| + case <-timer.C: |
| + inspect = !retrying |
| + } |
| + _, err := s.synchronize(false, inspect) |
| + if err != nil { |
| + retrying = true |
| + timer.Reset(delay) |
| + delay = min(delay*2, 30*time.Second) |
| + } else { |
| + retrying = false |
| + delay = s.retryDelay |
| + timer.Reset(s.reconcileEvery) |
| + } |
| + } |
| +} |
| + |
| +func (s *scheduledWork) middleware(next http.Handler) http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + defer s.request() |
| + next.ServeHTTP(w, r) |
| + }) |
| +} |
diff --git a/internal/calendar/scheduled_work_test.go b/internal/calendar/scheduled_work_test.go| new file mode 100644 |
| index 0000000..96ae657 |
| --- /dev/null |
| +++ b/internal/calendar/scheduled_work_test.go |
| @@ -0,0 +1,297 @@ |
| +package calendar |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "sync" |
| + "sync/atomic" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/carlos" |
| +) |
| + |
| +type scheduleRecorder struct { |
| + mu sync.Mutex |
| + calls []time.Time |
| + fail bool |
| +} |
| + |
| +func (r *scheduleRecorder) write(ctx context.Context, at time.Time) error { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + if r.fail { |
| + return context.DeadlineExceeded |
| + } |
| + r.calls = append(r.calls, at) |
| + return nil |
| +} |
| + |
| +func (r *scheduleRecorder) snapshot() []time.Time { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return append([]time.Time(nil), r.calls...) |
| +} |
| + |
| +func testScheduledWork(t *testing.T, query func(context.Context) (time.Time, error)) (*scheduledWork, *scheduleRecorder) { |
| + t.Helper() |
| + r := &scheduleRecorder{} |
| + s := newScheduledWork(query, false, nil) |
| + s.retryDelay = time.Millisecond |
| + s.capability = func(context.Context) error { return nil } |
| + s.armed = func(context.Context) (remoteSchedule, error) { |
| + return remoteSchedule{NextAt: s.registeredAt.Unix(), DueAt: s.registeredAt.Unix(), Path: "/jobs/reminders", Kind: "once"}, nil |
| + } |
| + s.put = func(ctx context.Context, name string, at time.Time, path string) error { |
| + if name != scheduledWorkName || path != "/jobs/reminders" { |
| + t.Errorf("unexpected registration: %q %q", name, path) |
| + } |
| + return r.write(ctx, at) |
| + } |
| + s.remove = func(ctx context.Context, name string) error { return r.write(ctx, time.Time{}) } |
| + if err := s.start(); err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(s.close) |
| + return s, r |
| +} |
| + |
| +func TestScheduledWorkKeepsUnchangedRequestsFromResettingRetries(t *testing.T) { |
| + due := time.Now().Add(time.Hour).Truncate(time.Second) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return due, nil }) |
| + for range 3 { |
| + if _, err := s.sync(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if n := len(r.snapshot()); n != 1 { |
| + t.Fatalf("ordinary requests wrote %d registrations, want 1", n) |
| + } |
| + if _, err := s.sync(true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := len(r.snapshot()); n != 2 { |
| + t.Fatalf("completed delivery did not rearm the same due time: %d", n) |
| + } |
| +} |
| + |
| +func TestScheduledWorkRetriesAForcedRearmWithoutAVisitor(t *testing.T) { |
| + due := time.Now().Add(time.Hour) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return due, nil }) |
| + r.mu.Lock() |
| + r.fail = true |
| + r.mu.Unlock() |
| + if _, err := s.sync(true); err == nil { |
| + t.Fatal("registration failure was swallowed") |
| + } |
| + r.mu.Lock() |
| + r.fail = false |
| + r.mu.Unlock() |
| + deadline := time.After(time.Second) |
| + for len(r.snapshot()) != 2 { |
| + select { |
| + case <-deadline: |
| + t.Fatal("failed forced registration was never retried") |
| + case <-time.After(time.Millisecond): |
| + } |
| + } |
| + if _, err := s.sync(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if n := len(r.snapshot()); n != 2 { |
| + t.Fatalf("successful retry was repeated by ordinary request: %d", n) |
| + } |
| +} |
| + |
| +func TestScheduledWorkReadsAfterOutOfOrderRequestCompletion(t *testing.T) { |
| + var due atomic.Int64 |
| + due.Store(time.Now().Add(2 * time.Hour).Unix()) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Unix(due.Load(), 0), nil }) |
| + entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) |
| + first := s.middleware(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { |
| + due.Store(time.Now().Add(time.Hour).Unix()) |
| + close(entered) |
| + <-release |
| + })) |
| + go func() { |
| + defer close(finished) |
| + first.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/first", nil)) |
| + }() |
| + <-entered |
| + earlier := time.Now().Add(time.Minute).Unix() |
| + second := s.middleware(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { due.Store(earlier) })) |
| + second.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/second", nil)) |
| + close(release) |
| + <-finished |
| + if _, err := s.sync(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := r.snapshot() |
| + if len(calls) != 2 || calls[1].Unix() != earlier { |
| + t.Fatalf("stale request replaced the earlier registration: %v", calls) |
| + } |
| +} |
| + |
| +func TestScheduledWorkSerializesQueryWithRegistration(t *testing.T) { |
| + var due atomic.Int64 |
| + due.Store(time.Now().Add(time.Hour).Unix()) |
| + entered, release := make(chan struct{}), make(chan struct{}) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Unix(due.Load(), 0), nil }) |
| + oldPut := s.put |
| + var blocked atomic.Bool |
| + s.put = func(ctx context.Context, name string, at time.Time, path string) error { |
| + if blocked.CompareAndSwap(false, true) { |
| + close(entered) |
| + <-release |
| + } |
| + return oldPut(ctx, name, at, path) |
| + } |
| + firstDone := make(chan struct{}) |
| + go func() { defer close(firstDone); _, _ = s.sync(true) }() |
| + <-entered |
| + earlier := time.Now().Add(time.Minute).Unix() |
| + due.Store(earlier) |
| + secondDone := make(chan struct{}) |
| + go func() { defer close(secondDone); _, _ = s.sync(false) }() |
| + // The second write must not overtake the first while its control call is held. |
| + select { |
| + case <-secondDone: |
| + t.Fatal("query and registration were not serialized") |
| + case <-time.After(10 * time.Millisecond): |
| + } |
| + close(release) |
| + <-firstDone |
| + <-secondDone |
| + calls := r.snapshot() |
| + if calls[len(calls)-1].Unix() != earlier { |
| + t.Fatalf("last registration = %v", calls) |
| + } |
| +} |
| + |
| +func TestScheduledWorkQueryFailureCannotCancelOrForgetWork(t *testing.T) { |
| + var broken atomic.Bool |
| + due := time.Now().Add(-time.Minute) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { |
| + if broken.Load() { |
| + return time.Time{}, context.DeadlineExceeded |
| + } |
| + return due, nil |
| + }) |
| + broken.Store(true) |
| + got, err := s.sync(false) |
| + if err == nil || !got.Equal(due) { |
| + t.Fatalf("failed query = %v, %v", got, err) |
| + } |
| + if len(r.snapshot()) != 1 { |
| + t.Fatal("failed query cancelled the timer") |
| + } |
| +} |
| + |
| +func TestScheduledWorkFarFutureAndCancellation(t *testing.T) { |
| + var due atomic.Int64 |
| + due.Store(time.Now().Add(2 * carlos.MaxAhead).Unix()) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { |
| + if n := due.Load(); n != 0 { |
| + return time.Unix(n, 0), nil |
| + } |
| + return time.Time{}, nil |
| + }) |
| + got := r.snapshot()[0] |
| + if got.After(time.Now().Add(carlos.MaxAhead)) || got.Before(time.Now().Add(carlos.MaxAhead-2*time.Minute)) { |
| + t.Fatalf("far future recheck = %v", got) |
| + } |
| + if _, err := s.sync(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(r.snapshot()) != 1 { |
| + t.Fatal("far future registration drifted on an ordinary request") |
| + } |
| + due.Store(0) |
| + if _, err := s.sync(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if calls := r.snapshot(); len(calls) != 2 || !calls[1].IsZero() { |
| + t.Fatalf("empty queue was not cancelled: %v", calls) |
| + } |
| +} |
| + |
| +func TestScheduledWorkOffPlatformIsDevelopmentOnly(t *testing.T) { |
| + t.Setenv("CARLOS_CONTROL_SOCKET", "") |
| + for _, development := range []bool{false, true} { |
| + s := newScheduledWork(func(context.Context) (time.Time, error) { return time.Time{}, nil }, development, nil) |
| + err := s.start() |
| + if development { |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + s.close() |
| + } else if !errors.Is(err, carlos.ErrNotOnCarlos) { |
| + t.Fatalf("production startup = %v", err) |
| + } |
| + } |
| +} |
| + |
| +func TestScheduledWorkCloseCancelsAndJoinsRegistration(t *testing.T) { |
| + s, _ := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Now().Add(time.Hour), nil }) |
| + entered := make(chan struct{}) |
| + s.mu.Lock() |
| + s.put = func(ctx context.Context, name string, at time.Time, path string) error { |
| + close(entered) |
| + <-ctx.Done() |
| + return ctx.Err() |
| + } |
| + s.mu.Unlock() |
| + registered := make(chan struct{}) |
| + go func() { defer close(registered); _, _ = s.sync(true) }() |
| + <-entered |
| + closed := make(chan struct{}) |
| + go func() { s.close(); close(closed) }() |
| + select { |
| + case <-closed: |
| + case <-time.After(time.Second): |
| + t.Fatal("Close did not cancel registration") |
| + } |
| + <-registered |
| + select { |
| + case <-s.done: |
| + default: |
| + t.Fatal("retry worker outlived Close") |
| + } |
| + if _, err := s.sync(true); !errors.Is(err, context.Canceled) { |
| + t.Fatalf("registration after Close = %v", err) |
| + } |
| +} |
| + |
| +func TestScheduledWorkRepairsAnAmbiguousControlWrite(t *testing.T) { |
| + var due atomic.Int64 |
| + original := time.Now().Add(time.Hour).Unix() |
| + due.Store(original) |
| + s, r := testScheduledWork(t, func(context.Context) (time.Time, error) { return time.Unix(due.Load(), 0), nil }) |
| + oldPut := s.put |
| + var fail atomic.Bool |
| + fail.Store(true) |
| + s.put = func(ctx context.Context, name string, at time.Time, path string) error { |
| + if err := oldPut(ctx, name, at, path); err != nil { |
| + return err |
| + } |
| + if fail.Load() { |
| + return context.DeadlineExceeded |
| + } |
| + return nil |
| + } |
| + due.Store(original + 60) |
| + if _, err := s.sync(false); err == nil { |
| + t.Fatal("lost control response was ignored") |
| + } |
| + due.Store(original) |
| + fail.Store(false) |
| + if _, err := s.sync(false); err != nil { |
| + t.Fatal(err) |
| + } |
| + calls := r.snapshot() |
| + if len(calls) < 3 || calls[len(calls)-1].Unix() != original { |
| + t.Fatalf("cached original hid ambiguous replacement: %v", calls) |
| + } |
| +} |
diff --git a/internal/calendar/store_reminder_next.go b/internal/calendar/store_reminder_next.go| index b8d6076..8030acc 100644 |
| --- a/internal/calendar/store_reminder_next.go |
| +++ b/internal/calendar/store_reminder_next.go |
| @@ -44,43 +44,24 @@ func (s *Store) nextOccurrenceAfter(e Event, now int64, offsetMin int) (int64, s |
| // NextDueAt is what Options.NextDue answers: the earliest moment this |
| // instance has work. |
| // |
| -// The platform asks a running instance this before hibernating it, and |
| -// wakes it at the answer. That is what makes a reminder fire on a |
| -// machine that is asleep almost all the time — without it, a reminder |
| -// would arrive whenever the next person happened to load a page. |
| -// |
| // Zero means nothing is scheduled, and the platform hibernates |
| // indefinitely. |
| func (s *Store) NextDueAt(ctx context.Context) time.Time { |
| - var due time.Time |
| - consider := func(t time.Time) { |
| - if t.IsZero() { |
| - return |
| - } |
| - if due.IsZero() || t.Before(due) { |
| - due = t |
| - } |
| - } |
| - consider(s.nextClaimableReminder(ctx)) |
| - consider(s.NextHoldExpiry(ctx)) |
| - consider(s.NextBookingMailDue(ctx)) |
| - consider(s.NextIPForget(ctx)) |
| + due, _ := s.nextDueAt(ctx) |
| return due |
| } |
| -// nextClaimableReminder is the earliest instant a reminder row may |
| -// actually be CLAIMED: its due time, or its retry time if deferred, or |
| -// the end of its lease if somebody holds it — whichever is latest for |
| -// that row, and then the earliest across rows. Advertising a bare |
| -// due_at woke the instance for a row nobody could take for five |
| -// minutes (booking spec §8, r4 A6). |
| -func (s *Store) nextClaimableReminder(ctx context.Context) time.Time { |
| +func (s *Store) nextDueAt(ctx context.Context) (time.Time, error) { |
| var at *int64 |
| - err := s.g.WithContext(ctx).Raw(` |
| - SELECT MIN(MAX(due_at, COALESCE(retry_at, 0), COALESCE(claimed_at + ?, 0))) |
| - FROM reminder_queues WHERE fired_at IS NULL`, int64(claimLease.Seconds())).Scan(&at).Error |
| + err := s.g.WithContext(ctx).Raw(`SELECT MIN(at) FROM ( |
| + SELECT MIN(MAX(due_at, COALESCE(retry_at, 0), COALESCE(claimed_at + ?, 0))) AS at |
| + FROM reminder_queues WHERE fired_at IS NULL |
| + UNION ALL SELECT MIN(hold_expires_at) FROM bookings WHERE state = ? |
| + UNION ALL SELECT MIN(due_at) FROM booking_mails WHERE sent_at IS NULL |
| + UNION ALL SELECT MIN(ip_forget_at) FROM bookings WHERE ip_fp IS NOT NULL |
| + )`, int64(claimLease.Seconds()), BookingHeld).Scan(&at).Error |
| if err != nil || at == nil { |
| - return time.Time{} |
| + return time.Time{}, err |
| } |
| - return time.Unix(*at, 0) |
| + return time.Unix(*at, 0), nil |
| } |
diff --git a/internal/calendartest/reminder_test.go b/internal/calendartest/reminder_test.go| index 2adf293..75d01b1 100644 |
| --- a/internal/calendartest/reminder_test.go |
| +++ b/internal/calendartest/reminder_test.go |
| @@ -122,7 +122,7 @@ func TestNextDuePointsAtTheEarliestReminder(t *testing.T) { |
| app, session := signedIn(t, "someone@example.com") |
| h := app.Handler() |
| - if due := app.NextDue(); !due.IsZero() { |
| + if due := settledNextDue(t, app); !due.IsZero() { |
| t.Errorf("NextDue on an empty calendar = %v, want the zero time", due) |
| } |
| @@ -136,7 +136,7 @@ func TestNextDuePointsAtTheEarliestReminder(t *testing.T) { |
| "repeat": {"none"}, |
| }) |
| - due := app.NextDue() |
| + due := settledNextDue(t, app) |
| if due.IsZero() { |
| t.Fatal("NextDue is zero with a reminder queued; the platform would hibernate through it") |
| } |
| @@ -171,7 +171,7 @@ func TestARecurringReminderRequeuesItself(t *testing.T) { |
| // After firing, the rule's next occurrence must be queued — |
| // roughly a day later. |
| - due := app.NextDue() |
| + due := settledNextDue(t, app) |
| if due.IsZero() { |
| t.Fatal("a repeating reminder fired and queued nothing; the series would remind once and stop") |
| } |
| @@ -181,3 +181,15 @@ func TestARecurringReminderRequeuesItself(t *testing.T) { |
| } |
| var _ = calendar.MaxReminderAttempts |
| + |
| +func settledNextDue(t *testing.T, app *calendar.App) time.Time { |
| + t.Helper() |
| + deadline := time.Now().Add(time.Second) |
| + for { |
| + due := app.NextDue() |
| + if due.IsZero() || due.After(time.Now().Add(time.Second)) || time.Now().After(deadline) { |
| + return due |
| + } |
| + time.Sleep(time.Millisecond) |
| + } |
| +} |
diff --git a/internal/calendartest/schedule_control_test.go b/internal/calendartest/schedule_control_test.go| new file mode 100644 |
| index 0000000..7e5a9f4 |
| --- /dev/null |
| +++ b/internal/calendartest/schedule_control_test.go |
| @@ -0,0 +1,116 @@ |
| +package calendartest |
| + |
| +import ( |
| + "encoding/json" |
| + "net" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +type controlSchedules struct { |
| + mu sync.Mutex |
| + at time.Time |
| + writes int |
| + fail bool |
| + unsupported bool |
| +} |
| + |
| +func controlForTest(t *testing.T) *controlSchedules { |
| + t.Helper() |
| + dir, err := os.MkdirTemp("/tmp", "cal-control-") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(dir) }) |
| + socket := filepath.Join(dir, "control.sock") |
| + listener, err := net.Listen("unix", socket) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + state := &controlSchedules{} |
| + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + state.mu.Lock() |
| + defer state.mu.Unlock() |
| + if r.Header.Get("Authorization") != "Bearer test-token" { |
| + http.Error(w, "forbidden", 403) |
| + return |
| + } |
| + if state.fail { |
| + http.Error(w, "retry", 503) |
| + return |
| + } |
| + if r.URL.Path == "/schedule-capabilities" { |
| + if state.unsupported { |
| + w.WriteHeader(404) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]bool{"registration_fence": true}) |
| + return |
| + } |
| + if r.URL.Path == "/schedule" { |
| + var timers []map[string]any |
| + if !state.at.IsZero() { |
| + timers = append(timers, map[string]any{"name": "calendar-next-due", "next_at": state.at.Unix(), "due_at": state.at.Unix(), "kind": "once", "path": "/jobs/reminders"}) |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"timers": timers}) |
| + return |
| + } |
| + if r.URL.Path != "/schedule/calendar-next-due" { |
| + w.WriteHeader(404) |
| + return |
| + } |
| + switch r.Method { |
| + case http.MethodPut: |
| + var body struct { |
| + At time.Time |
| + Path string |
| + } |
| + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { |
| + t.Error(err) |
| + w.WriteHeader(400) |
| + return |
| + } |
| + if body.Path != "/jobs/reminders" { |
| + t.Errorf("scheduled path = %q", body.Path) |
| + } |
| + state.at = body.At |
| + case http.MethodDelete: |
| + state.at = time.Time{} |
| + default: |
| + w.WriteHeader(405) |
| + return |
| + } |
| + state.writes++ |
| + w.WriteHeader(204) |
| + })} |
| + go server.Serve(listener) |
| + t.Cleanup(func() { server.Close() }) |
| + t.Setenv("CARLOS_CONTROL_SOCKET", socket) |
| + t.Setenv("CARLOS_ADMIN_TOKEN", "test-token") |
| + return state |
| +} |
| + |
| +func (s *controlSchedules) snapshot() (time.Time, int) { |
| + s.mu.Lock() |
| + defer s.mu.Unlock() |
| + return s.at, s.writes |
| +} |
| + |
| +func (s *controlSchedules) waitArmed(t *testing.T) { |
| + t.Helper() |
| + deadline := time.After(2 * time.Second) |
| + for { |
| + if at, _ := s.snapshot(); !at.IsZero() { |
| + return |
| + } |
| + select { |
| + case <-deadline: |
| + t.Fatal("timer was not armed") |
| + case <-time.After(time.Millisecond): |
| + } |
| + } |
| +} |
diff --git a/internal/calendartest/schedule_delivery_test.go b/internal/calendartest/schedule_delivery_test.go| new file mode 100644 |
| index 0000000..cdbeba8 |
| --- /dev/null |
| +++ b/internal/calendartest/schedule_delivery_test.go |
| @@ -0,0 +1,200 @@ |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| + "github.com/carlosframework/rastrillo/carlos" |
| +) |
| + |
| +func TestPersistedWorkRegistersAtBootBeforeAnyRequest(t *testing.T) { |
| + control := controlForTest(t) |
| + dir := t.TempDir() |
| + app := calendar.NewForTest(t, dir) |
| + due := time.Now().Add(-time.Minute).Unix() |
| + if _, err := app.DBForTest().Exec(`INSERT INTO reminder_queues (reminder_id, occurrence_key, due_at, attempts, late_by_sec) VALUES (1, 'boot', ?, 0, 0)`, due); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := app.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + app = calendar.NewForTest(t, dir) |
| + at, _ := control.snapshot() |
| + if at.Unix() < due || at.After(time.Now()) || at.IsZero() { |
| + t.Fatalf("boot did not register persisted work: got %v, want %d", at, due) |
| + } |
| + if body, _ := app.LastMailForTest(); body != "" { |
| + t.Fatal("startup delivered mail outside a held request") |
| + } |
| +} |
| + |
| +func TestScheduledHTTPDeliversWithoutAVisitorAndRearmsRecurrence(t *testing.T) { |
| + control := controlForTest(t) |
| + app, session := signedIn(t, "someone@example.com") |
| + start := time.Now().UTC().Add(2 * time.Minute) |
| + createEvent(t, app.Handler(), session, url.Values{ |
| + "title": {"Scheduled standup"}, "start": {start.Format("2006-01-02T15:04")}, "end": {start.Add(15 * time.Minute).Format("2006-01-02T15:04")}, |
| + "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"daily"}, |
| + }) |
| + expected := app.StoreForTest().NextDueAt(context.Background()) |
| + control.waitArmed(t) |
| + before, _ := control.snapshot() |
| + if before.Before(expected) || before.After(time.Now()) { |
| + t.Fatalf("event POST failed to arm its overdue reminder: %v, expected %v", before, expected) |
| + } |
| + req := httptest.NewRequest(http.MethodPost, "/jobs/reminders", nil) |
| + req.Header.Set("Authorization", "Bearer test-token") |
| + req.Header.Set("X-Carlos-Schedule", "calendar-next-due") |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusNoContent { |
| + t.Fatalf("scheduled request = %d: %s", rec.Code, rec.Body.String()) |
| + } |
| + body, _ := app.LastMailForTest() |
| + if !strings.Contains(body, "Scheduled standup") { |
| + t.Fatal("scheduled HTTP did not deliver the reminder") |
| + } |
| + next, _ := control.snapshot() |
| + expected = app.StoreForTest().NextDueAt(context.Background()) |
| + if !next.Equal(expected) || !next.After(time.Now()) { |
| + t.Fatalf("next recurrence not armed before completion: %v, expected %v", next, expected) |
| + } |
| +} |
| + |
| +func TestPublicBookingPostRegistersItsHoldAndMail(t *testing.T) { |
| + control := controlForTest(t) |
| + app, _, path, _ := publishedPage(t) |
| + _, starts := futureDaySlots(t, app.Handler(), path) |
| + strangerPost(t, app.Handler(), path, holdForm(starts[0], "A", "a@example.com")) |
| + expected := app.StoreForTest().NextDueAt(context.Background()) |
| + control.waitArmed(t) |
| + at, _ := control.snapshot() |
| + if expected.IsZero() || !at.Equal(expected) { |
| + t.Fatalf("public booking did not register scheduled work: got %v, expected %v", at, expected) |
| + } |
| +} |
| + |
| +func TestTickRegistrationFailureReturnsRetryAndRecoversWithoutAVisitor(t *testing.T) { |
| + control := controlForTest(t) |
| + app := calendar.NewForTest(t, t.TempDir()) |
| + due := time.Now().Add(time.Hour).Unix() |
| + if _, err := app.DBForTest().Exec(`INSERT INTO reminder_queues (reminder_id, occurrence_key, due_at, attempts, late_by_sec) VALUES (1, 'retry', ?, 0, 0)`, due); err != nil { |
| + t.Fatal(err) |
| + } |
| + control.mu.Lock() |
| + control.fail = true |
| + control.mu.Unlock() |
| + rec := tick(t, app.Handler()) |
| + if rec.Code != http.StatusInternalServerError || strings.TrimSpace(rec.Body.String()) != "retry" { |
| + t.Fatalf("registration failure acknowledged: %d %s", rec.Code, rec.Body.String()) |
| + } |
| + control.mu.Lock() |
| + control.fail = false |
| + control.mu.Unlock() |
| + deadline := time.After(4 * time.Second) |
| + for { |
| + at, _ := control.snapshot() |
| + if at.Unix() == due { |
| + break |
| + } |
| + select { |
| + case <-deadline: |
| + t.Fatal("registration did not recover without a visitor") |
| + case <-time.After(10 * time.Millisecond): |
| + } |
| + } |
| +} |
| + |
| +func TestDatabaseFailureCannotCancelTheTimer(t *testing.T) { |
| + control := controlForTest(t) |
| + app := calendar.NewForTest(t, t.TempDir()) |
| + due := time.Now().Add(time.Hour).Unix() |
| + if _, err := app.DBForTest().Exec(`INSERT INTO reminder_queues (reminder_id, occurrence_key, due_at, attempts, late_by_sec) VALUES (1, 'failure', ?, 0, 0)`, due); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := app.NextDue(); got.Unix() != due { |
| + t.Fatal(got) |
| + } |
| + if _, err := app.DBForTest().Exec(`DROP TABLE booking_mails`); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := app.NextDue() |
| + at, _ := control.snapshot() |
| + if got.IsZero() || got.After(time.Now()) || at.Unix() != due { |
| + t.Fatalf("failed due query erased wake: probe %v, timer %v", got, at) |
| + } |
| +} |
| + |
| +func TestProductionRefusesMissingControlSocket(t *testing.T) { |
| + t.Setenv("CARLOS_CONTROL_SOCKET", "") |
| + _, err := calendar.New(calendar.Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "production", Origin: calendar.TestOrigin, |
| + InstanceKey: "01234567890123456789012345678901", DEK: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", AllowedAddresses: "person@example.com", |
| + Mailer: &pickyMailer{}, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}) |
| + if !errors.Is(err, carlos.ErrNotOnCarlos) { |
| + t.Fatalf("production startup error = %v, want missing platform scheduling", err) |
| + } |
| +} |
| + |
| +func TestScheduledHTTPRearmsAFullSweepBacklog(t *testing.T) { |
| + control := controlForTest(t) |
| + app := calendar.NewForTest(t, t.TempDir()) |
| + due := time.Now().Add(-time.Minute).Unix() |
| + _, err := app.DBForTest().Exec(`WITH RECURSIVE rows(n) AS (SELECT 1 UNION ALL SELECT n+1 FROM rows WHERE n<501) |
| + INSERT INTO bookings (page_id,event_id,token_hash,state,booker_address_fp,booker_zone,hold_expires_at,cancelled_by,ip_fp,ip_forget_at,reschedules,created_at,updated_at) |
| + SELECT 0,0,CAST(n AS TEXT),'expired','','UTC',0,'','test',?,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP FROM rows`, due) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec := tick(t, app.Handler()); rec.Code != http.StatusNoContent { |
| + t.Fatalf("tick = %d", rec.Code) |
| + } |
| + var remaining int |
| + if err := app.DBForTest().QueryRow(`SELECT COUNT(*) FROM bookings WHERE ip_fp IS NOT NULL`).Scan(&remaining); err != nil { |
| + t.Fatal(err) |
| + } |
| + at, _ := control.snapshot() |
| + if remaining != 1 || at.Unix() < due || at.After(time.Now()) || at.IsZero() { |
| + t.Fatalf("backlog = %d, timer = %v", remaining, at) |
| + } |
| + if rec := tick(t, app.Handler()); rec.Code != http.StatusNoContent { |
| + t.Fatalf("second tick = %d", rec.Code) |
| + } |
| + if at, _ := control.snapshot(); !at.IsZero() { |
| + t.Fatalf("finished backlog remains armed at %v", at) |
| + } |
| +} |
| + |
| +func TestScheduledHTTPKeepsRetryAndLeaseDeadlines(t *testing.T) { |
| + control := controlForTest(t) |
| + app := calendar.NewForTest(t, t.TempDir()) |
| + now := time.Now().Unix() |
| + _, err := app.DBForTest().Exec(`INSERT INTO reminder_queues (reminder_id,occurrence_key,due_at,claimed_at,retry_at,attempts,late_by_sec) VALUES (1,'leased',?,?,?,0,0)`, now-60, now, now+600) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec := tick(t, app.Handler()); rec.Code != http.StatusNoContent { |
| + t.Fatalf("tick = %d", rec.Code) |
| + } |
| + if at, _ := control.snapshot(); at.Unix() != now+600 { |
| + t.Fatalf("retry time lost: %v", at) |
| + } |
| + if _, err := app.DBForTest().Exec(`UPDATE reminder_queues SET retry_at = NULL`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec := tick(t, app.Handler()); rec.Code != http.StatusNoContent { |
| + t.Fatalf("second tick = %d", rec.Code) |
| + } |
| + if at, _ := control.snapshot(); at.Unix() != now+300 { |
| + t.Fatalf("lease time lost: %v", at) |
| + } |
| +} |
diff --git a/internal/calendartest/schedule_progress_test.go b/internal/calendartest/schedule_progress_test.go| new file mode 100644 |
| index 0000000..fe0811f |
| --- /dev/null |
| +++ b/internal/calendartest/schedule_progress_test.go |
| @@ -0,0 +1,49 @@ |
| +package calendartest |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +func TestSuccessfulClaimRaceWithoutProgressBacksOff(t *testing.T) { |
| + control := controlForTest(t) |
| + app := calendar.NewForTest(t, t.TempDir()) |
| + due := time.Now().Add(-time.Minute).Unix() |
| + if _, err := app.DBForTest().Exec(`INSERT INTO reminder_queues (reminder_id,occurrence_key,due_at,attempts,late_by_sec) VALUES (1,'unclaimable',?,0,0)`, due); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := app.DBForTest().Exec(`CREATE TRIGGER decline_claim BEFORE UPDATE OF claimed_at ON reminder_queues BEGIN SELECT RAISE(IGNORE); END`); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := time.Now() |
| + if rec := tick(t, app.Handler()); rec.Code != http.StatusNoContent { |
| + t.Fatalf("claim race tick = %d", rec.Code) |
| + } |
| + if at, _ := control.snapshot(); at.Before(before.Add(time.Minute - time.Second)) { |
| + t.Fatalf("no-progress successful drain rearmed immediately: %v", at) |
| + } |
| +} |
| + |
| +func TestRepeatedOccurrenceGuardFailsEvenAfterASuccessfulSend(t *testing.T) { |
| + control := controlForTest(t) |
| + app, session := signedIn(t, "someone@example.com") |
| + now := time.Now().UTC() |
| + for _, offset := range []time.Duration{time.Hour, 2 * time.Hour} { |
| + start := now.Add(offset) |
| + createEvent(t, app.Handler(), session, url.Values{"title": {"Guarded reminder"}, "start": {start.Format("2006-01-02T15:04")}, "end": {start.Add(15 * time.Minute).Format("2006-01-02T15:04")}, "kind": {"meeting"}, "status": {"confirmed"}, "repeat": {"none"}}) |
| + } |
| + if _, err := app.DBForTest().Exec(`UPDATE reminder_queues SET occurrence_key='same', due_at=?`, now.Add(-time.Minute).Unix()); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := time.Now() |
| + if rec := tick(t, app.Handler()); rec.Code != http.StatusInternalServerError { |
| + t.Fatalf("repeated occurrence was acknowledged despite a prior send: %d", rec.Code) |
| + } |
| + if at, _ := control.snapshot(); at.Before(before.Add(time.Minute - time.Second)) { |
| + t.Fatalf("stalled occurrence did not back off: %v", at) |
| + } |
| +} |
diff --git a/internal/calendartest/schedule_recovery_test.go b/internal/calendartest/schedule_recovery_test.go| new file mode 100644 |
| index 0000000..4c2699d |
| --- /dev/null |
| +++ b/internal/calendartest/schedule_recovery_test.go |
| @@ -0,0 +1,84 @@ |
| +package calendartest |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + calendar "amadan.net/oficina/calendar/internal/calendar" |
| +) |
| + |
| +func TestTransientControlFailureAtBootRecovers(t *testing.T) { |
| + control := controlForTest(t) |
| + control.mu.Lock() |
| + control.fail = true |
| + control.mu.Unlock() |
| + app := calendar.NewForTest(t, t.TempDir()) |
| + due := time.Now().Add(time.Hour).Unix() |
| + if _, err := app.DBForTest().Exec(`INSERT INTO reminder_queues (reminder_id,occurrence_key,due_at,attempts,late_by_sec) VALUES (1,'boot-retry',?,0,0)`, due); err != nil { |
| + t.Fatal(err) |
| + } |
| + control.mu.Lock() |
| + control.fail = false |
| + control.mu.Unlock() |
| + control.waitArmed(t) |
| + if at, _ := control.snapshot(); at.Unix() != due { |
| + t.Fatalf("recovered timer = %v", at) |
| + } |
| +} |
| + |
| +func TestOldPlatformRefusesStartupBeforeWritingATimer(t *testing.T) { |
| + control := controlForTest(t) |
| + control.mu.Lock() |
| + control.unsupported = true |
| + control.mu.Unlock() |
| + app, err := calendar.New(calendar.Config{Env: "development", DBPath: t.TempDir() + "/calendar.db", Origin: calendar.TestOrigin}) |
| + if err == nil { |
| + app.Close() |
| + t.Fatal("old platform accepted") |
| + } |
| + if _, writes := control.snapshot(); writes != 0 { |
| + t.Fatalf("unsupported platform received %d writes", writes) |
| + } |
| +} |
| + |
| +func TestReadOnlyDrainFailureBacksOffWithoutOrdinaryRequestsBypassingIt(t *testing.T) { |
| + control := controlForTest(t) |
| + app := calendar.NewForTest(t, t.TempDir()) |
| + _, err := app.DBForTest().Exec(`INSERT INTO bookings (page_id,event_id,token_hash,state,booker_address_fp,booker_zone,hold_expires_at,cancelled_by,ip_fp,ip_forget_at,reschedules,created_at,updated_at) |
| + VALUES (0,0,'failure','expired','','UTC',0,'','test',?,0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`, time.Now().Add(-time.Minute).Unix()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + _, err = app.DBForTest().Exec(`CREATE TRIGGER prevent_forget BEFORE UPDATE OF ip_fp ON bookings BEGIN SELECT RAISE(FAIL,'test write failure'); END`) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, delay := range []time.Duration{time.Minute, 2 * time.Minute} { |
| + before := time.Now() |
| + rec := tick(t, app.Handler()) |
| + if rec.Code != http.StatusInternalServerError { |
| + t.Fatalf("write failure status = %d", rec.Code) |
| + } |
| + due, _ := control.snapshot() |
| + if due.Before(before.Add(delay-time.Second)) || due.After(time.Now().Add(delay)) { |
| + t.Fatalf("drain failure did not back off %v: %v", delay, due) |
| + } |
| + if got := app.NextDue(); got.Before(due) { |
| + t.Fatalf("NextDue bypassed failure floor: %v < %v", got, due) |
| + } |
| + if raw := app.StoreForTest().NextDueAt(context.Background()); raw.IsZero() || raw.After(time.Now()) { |
| + t.Fatalf("fixture is not read-success/write-failure: %v", raw) |
| + } |
| + } |
| + if _, err := app.DBForTest().Exec(`DROP TRIGGER prevent_forget`); err != nil { |
| + t.Fatal(err) |
| + } |
| + if rec := tick(t, app.Handler()); rec.Code != http.StatusNoContent { |
| + t.Fatalf("recovery = %d", rec.Code) |
| + } |
| + if at, _ := control.snapshot(); !at.IsZero() { |
| + t.Fatalf("successful drain kept failure floor: %v", at) |
| + } |
| +} |