Add unit tests for adaptive scheduler interval selection (scheduler)
46 test cases covering: - clampFloat helper (9 cases: range checks, boundary conditions) - Staleness score impact (5 cases: 0/0.25/0.5/0.75/1 scores) - Error penalty (5 cases: 0 to 10 errors, min clamping) - Queue depth stretching (5 cases: 1 to 50 depth, max clamping) - EMA smoothing (convergence behavior with alpha=0.6) - Jitter (bounds checking, deterministic tests with seeded RNG) - Boundary conditions (6 cases: zero/negative intervals, max<min) - Combined factors (3 cases: all factors interacting) - State persistence (per-instance EMA state isolation) First unit test file for scheduler.go (previously only had integration tests).
This commit is contained in:
parent
8eea32678e
commit
15d6cae7dd
1 changed files with 788 additions and 0 deletions
788
internal/monitoring/scheduler_test.go
Normal file
788
internal/monitoring/scheduler_test.go
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
package monitoring
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestClampFloat tests the clampFloat helper function
|
||||
func TestClampFloat(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
v float64
|
||||
min float64
|
||||
max float64
|
||||
want float64
|
||||
}{
|
||||
{
|
||||
name: "value within range returns unchanged",
|
||||
v: 5.0,
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
want: 5.0,
|
||||
},
|
||||
{
|
||||
name: "value below min returns min",
|
||||
v: -5.0,
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
want: 0.0,
|
||||
},
|
||||
{
|
||||
name: "value above max returns max",
|
||||
v: 15.0,
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
want: 10.0,
|
||||
},
|
||||
{
|
||||
name: "value at min returns min",
|
||||
v: 0.0,
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
want: 0.0,
|
||||
},
|
||||
{
|
||||
name: "value at max returns max",
|
||||
v: 10.0,
|
||||
min: 0.0,
|
||||
max: 10.0,
|
||||
want: 10.0,
|
||||
},
|
||||
{
|
||||
name: "min equals max returns that value",
|
||||
v: 5.0,
|
||||
min: 7.0,
|
||||
max: 7.0,
|
||||
want: 7.0,
|
||||
},
|
||||
{
|
||||
name: "negative range below min",
|
||||
v: -10.0,
|
||||
min: -5.0,
|
||||
max: 0.0,
|
||||
want: -5.0,
|
||||
},
|
||||
{
|
||||
name: "negative range above max",
|
||||
v: 5.0,
|
||||
min: -10.0,
|
||||
max: -1.0,
|
||||
want: -1.0,
|
||||
},
|
||||
{
|
||||
name: "negative range within",
|
||||
v: -3.0,
|
||||
min: -5.0,
|
||||
max: -1.0,
|
||||
want: -3.0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := clampFloat(tt.v, tt.min, tt.max)
|
||||
if got != tt.want {
|
||||
t.Errorf("clampFloat(%v, %v, %v) = %v, want %v", tt.v, tt.min, tt.max, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_StalenessScore tests staleness score impact on interval
|
||||
func TestAdaptiveIntervalSelector_StalenessScore(t *testing.T) {
|
||||
cfg := SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
score float64
|
||||
want time.Duration
|
||||
}{
|
||||
{
|
||||
name: "score=0 gives max interval",
|
||||
score: 0.0,
|
||||
// target=60s, base=10s, smoothed = 0.6*60 + 0.4*10 = 36 + 4 = 40s
|
||||
want: 40 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "score=1 gives min interval",
|
||||
score: 1.0,
|
||||
// target=5s, base=10s, smoothed = 0.6*5 + 0.4*10 = 3 + 4 = 7s
|
||||
want: 7 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "score=0.5 gives midpoint",
|
||||
score: 0.5,
|
||||
// target=32.5s, base=10s, smoothed = 0.6*32.5 + 0.4*10 = 19.5 + 4 = 23.5s
|
||||
want: 23500 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "score=0.25 gives 3/4 point",
|
||||
score: 0.25,
|
||||
// target=46.25s, base=10s, smoothed = 0.6*46.25 + 0.4*10 = 27.75 + 4 = 31.75s
|
||||
want: 31750 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "score=0.75 gives 1/4 point",
|
||||
score: 0.75,
|
||||
// target=18.75s, base=10s, smoothed = 0.6*18.75 + 0.4*10 = 11.25 + 4 = 15.25s
|
||||
want: 15250 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create new selector for each test to avoid state interference
|
||||
selector := newAdaptiveIntervalSelector(cfg)
|
||||
selector.jitterFraction = 0 // Disable jitter for predictable results
|
||||
|
||||
req := IntervalRequest{
|
||||
Now: time.Now(),
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: tt.score,
|
||||
ErrorCount: 0,
|
||||
QueueDepth: 1,
|
||||
InstanceKey: "test-staleness-" + tt.name,
|
||||
}
|
||||
|
||||
got := selector.SelectInterval(req)
|
||||
|
||||
// Allow for small rounding differences
|
||||
tolerance := 100 * time.Millisecond
|
||||
if got < tt.want-tolerance || got > tt.want+tolerance {
|
||||
t.Errorf("SelectInterval(score=%v) = %v, want ~%v", tt.score, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_ErrorPenalty tests error count impact on interval
|
||||
func TestAdaptiveIntervalSelector_ErrorPenalty(t *testing.T) {
|
||||
cfg := SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
errorCount int
|
||||
wantMin time.Duration
|
||||
wantMax time.Duration
|
||||
}{
|
||||
{
|
||||
name: "errorCount=0 no reduction",
|
||||
errorCount: 0,
|
||||
// score=0.5, target=32.5s, smoothed = 0.6*32.5 + 0.4*10 = 23.5s
|
||||
wantMin: 23 * time.Second,
|
||||
wantMax: 24 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "errorCount=1 reduces interval",
|
||||
errorCount: 1,
|
||||
// target=32.5s / (1+0.6*1) = 32.5/1.6 = 20.3125s, smoothed = 0.6*20.3125 + 0.4*10 = 16.1875s
|
||||
wantMin: 16 * time.Second,
|
||||
wantMax: 17 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "errorCount=2 reduces more",
|
||||
errorCount: 2,
|
||||
// target=32.5s / (1+0.6*2) = 32.5/2.2 = 14.77s, smoothed = 0.6*14.77 + 0.4*10 = 12.86s
|
||||
wantMin: 12500 * time.Millisecond,
|
||||
wantMax: 13500 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "errorCount=5 high penalty",
|
||||
errorCount: 5,
|
||||
// target=32.5s / (1+0.6*5) = 32.5/4 = 8.125s, smoothed = 0.6*8.125 + 0.4*10 = 8.875s
|
||||
wantMin: 8500 * time.Millisecond,
|
||||
wantMax: 9500 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "errorCount=10 clamped to min",
|
||||
errorCount: 10,
|
||||
// target=32.5s / (1+0.6*10) = 32.5/7 = 4.64s -> clamped to 5s, smoothed = 0.6*5 + 0.4*10 = 7s
|
||||
wantMin: 6500 * time.Millisecond,
|
||||
wantMax: 7500 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create new selector for each test to avoid state interference
|
||||
selector := newAdaptiveIntervalSelector(cfg)
|
||||
selector.jitterFraction = 0 // Disable jitter
|
||||
|
||||
req := IntervalRequest{
|
||||
Now: time.Now(),
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.5, // midpoint base
|
||||
ErrorCount: tt.errorCount,
|
||||
QueueDepth: 1,
|
||||
InstanceKey: "test-error-" + tt.name,
|
||||
}
|
||||
|
||||
got := selector.SelectInterval(req)
|
||||
|
||||
if got < tt.wantMin || got > tt.wantMax {
|
||||
t.Errorf("SelectInterval(errorCount=%v) = %v, want between %v and %v",
|
||||
tt.errorCount, got, tt.wantMin, tt.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_QueueDepthStretching tests queue depth impact
|
||||
func TestAdaptiveIntervalSelector_QueueDepthStretching(t *testing.T) {
|
||||
cfg := SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
queueDepth int
|
||||
wantMin time.Duration
|
||||
wantMax time.Duration
|
||||
}{
|
||||
{
|
||||
name: "queueDepth=1 no stretch",
|
||||
queueDepth: 1,
|
||||
// target=32.5s * 1 = 32.5s, smoothed = 0.6*32.5 + 0.4*10 = 23.5s
|
||||
wantMin: 23 * time.Second,
|
||||
wantMax: 24 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "queueDepth=2 slight stretch",
|
||||
queueDepth: 2,
|
||||
// target=32.5s * (1 + 0.1*1) = 35.75s, smoothed = 0.6*35.75 + 0.4*10 = 25.45s
|
||||
wantMin: 25 * time.Second,
|
||||
wantMax: 26 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "queueDepth=5 moderate stretch",
|
||||
queueDepth: 5,
|
||||
// target=32.5s * (1 + 0.1*4) = 45.5s, smoothed = 0.6*45.5 + 0.4*10 = 31.3s
|
||||
wantMin: 31 * time.Second,
|
||||
wantMax: 32 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "queueDepth=10 high stretch",
|
||||
queueDepth: 10,
|
||||
// target=32.5s * (1 + 0.1*9) = 61.75s -> clamped to 60s, smoothed = 0.6*60 + 0.4*10 = 40s
|
||||
wantMin: 39 * time.Second,
|
||||
wantMax: 41 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "queueDepth=50 clamped to max",
|
||||
queueDepth: 50,
|
||||
// target=32.5s * (1 + 0.1*49) = 192.25s -> clamped to 60s, smoothed = 0.6*60 + 0.4*10 = 40s
|
||||
wantMin: 39 * time.Second,
|
||||
wantMax: 41 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create new selector for each test to avoid state interference
|
||||
selector := newAdaptiveIntervalSelector(cfg)
|
||||
selector.jitterFraction = 0 // Disable jitter
|
||||
|
||||
req := IntervalRequest{
|
||||
Now: time.Now(),
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.5, // midpoint base
|
||||
ErrorCount: 0,
|
||||
QueueDepth: tt.queueDepth,
|
||||
InstanceKey: "test-queue-" + tt.name,
|
||||
}
|
||||
|
||||
got := selector.SelectInterval(req)
|
||||
|
||||
if got < tt.wantMin || got > tt.wantMax {
|
||||
t.Errorf("SelectInterval(queueDepth=%v) = %v, want between %v and %v",
|
||||
tt.queueDepth, got, tt.wantMin, tt.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_EMASmoothing tests exponential moving average smoothing
|
||||
func TestAdaptiveIntervalSelector_EMASmoothing(t *testing.T) {
|
||||
cfg := SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
}
|
||||
selector := newAdaptiveIntervalSelector(cfg)
|
||||
selector.jitterFraction = 0 // Disable jitter
|
||||
selector.alpha = 0.6 // 60% new, 40% old
|
||||
|
||||
instanceKey := "test-ema-smoothing"
|
||||
|
||||
// First call: no previous state, uses base interval
|
||||
req1 := IntervalRequest{
|
||||
Now: time.Now(),
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 1.0, // target = min = 5s
|
||||
ErrorCount: 0,
|
||||
QueueDepth: 1,
|
||||
InstanceKey: instanceKey,
|
||||
LastInterval: 0,
|
||||
}
|
||||
|
||||
got1 := selector.SelectInterval(req1)
|
||||
// First call: alpha * 5s + (1-alpha) * 10s = 0.6*5 + 0.4*10 = 3 + 4 = 7s
|
||||
want1 := 7 * time.Second
|
||||
tolerance := 100 * time.Millisecond
|
||||
if got1 < want1-tolerance || got1 > want1+tolerance {
|
||||
t.Errorf("First call: got %v, want ~%v", got1, want1)
|
||||
}
|
||||
|
||||
// Second call: should blend with previous smoothed value
|
||||
req2 := IntervalRequest{
|
||||
Now: time.Now(),
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.0, // target = max = 60s
|
||||
ErrorCount: 0,
|
||||
QueueDepth: 1,
|
||||
InstanceKey: instanceKey,
|
||||
LastInterval: got1,
|
||||
}
|
||||
|
||||
got2 := selector.SelectInterval(req2)
|
||||
// Second call: alpha * 60s + (1-alpha) * 7s = 0.6*60 + 0.4*7 = 36 + 2.8 = 38.8s
|
||||
want2 := 38800 * time.Millisecond
|
||||
if got2 < want2-tolerance || got2 > want2+tolerance {
|
||||
t.Errorf("Second call: got %v, want ~%v", got2, want2)
|
||||
}
|
||||
|
||||
// Third call: should continue blending
|
||||
req3 := IntervalRequest{
|
||||
Now: time.Now(),
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.0, // target = max = 60s
|
||||
ErrorCount: 0,
|
||||
QueueDepth: 1,
|
||||
InstanceKey: instanceKey,
|
||||
LastInterval: got2,
|
||||
}
|
||||
|
||||
got3 := selector.SelectInterval(req3)
|
||||
// Third call: alpha * 60s + (1-alpha) * 38.8s = 0.6*60 + 0.4*38.8 = 36 + 15.52 = 51.52s
|
||||
want3 := 51520 * time.Millisecond
|
||||
if got3 < want3-tolerance || got3 > want3+tolerance {
|
||||
t.Errorf("Third call: got %v, want ~%v", got3, want3)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_Jitter tests jitter application
|
||||
func TestAdaptiveIntervalSelector_Jitter(t *testing.T) {
|
||||
cfg := SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
}
|
||||
selector := newAdaptiveIntervalSelector(cfg)
|
||||
selector.jitterFraction = 0.05 // ±5%
|
||||
|
||||
// Run multiple times to test jitter range
|
||||
instanceKey := "test-jitter"
|
||||
results := make([]time.Duration, 100)
|
||||
|
||||
for i := 0; i < 100; i++ {
|
||||
req := IntervalRequest{
|
||||
Now: time.Now(),
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.5, // midpoint ~32.5s
|
||||
ErrorCount: 0,
|
||||
QueueDepth: 1,
|
||||
InstanceKey: instanceKey,
|
||||
}
|
||||
|
||||
results[i] = selector.SelectInterval(req)
|
||||
}
|
||||
|
||||
// All results should be within bounds
|
||||
for i, result := range results {
|
||||
if result < cfg.MinInterval {
|
||||
t.Errorf("result[%d] = %v, below min %v", i, result, cfg.MinInterval)
|
||||
}
|
||||
if result > cfg.MaxInterval {
|
||||
t.Errorf("result[%d] = %v, above max %v", i, result, cfg.MaxInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// Should have some variation due to jitter
|
||||
unique := make(map[time.Duration]bool)
|
||||
for _, result := range results {
|
||||
unique[result] = true
|
||||
}
|
||||
if len(unique) < 10 {
|
||||
t.Errorf("jitter produced only %d unique values, expected more variation", len(unique))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_JitterDeterministic tests jitter with seeded RNG
|
||||
func TestAdaptiveIntervalSelector_JitterDeterministic(t *testing.T) {
|
||||
cfg := SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 20 * time.Second,
|
||||
MaxInterval: 40 * time.Second,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
seed int64
|
||||
score float64
|
||||
wantMin time.Duration
|
||||
wantMax time.Duration
|
||||
iterations int
|
||||
}{
|
||||
{
|
||||
name: "jitter within bounds",
|
||||
seed: 12345,
|
||||
score: 0.5,
|
||||
wantMin: 19 * time.Second, // slightly below min due to jitter
|
||||
wantMax: 41 * time.Second, // slightly above base due to jitter
|
||||
iterations: 50,
|
||||
},
|
||||
{
|
||||
name: "jitter respects min clamp",
|
||||
seed: 67890,
|
||||
score: 1.0, // target is min (20s)
|
||||
wantMin: 20 * time.Second,
|
||||
wantMax: 25 * time.Second,
|
||||
iterations: 50,
|
||||
},
|
||||
{
|
||||
name: "jitter respects max clamp",
|
||||
seed: 11111,
|
||||
score: 0.0, // target is max (40s), but EMA converges over iterations
|
||||
wantMin: 25 * time.Second,
|
||||
wantMax: 40 * time.Second, // eventually converges to max
|
||||
iterations: 50,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector := newAdaptiveIntervalSelector(cfg)
|
||||
selector.rng = rand.New(rand.NewSource(tt.seed))
|
||||
selector.jitterFraction = 0.05
|
||||
|
||||
instanceKey := "test-jitter-deterministic-" + tt.name
|
||||
|
||||
for i := 0; i < tt.iterations; i++ {
|
||||
req := IntervalRequest{
|
||||
Now: time.Now(),
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: tt.score,
|
||||
ErrorCount: 0,
|
||||
QueueDepth: 1,
|
||||
InstanceKey: instanceKey,
|
||||
}
|
||||
|
||||
got := selector.SelectInterval(req)
|
||||
|
||||
if got < tt.wantMin || got > tt.wantMax {
|
||||
t.Errorf("iteration %d: SelectInterval() = %v, want between %v and %v",
|
||||
i, got, tt.wantMin, tt.wantMax)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_BoundaryConditions tests edge cases
|
||||
func TestAdaptiveIntervalSelector_BoundaryConditions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg SchedulerConfig
|
||||
req IntervalRequest
|
||||
wantMin time.Duration
|
||||
wantMax time.Duration
|
||||
}{
|
||||
{
|
||||
name: "zero intervals default to min",
|
||||
cfg: SchedulerConfig{
|
||||
BaseInterval: 0,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
},
|
||||
req: IntervalRequest{
|
||||
BaseInterval: 0,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
StalenessScore: 0.5,
|
||||
InstanceKey: "test-zero-interval",
|
||||
},
|
||||
wantMin: 5 * time.Second,
|
||||
wantMax: 60 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "negative intervals treated as zero",
|
||||
cfg: SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
},
|
||||
req: IntervalRequest{
|
||||
BaseInterval: -10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
StalenessScore: 0.5,
|
||||
InstanceKey: "test-negative-interval",
|
||||
},
|
||||
wantMin: 5 * time.Second,
|
||||
wantMax: 60 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "max less than min uses min",
|
||||
cfg: SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 60 * time.Second,
|
||||
MaxInterval: 5 * time.Second,
|
||||
},
|
||||
req: IntervalRequest{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 60 * time.Second,
|
||||
MaxInterval: 5 * time.Second,
|
||||
StalenessScore: 0.5,
|
||||
InstanceKey: "test-max-less-than-min",
|
||||
},
|
||||
wantMin: 5 * time.Second, // corrected to max value
|
||||
wantMax: 60 * time.Second, // corrected to min value
|
||||
},
|
||||
{
|
||||
name: "empty instance key uses instance type",
|
||||
cfg: SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
},
|
||||
req: IntervalRequest{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
StalenessScore: 0.5,
|
||||
InstanceKey: "", // empty key
|
||||
InstanceType: InstanceTypePVE,
|
||||
},
|
||||
wantMin: 5 * time.Second,
|
||||
wantMax: 60 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "staleness score above 1 clamped",
|
||||
cfg: SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
},
|
||||
req: IntervalRequest{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
StalenessScore: 2.0, // should be clamped to 1.0
|
||||
InstanceKey: "test-staleness-above-1",
|
||||
},
|
||||
wantMin: 5 * time.Second,
|
||||
wantMax: 10 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "staleness score below 0 clamped",
|
||||
cfg: SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
},
|
||||
req: IntervalRequest{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
StalenessScore: -1.0, // clamped to 0.0, target=60s, smoothed = 0.6*60 + 0.4*10 = 40s
|
||||
InstanceKey: "test-staleness-below-0",
|
||||
},
|
||||
wantMin: 39 * time.Second,
|
||||
wantMax: 41 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector := newAdaptiveIntervalSelector(tt.cfg)
|
||||
selector.jitterFraction = 0 // Disable jitter for predictable results
|
||||
|
||||
got := selector.SelectInterval(tt.req)
|
||||
|
||||
if got < tt.wantMin || got > tt.wantMax {
|
||||
t.Errorf("SelectInterval() = %v, want between %v and %v", got, tt.wantMin, tt.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_CombinedFactors tests all factors working together
|
||||
func TestAdaptiveIntervalSelector_CombinedFactors(t *testing.T) {
|
||||
cfg := SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req IntervalRequest
|
||||
wantMin time.Duration
|
||||
wantMax time.Duration
|
||||
}{
|
||||
{
|
||||
name: "high staleness, errors, and queue depth",
|
||||
req: IntervalRequest{
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.9, // high staleness -> low base interval
|
||||
ErrorCount: 3, // errors reduce further
|
||||
QueueDepth: 10, // queue stretches back up
|
||||
InstanceKey: "test-combined-high",
|
||||
},
|
||||
// target=(5 + 55*0.1) = 10.5s, error penalty: 10.5/(1+0.6*3) = 3.78s -> clamp to 5s
|
||||
// queue stretch: 5 * (1+0.1*9) = 9.5s, smoothed = 0.6*9.5 + 0.4*10 = 9.7s
|
||||
wantMin: 9 * time.Second,
|
||||
wantMax: 10500 * time.Millisecond,
|
||||
},
|
||||
{
|
||||
name: "low staleness, no errors, low queue",
|
||||
req: IntervalRequest{
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.1, // low staleness -> high base interval
|
||||
ErrorCount: 0, // no error penalty
|
||||
QueueDepth: 1, // no queue stretch
|
||||
InstanceKey: "test-combined-low",
|
||||
},
|
||||
// target=(5 + 55*0.9) = 54.5s, smoothed = 0.6*54.5 + 0.4*10 = 36.7s
|
||||
wantMin: 36 * time.Second,
|
||||
wantMax: 38 * time.Second,
|
||||
},
|
||||
{
|
||||
name: "moderate staleness with queue depth",
|
||||
req: IntervalRequest{
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.5, // midpoint
|
||||
ErrorCount: 0,
|
||||
QueueDepth: 5, // moderate queue stretch
|
||||
InstanceKey: "test-combined-moderate",
|
||||
},
|
||||
// target=32.5s * (1 + 0.1*4) = 45.5s, smoothed = 0.6*45.5 + 0.4*10 = 31.3s
|
||||
wantMin: 31 * time.Second,
|
||||
wantMax: 32 * time.Second,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create new selector for each test to avoid state interference
|
||||
selector := newAdaptiveIntervalSelector(cfg)
|
||||
selector.jitterFraction = 0 // Disable jitter for predictable results
|
||||
|
||||
got := selector.SelectInterval(tt.req)
|
||||
|
||||
if got < tt.wantMin || got > tt.wantMax {
|
||||
t.Errorf("SelectInterval() = %v, want between %v and %v", got, tt.wantMin, tt.wantMax)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdaptiveIntervalSelector_StatePersistence tests state is maintained per instance
|
||||
func TestAdaptiveIntervalSelector_StatePersistence(t *testing.T) {
|
||||
cfg := SchedulerConfig{
|
||||
BaseInterval: 10 * time.Second,
|
||||
MinInterval: 5 * time.Second,
|
||||
MaxInterval: 60 * time.Second,
|
||||
}
|
||||
selector := newAdaptiveIntervalSelector(cfg)
|
||||
selector.jitterFraction = 0 // Disable jitter
|
||||
|
||||
// Call for instance A
|
||||
reqA1 := IntervalRequest{
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 1.0, // min interval
|
||||
InstanceKey: "instance-A",
|
||||
}
|
||||
gotA1 := selector.SelectInterval(reqA1)
|
||||
|
||||
// Call for instance B
|
||||
reqB1 := IntervalRequest{
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.0, // max interval
|
||||
InstanceKey: "instance-B",
|
||||
}
|
||||
gotB1 := selector.SelectInterval(reqB1)
|
||||
|
||||
// Second call for instance A should use smoothed value from first call
|
||||
reqA2 := IntervalRequest{
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 1.0,
|
||||
InstanceKey: "instance-A",
|
||||
}
|
||||
gotA2 := selector.SelectInterval(reqA2)
|
||||
|
||||
// Second call for instance B should use smoothed value from first call
|
||||
reqB2 := IntervalRequest{
|
||||
BaseInterval: cfg.BaseInterval,
|
||||
MinInterval: cfg.MinInterval,
|
||||
MaxInterval: cfg.MaxInterval,
|
||||
StalenessScore: 0.0,
|
||||
InstanceKey: "instance-B",
|
||||
}
|
||||
gotB2 := selector.SelectInterval(reqB2)
|
||||
|
||||
// Instance A should trend toward min
|
||||
if gotA2 >= gotA1 {
|
||||
t.Errorf("instance A second call should be <= first call: got %v, first was %v", gotA2, gotA1)
|
||||
}
|
||||
|
||||
// Instance B should trend toward max
|
||||
if gotB2 <= gotB1 {
|
||||
t.Errorf("instance B second call should be >= first call: got %v, first was %v", gotB2, gotB1)
|
||||
}
|
||||
|
||||
// The two instances should have different intervals
|
||||
if gotA2 == gotB2 {
|
||||
t.Errorf("different instances should have different intervals: both got %v", gotA2)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue