diff --git a/chart/values.yaml b/chart/values.yaml index f0ad4218..d5b0df38 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -185,6 +185,9 @@ sparrowConfig: # unhealthyThreshold: 600s # registrationInterval: 300s # updateInterval: 900s +# # -- Jitter factor applied to polling intervals [0.0, 1.0]. +# # -- 0.0 means no jitter; 0.2 means intervals vary by up to 20%. +# jitter: 0.2 # gitlab: # token: "" # baseUrl: https://gitlab.com diff --git a/internal/helper/jitter.go b/internal/helper/jitter.go new file mode 100644 index 00000000..ce7fac96 --- /dev/null +++ b/internal/helper/jitter.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package helper + +import ( + "math" + "math/rand/v2" + "time" +) + +// ApplyJitter adds a random jitter to d. factor is a percentage of d +// that will be subtracted from d, so the returned duration is in the +// range [d*(1-factor), d]. A factor of 0 returns d unchanged. +// factor must be in [0.0, 1.0]; values above 1.0 are clamped to 1.0. +func ApplyJitter(d time.Duration, factor float64) time.Duration { + if math.IsNaN(factor) || math.IsInf(factor, 0) || factor <= 0 || d <= 0 { + return d + } + if factor > 1 { + factor = 1 + } + + minDuration := float64(d) * (1 - factor) + jitterRange := float64(d) * factor + + //gosec:disable G404 -- jitter does not need crypto rand + return time.Duration(minDuration + rand.Float64()*jitterRange) +} diff --git a/internal/helper/jitter_test.go b/internal/helper/jitter_test.go new file mode 100644 index 00000000..52f3fb01 --- /dev/null +++ b/internal/helper/jitter_test.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2025 Deutsche Telekom IT GmbH +// +// SPDX-License-Identifier: Apache-2.0 + +package helper + +import ( + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestApplyJitter_EdgeCases(t *testing.T) { + tests := []struct { + name string + duration time.Duration + factor float64 + want time.Duration + }{ + { + name: "factor 0 returns exact duration", + duration: 10 * time.Second, + factor: 0, + want: 10 * time.Second, + }, + { + name: "zero duration", + duration: 0, + factor: 0.5, + want: 0, + }, + { + name: "negative factor treated as identity", + duration: 10 * time.Second, + factor: -0.5, + want: 10 * time.Second, + }, + { + name: "NaN factor treated as identity", + duration: 10 * time.Second, + factor: math.NaN(), + want: 10 * time.Second, + }, + { + name: "positive Inf factor treated as identity", + duration: 10 * time.Second, + factor: math.Inf(1), + want: 10 * time.Second, + }, + { + name: "negative Inf factor treated as identity", + duration: 10 * time.Second, + factor: math.Inf(-1), + want: 10 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ApplyJitter(tt.duration, tt.factor) + assert.Equal(t, tt.want, got) + }) + } +} + +func FuzzApplyJitter(f *testing.F) { + f.Add(int64(10*time.Second), 0.0) + f.Add(int64(10*time.Second), 0.2) + f.Add(int64(10*time.Second), 1.0) + f.Add(int64(0), 0.5) + f.Add(int64(10*time.Second), -0.5) + f.Add(int64(10*time.Second), 2.0) + f.Add(int64(time.Millisecond), 0.99) + f.Add(int64(math.MaxInt64), 0.5) + + f.Fuzz(func(t *testing.T, durationNs int64, factor float64) { + d := time.Duration(durationNs) + got := ApplyJitter(d, factor) + + // Identity: when factor is non-positive, NaN, Inf, or d <= 0 + isIdentity := factor <= 0 || d <= 0 || + math.IsNaN(factor) || math.IsInf(factor, 0) + if isIdentity { + assert.Equal(t, d, got, "expected identity") + return + } + + // Clamp factor for bound assertions + f := min(factor, 1.0) + + // Property: result never exceeds original + assert.LessOrEqual(t, got, d, "above maximum") + + // Property: result respects bounded minimum + minD := time.Duration(float64(d) * (1 - f)) + assert.GreaterOrEqual(t, got, minD, "below minimum") + + // Property: result is non-negative when d > 0 + assert.GreaterOrEqual(t, got, time.Duration(0), "negative result") + }) +} diff --git a/pkg/sparrow/targets/errors.go b/pkg/sparrow/targets/errors.go index 255939ed..cfbb083a 100644 --- a/pkg/sparrow/targets/errors.go +++ b/pkg/sparrow/targets/errors.go @@ -19,4 +19,6 @@ var ( ErrInvalidInteractorType = errors.New("invalid interactor type") // ErrInvalidScheme is returned when the scheme is not http or https ErrInvalidScheme = errors.New("scheme must be 'http' of 'https'") + // ErrInvalidJitter is returned when the jitter factor is out of range + ErrInvalidJitter = errors.New("jitter must be between 0.0 and 1.0") ) diff --git a/pkg/sparrow/targets/manager.go b/pkg/sparrow/targets/manager.go index 7e8562fc..9ab6346f 100644 --- a/pkg/sparrow/targets/manager.go +++ b/pkg/sparrow/targets/manager.go @@ -14,6 +14,7 @@ import ( "github.com/prometheus/client_golang/prometheus" smetrics "github.com/telekom/sparrow/pkg/sparrow/metrics" + "github.com/telekom/sparrow/internal/helper" "github.com/telekom/sparrow/internal/logger" "github.com/telekom/sparrow/pkg/checks" "github.com/telekom/sparrow/pkg/sparrow/targets/remote" @@ -114,19 +115,19 @@ func (t *manager) Reconcile(ctx context.Context) error { if err != nil { log.WarnContext(ctx, "Failed to get global targets", "error", err) } - checkTimer.Reset(t.cfg.CheckInterval) + checkTimer.Reset(helper.ApplyJitter(t.cfg.CheckInterval, t.cfg.Jitter)) case <-registrationTimer.C: err := t.register(ctx) if err != nil { log.WarnContext(ctx, "Failed to register self as global target", "error", err) } - registrationTimer.Reset(t.cfg.RegistrationInterval) + registrationTimer.Reset(helper.ApplyJitter(t.cfg.RegistrationInterval, t.cfg.Jitter)) case <-updateTimer.C: err := t.update(ctx) if err != nil { log.WarnContext(ctx, "Failed to update registration", "error", err) } - updateTimer.Reset(t.cfg.UpdateInterval) + updateTimer.Reset(helper.ApplyJitter(t.cfg.UpdateInterval, t.cfg.Jitter)) } } } diff --git a/pkg/sparrow/targets/targetmanager.go b/pkg/sparrow/targets/targetmanager.go index 146f5f3f..8029cd75 100644 --- a/pkg/sparrow/targets/targetmanager.go +++ b/pkg/sparrow/targets/targetmanager.go @@ -48,6 +48,10 @@ type General struct { // Scheme is the scheme used for the remote target manager // Can either be http or https Scheme string `yaml:"scheme" mapstructure:"scheme"` + // Jitter is the jitter factor applied to polling intervals. + // A value between 0.0 (no jitter) and 1.0 (full jitter). + // The actual interval will be in [interval*(1-jitter), interval]. + Jitter float64 `yaml:"jitter" mapstructure:"jitter"` } // TargetManagerConfig is the configuration for the target manager @@ -85,6 +89,11 @@ func (c *TargetManagerConfig) Validate(ctx context.Context) error { return ErrInvalidScheme } + if c.Jitter < 0 || c.Jitter > 1 { + log.Error("The jitter factor should be between 0.0 and 1.0", "jitter", c.Jitter) + return ErrInvalidJitter + } + switch c.Type { case interactor.Gitlab: return nil diff --git a/pkg/sparrow/targets/targetmanager_test.go b/pkg/sparrow/targets/targetmanager_test.go index 9586a243..3d29d3f0 100644 --- a/pkg/sparrow/targets/targetmanager_test.go +++ b/pkg/sparrow/targets/targetmanager_test.go @@ -190,6 +190,54 @@ func TestTargetManagerConfig_Validate(t *testing.T) { }, wantErr: true, }, + { + name: "valid config - jitter 0.0", + cfg: TargetManagerConfig{ + Type: "gitlab", + General: General{ + Scheme: schemeHTTPS, + CheckInterval: 1 * time.Second, + Jitter: 0.0, + }, + }, + wantErr: false, + }, + { + name: "valid config - jitter 1.0", + cfg: TargetManagerConfig{ + Type: "gitlab", + General: General{ + Scheme: schemeHTTPS, + CheckInterval: 1 * time.Second, + Jitter: 1.0, + }, + }, + wantErr: false, + }, + { + name: "invalid config - jitter negative", + cfg: TargetManagerConfig{ + Type: "gitlab", + General: General{ + Scheme: schemeHTTPS, + CheckInterval: 1 * time.Second, + Jitter: -0.1, + }, + }, + wantErr: true, + }, + { + name: "invalid config - jitter above 1", + cfg: TargetManagerConfig{ + Type: "gitlab", + General: General{ + Scheme: schemeHTTPS, + CheckInterval: 1 * time.Second, + Jitter: 1.5, + }, + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {