package cron import ( "context" "errors" "io" "log/slog" "sync/atomic" "testing" "time" ) // Intervals here are tens of milliseconds: long enough that scheduler jitter // cannot invert an assertion, short enough that the whole file runs in well // under a second. func discard() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } // The first run must be one interval after start, never at startup — the // deploy hook already warmed the cache, so a boot-time run is pure waste. func TestFirstRunIsDeferredOneInterval(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) defer cancel() var runs atomic.Int32 done := make(chan struct{}) go func() { defer close(done) Run(ctx, discard(), Job{ Name: "test", Interval: 60 * time.Millisecond, Run: func(context.Context) error { runs.Add(1) return nil }, }) }() // Well inside the first interval: nothing may have run yet. time.Sleep(20 * time.Millisecond) if got := runs.Load(); got != 0 { t.Fatalf("job ran %d time(s) before the first interval elapsed; first run must be deferred", got) } // Well past the first interval: it must have run by now. deadline := time.After(500 * time.Millisecond) for runs.Load() == 0 { select { case <-deadline: t.Fatal("job never ran after the first interval elapsed") case <-time.After(5 * time.Millisecond): } } cancel() <-done } // A slow run must not overlap with itself, and ticks that fired mid-run must // be skipped, not queued — an immediate back-to-back run would double-spend // the archive-fetch quota just like a concurrent one. func TestSlowJobNeverOverlapsAndSkipsMissedTicks(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) defer cancel() const interval = 30 * time.Millisecond var inFlight, maxInFlight, runs atomic.Int32 release := make(chan struct{}) started := make(chan struct{}, 16) done := make(chan struct{}) go func() { defer close(done) Run(ctx, discard(), Job{ Name: "slow", Interval: interval, Run: func(context.Context) error { n := inFlight.Add(1) for { m := maxInFlight.Load() if n <= m || maxInFlight.CompareAndSwap(m, n) { break } } runs.Add(1) started <- struct{}{} <-release // block until the test lets each run finish inFlight.Add(-1) return nil }, }) }() // First run starts; hold it across several intervals. <-started time.Sleep(4 * interval) if got := maxInFlight.Load(); got != 1 { t.Fatalf("job overlapped with itself: max in-flight = %d", got) } if got := runs.Load(); got != 1 { t.Fatalf("expected exactly 1 run while the first is still blocked, got %d", got) } release <- struct{}{} // After release the schedule resumes, but the ticks missed during the // block must have been dropped: the next run arrives roughly one interval // later, and only one more within that window (not a burst of catch-ups). select { case <-started: case <-time.After(500 * time.Millisecond): t.Fatal("job never resumed after the blocked run finished") } if got := runs.Load(); got != 2 { t.Fatalf("missed ticks were replayed as a burst: %d runs total, want 2", got) } release <- struct{}{} cancel() <-done if got := maxInFlight.Load(); got != 1 { t.Fatalf("job overlapped with itself: max in-flight = %d", got) } } // One failed tick must never kill the ticker — nothing restarts the schedule // short of a container restart. func TestFailedTickDoesNotStopSchedule(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) defer cancel() var runs atomic.Int32 done := make(chan struct{}) go func() { defer close(done) Run(ctx, discard(), Job{ Name: "flaky", Interval: 20 * time.Millisecond, Run: func(context.Context) error { if runs.Add(1) == 1 { return errors.New("backend briefly down") } return nil }, }) }() deadline := time.After(1 * time.Second) for runs.Load() < 3 { select { case <-deadline: t.Fatalf("schedule stalled after a failure: only %d run(s)", runs.Load()) case <-time.After(5 * time.Millisecond): } } cancel() <-done } // Cancellation must stop Run promptly even while a job is mid-call: the job // callbacks are ctx-aware HTTP calls, so cancelling the context aborts the // in-flight request and the loop must then exit instead of ticking again. func TestCancelStopsRunWhileJobInFlight(t *testing.T) { t.Parallel() ctx, cancel := context.WithCancel(context.Background()) started := make(chan struct{}) done := make(chan struct{}) go func() { defer close(done) Run(ctx, discard(), Job{ Name: "blocking", Interval: 10 * time.Millisecond, Run: func(jobCtx context.Context) error { close(started) <-jobCtx.Done() // behaves like an HTTP call aborted by cancellation return jobCtx.Err() }, }, // A second, idle job proves Run waits for ALL jobs to stop. Job{ Name: "idle", Interval: time.Hour, Run: func(context.Context) error { return nil }, }, ) }() <-started cancel() select { case <-done: case <-time.After(1 * time.Second): t.Fatal("Run did not return promptly after context cancellation") } }