Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions internal/helper/jitter.go
Original file line number Diff line number Diff line change
@@ -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)
}
103 changes: 103 additions & 0 deletions internal/helper/jitter_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
2 changes: 2 additions & 0 deletions pkg/sparrow/targets/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
)
7 changes: 4 additions & 3 deletions pkg/sparrow/targets/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions pkg/sparrow/targets/targetmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions pkg/sparrow/targets/targetmanager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading