From 15d6cae7dd1dcbb7aa7f76fa9910131ccf32db1b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 30 Nov 2025 06:11:22 +0000 Subject: [PATCH] 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 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) + } +}