diff --git a/internal/config/options.go b/internal/config/options.go index be8768a603e..d2431c33280 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -504,6 +504,14 @@ func NewConfigOptions() *configOptions { return validateChoices(rawValue, []string{"round_robin", "entry_frequency"}) }, }, + "POLLING_JITTER": { + ParsedDuration: 10 * time.Minute, + RawValue: "10", + ValueType: minuteType, + Validator: func(rawValue string) error { + return validateGreaterOrEqualThan(rawValue, 1) + }, + }, "PORT": { ParsedStringValue: "", RawValue: "", @@ -902,6 +910,10 @@ func (c *configOptions) PollingScheduler() string { return c.options["POLLING_SCHEDULER"].ParsedStringValue } +func (c *configOptions) PollingJitter() time.Duration { + return c.options["POLLING_JITTER"].ParsedDuration +} + func (c *configOptions) Port() string { return c.options["PORT"].ParsedStringValue } diff --git a/internal/model/feed.go b/internal/model/feed.go index 5e8a7aaae95..fbfdeea279f 100644 --- a/internal/model/feed.go +++ b/internal/model/feed.go @@ -6,6 +6,7 @@ package model // import "miniflux.app/v2/internal/model" import ( "fmt" "io" + "math/rand" "time" "miniflux.app/v2/internal/config" @@ -143,6 +144,20 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) ti interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval()) } + // Apply a small random jitter to spread next checks and reduce thundering herds. + jitterMax := config.Opts.PollingJitter() + + randomJitter := time.Duration(rand.Int63n(int64(jitterMax + 1))) + interval += randomJitter + + // Re-apply max clamping after randomJitter to avoid exceeding configured caps. + switch config.Opts.PollingScheduler() { + case SchedulerRoundRobin: + interval = min(interval, config.Opts.SchedulerRoundRobinMaxInterval()) + case SchedulerEntryFrequency: + interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval()) + } + f.NextCheckAt = time.Now().Add(interval) return interval } diff --git a/internal/model/feed_test.go b/internal/model/feed_test.go index b5094870e6e..a8851db1ab3 100644 --- a/internal/model/feed_test.go +++ b/internal/model/feed_test.go @@ -68,12 +68,15 @@ func TestFeedCheckedNow(t *testing.T) { } func checkTargetInterval(t *testing.T, feed *Feed, targetInterval time.Duration, timeBefore time.Time, message string) { - if feed.NextCheckAt.Before(timeBefore.Add(targetInterval)) { - t.Errorf(`The next_check_at should be after timeBefore + %s`, message) - } - if feed.NextCheckAt.After(time.Now().Add(targetInterval)) { - t.Errorf(`The next_check_at should be before now + %s`, message) - } + // Allow a positive jitter up to 10 minutes added by the scheduler. + jitterMax := 10 * time.Minute + + if feed.NextCheckAt.Before(timeBefore.Add(targetInterval)) { + t.Errorf(`The next_check_at should be after timeBefore + %s`, message) + } + if feed.NextCheckAt.After(time.Now().Add(targetInterval + jitterMax)) { + t.Errorf(`The next_check_at should be before now + %s (with jitter)`, message) + } } func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {