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..e9e054ebde2 100644 --- a/internal/model/feed.go +++ b/internal/model/feed.go @@ -6,11 +6,13 @@ package model // import "miniflux.app/v2/internal/model" import ( "fmt" "io" + "math/rand" "time" "miniflux.app/v2/internal/config" ) + // List of supported schedulers. const ( SchedulerRoundRobin = "round_robin" @@ -117,6 +119,18 @@ func (f *Feed) CheckedNow() { } } +// getMaxInterval returns the maximum allowed interval based on the configured polling scheduler. +func getMaxInterval() time.Duration { + switch config.Opts.PollingScheduler() { + case SchedulerRoundRobin: + return config.Opts.SchedulerRoundRobinMaxInterval() + case SchedulerEntryFrequency: + return config.Opts.SchedulerEntryFrequencyMaxInterval() + default: + return config.Opts.SchedulerRoundRobinMaxInterval() + } +} + // ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration. func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) time.Duration { // Default to the global config Polling Frequency. @@ -135,13 +149,16 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelay time.Duration) ti // Use the RSS TTL field, Retry-After, Cache-Control or Expires HTTP headers if defined. interval = max(interval, refreshDelay) - // Limit the max interval value for misconfigured feeds. - switch config.Opts.PollingScheduler() { - case SchedulerRoundRobin: - interval = min(interval, config.Opts.SchedulerRoundRobinMaxInterval()) - case SchedulerEntryFrequency: - interval = min(interval, config.Opts.SchedulerEntryFrequencyMaxInterval()) - } + // Apply a small random jitter to spread next checks and reduce thundering herds. + jitterMax := config.Opts.PollingJitter() + + // No explicit global seeding for math/rand is required since Go 1.20. + randomJitter := time.Duration(rand.Int63n(int64(jitterMax + 1))) + interval += randomJitter + + // Apply max clamping after randomJitter to avoid exceeding configured caps. + maxInterval := getMaxInterval() + interval = min(interval, maxInterval) 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) {