-
Notifications
You must be signed in to change notification settings - Fork 7
Fix background feed refresh stopping after the first tick #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,57 +1,61 @@ | ||
| package job | ||
|
|
||
| import "time" | ||
| import ( | ||
| "slices" | ||
| "time" | ||
| ) | ||
|
|
||
| type Job interface { | ||
| Execute() | ||
| } | ||
|
|
||
| type Scheduler struct { | ||
| Queue chan Job | ||
| Interval time.Duration | ||
| jobs []Job | ||
| interval time.Duration | ||
| quit chan struct{} | ||
| done chan struct{} | ||
| } | ||
|
|
||
| func NewScheduler(interval time.Duration) *Scheduler { | ||
| // NewScheduler copies jobs so that a caller spreading its own slice cannot | ||
| // mutate the job list afterwards. The worker reads it without a lock. | ||
| func NewScheduler(interval time.Duration, jobs ...Job) *Scheduler { | ||
| return &Scheduler{ | ||
| Queue: make(chan Job), | ||
| Interval: interval, | ||
| jobs: slices.Clone(jobs), | ||
| interval: interval, | ||
| quit: make(chan struct{}), | ||
| done: make(chan struct{}), | ||
| } | ||
| } | ||
|
|
||
| // Start runs every job once, then again on each tick, until Stop is called. | ||
| func (s *Scheduler) Start() { | ||
| go func() { | ||
| defer close(s.done) | ||
| ticker := time.NewTicker(s.Interval) | ||
| ticker := time.NewTicker(s.interval) | ||
| defer ticker.Stop() | ||
|
|
||
| s.runAll() | ||
|
|
||
| for { | ||
| select { | ||
| case <-s.quit: | ||
| return | ||
| case job := <-s.Queue: | ||
| job.Execute() | ||
| case <-ticker.C: | ||
| for job := range s.Queue { | ||
| job.Execute() | ||
| } | ||
| s.runAll() | ||
| } | ||
| } | ||
| }() | ||
| } | ||
|
|
||
| func (s *Scheduler) runAll() { | ||
| for _, j := range s.jobs { | ||
| j.Execute() | ||
| } | ||
| } | ||
|
|
||
| // Stop signals the worker to exit and waits for any in-flight job to finish. | ||
| // Safe to call once; subsequent calls will panic on the close of quit. | ||
| func (s *Scheduler) Stop() { | ||
| close(s.quit) | ||
| <-s.done | ||
| } | ||
| func (s *Scheduler) ScheduleOnce(duration time.Duration, job Job) { | ||
| go func() { | ||
| time.Sleep(duration) | ||
| s.Queue <- job | ||
| }() | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| package job_test | ||
|
|
||
| import ( | ||
| "sync/atomic" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
|
|
||
| "github.com/cubny/lite-reader/internal/infra/job" | ||
| ) | ||
|
|
||
| const tick = 50 * time.Millisecond | ||
|
|
||
| // countingJob records how many times it ran, and optionally blocks for a fixed | ||
| // duration to simulate a long-running refresh. | ||
| type countingJob struct { | ||
| runs atomic.Int32 | ||
| block time.Duration | ||
| } | ||
|
|
||
| func (c *countingJob) Execute() { | ||
| c.runs.Add(1) | ||
| if c.block > 0 { | ||
| time.Sleep(c.block) | ||
| } | ||
| } | ||
|
|
||
| func TestScheduler_RunsJobsAtStart(t *testing.T) { | ||
| j := &countingJob{} | ||
| s := job.NewScheduler(time.Hour, j) | ||
| s.Start() | ||
| defer s.Stop() | ||
|
|
||
| assert.Eventually(t, func() bool { | ||
| return j.runs.Load() == 1 | ||
| }, time.Second, 5*time.Millisecond, "job should run once at boot without waiting for a tick") | ||
| } | ||
|
|
||
| // Regression test for the scheduler that stopped firing after the first tick, | ||
| // leaving feeds stale indefinitely. | ||
| func TestScheduler_RunsJobsOnEveryTick(t *testing.T) { | ||
| j := &countingJob{} | ||
| s := job.NewScheduler(tick, j) | ||
| s.Start() | ||
| defer s.Stop() | ||
|
|
||
| assert.Eventually(t, func() bool { | ||
| return j.runs.Load() >= 4 | ||
| }, 2*time.Second, tick/2, "job should keep running on every tick, got %d runs", j.runs.Load()) | ||
| } | ||
|
|
||
| func TestScheduler_RunsAllJobs(t *testing.T) { | ||
| first, second := &countingJob{}, &countingJob{} | ||
| s := job.NewScheduler(tick, first, second) | ||
| s.Start() | ||
| defer s.Stop() | ||
|
|
||
| assert.Eventually(t, func() bool { | ||
| return first.runs.Load() >= 2 && second.runs.Load() >= 2 | ||
| }, 2*time.Second, tick/2, "every registered job should run on each tick") | ||
| } | ||
|
|
||
| // The worker reads the job list without a lock, so the scheduler must not | ||
| // alias a slice the caller can still write to. | ||
| func TestScheduler_DoesNotAliasCallerSlice(t *testing.T) { | ||
| registered, swapped := &countingJob{}, &countingJob{} | ||
| jobs := []job.Job{registered} | ||
|
|
||
| s := job.NewScheduler(time.Hour, jobs...) | ||
| jobs[0] = swapped // caller mutates its slice after construction | ||
| s.Start() | ||
| defer s.Stop() | ||
|
|
||
| assert.Eventually(t, func() bool { | ||
| return registered.runs.Load() == 1 | ||
| }, time.Second, 5*time.Millisecond, "scheduler should run the job it was constructed with") | ||
| assert.Equal(t, int32(0), swapped.runs.Load(), "scheduler must not observe the caller's later write") | ||
| } | ||
|
|
||
| // Regression test for the shutdown hang: once a tick had fired, the worker | ||
| // parked on a channel it never drained and stopped observing quit, so Stop | ||
| // blocked forever and fly.io had to SIGKILL the machine on every deploy. | ||
| func TestScheduler_StopReturnsAfterTicksHaveFired(t *testing.T) { | ||
| j := &countingJob{} | ||
| s := job.NewScheduler(tick, j) | ||
| s.Start() | ||
|
|
||
| assert.Eventually(t, func() bool { | ||
| return j.runs.Load() >= 2 | ||
| }, 2*time.Second, tick/2, "precondition: at least one tick must fire before Stop") | ||
|
|
||
| stopped := make(chan struct{}) | ||
| go func() { | ||
| s.Stop() | ||
| close(stopped) | ||
| }() | ||
|
|
||
| select { | ||
| case <-stopped: | ||
| case <-time.After(2 * time.Second): | ||
| t.Fatal("Stop deadlocked after a tick had fired") | ||
| } | ||
| } | ||
|
|
||
| func TestScheduler_StopWaitsForInFlightJob(t *testing.T) { | ||
| j := &countingJob{block: 200 * time.Millisecond} | ||
| s := job.NewScheduler(time.Hour, j) | ||
| s.Start() | ||
|
|
||
| // The boot run is in flight by now; Stop must not cut it short. | ||
| start := time.Now() | ||
| s.Stop() | ||
|
|
||
| assert.GreaterOrEqual(t, time.Since(start), 100*time.Millisecond, | ||
| "Stop should wait for the in-flight job to finish") | ||
| assert.Equal(t, int32(1), j.runs.Load()) | ||
| } | ||
|
|
||
| func TestScheduler_StopIsSafeBeforeAnyTick(t *testing.T) { | ||
| j := &countingJob{} | ||
| s := job.NewScheduler(time.Hour, j) | ||
| s.Start() | ||
|
|
||
| stopped := make(chan struct{}) | ||
| go func() { | ||
| s.Stop() | ||
| close(stopped) | ||
| }() | ||
|
|
||
| select { | ||
| case <-stopped: | ||
| case <-time.After(2 * time.Second): | ||
| t.Fatal("Stop deadlocked before the first tick") | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch — fixed in
6662e32.
You're right that the lock-free read of
jobsdepends on the list being immutable after construction, and storing the variadic slice directly only made that true by coincidence: no current caller spreads a slice, so nothing can alias it today. That's a fragile thing to rest the no-mutex design on, soNewSchedulernow doesslices.Clone(jobs).Added
TestScheduler_DoesNotAliasCallerSliceas a guard. It constructs withjobs..., has the caller overwritejobs[0]immediately after, and asserts the scheduler still runs the originally-registered job. Verified it's a real guard: red without the clone (the scheduler ran the swapped-in job), green with it.