Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/server/internal/scheduler/scheduler_test.go
Fimeg d25f6ea030 swept the cobwebs, stopped re-dialing the same three hosts
three more off the scale list:

- rate-limit map now gets swept on a cadence (taskrunner.Every) instead of
  growing forever — nobody was calling the cleanup. first old ticker moved
  onto the runner
- subsystem load was one db query per agent at startup; now it's a single
  ANY($1) for all the online ones. 100 agents, 1 query
- outbound http clients (osv, registries, upstream, agent) were inheriting
  the stock transport that keeps 2 idle conns per host — so every scan burst
  re-dialed. shared tuned transport now, 10 per host, 90s idle

builds clean both modules.
2026-06-07 19:35:32 -04:00

375 lines
8.9 KiB
Go

package scheduler
import (
"testing"
"time"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/gofrs/uuid/v5"
)
func TestScheduler_NewScheduler(t *testing.T) {
config := DefaultConfig()
s := NewScheduler(config, nil, nil, nil, nil)
if s == nil {
t.Fatal("NewScheduler returned nil")
}
if s.config.NumWorkers != 10 {
t.Fatalf("expected 10 workers, got %d", s.config.NumWorkers)
}
if s.queue == nil {
t.Fatal("queue not initialized")
}
if len(s.workers) != config.NumWorkers {
t.Fatalf("expected %d workers, got %d", config.NumWorkers, len(s.workers))
}
}
func TestScheduler_DefaultConfig(t *testing.T) {
config := DefaultConfig()
if config.CheckInterval != 10*time.Second {
t.Fatalf("expected check interval 10s, got %v", config.CheckInterval)
}
if config.LookaheadWindow != 60*time.Second {
t.Fatalf("expected lookahead 60s, got %v", config.LookaheadWindow)
}
if config.MaxJitter != 30*time.Second {
t.Fatalf("expected max jitter 30s, got %v", config.MaxJitter)
}
if config.NumWorkers != 10 {
t.Fatalf("expected 10 workers, got %d", config.NumWorkers)
}
if config.BackpressureThreshold != 5 {
t.Fatalf("expected backpressure threshold 5, got %d", config.BackpressureThreshold)
}
if config.RateLimitPerSecond != 100 {
t.Fatalf("expected rate limit 100/s, got %d", config.RateLimitPerSecond)
}
}
func TestScheduler_QueueIntegration(t *testing.T) {
config := DefaultConfig()
s := NewScheduler(config, nil, nil, nil, nil)
// Add jobs to queue
agent1 := uuid.Must(uuid.NewV4())
agent2 := uuid.Must(uuid.NewV4())
job1 := &SubsystemJob{
AgentID: agent1,
AgentHostname: "agent-01",
Subsystem: "updates",
IntervalMinutes: 15,
NextRunAt: time.Now().Add(5 * time.Minute),
}
job2 := &SubsystemJob{
AgentID: agent2,
AgentHostname: "agent-02",
Subsystem: "storage",
IntervalMinutes: 15,
NextRunAt: time.Now().Add(10 * time.Minute),
}
s.queue.Push(job1)
s.queue.Push(job2)
if s.queue.Len() != 2 {
t.Fatalf("expected queue len 2, got %d", s.queue.Len())
}
// Get stats
stats := s.GetQueueStats()
if stats.Size != 2 {
t.Fatalf("expected stats size 2, got %d", stats.Size)
}
}
func TestScheduler_GetStats(t *testing.T) {
config := DefaultConfig()
s := NewScheduler(config, nil, nil, nil, nil)
// Initial stats should be zero
stats := s.GetStats()
if stats.JobsProcessed != 0 {
t.Fatalf("expected 0 jobs processed, got %d", stats.JobsProcessed)
}
if stats.CommandsCreated != 0 {
t.Fatalf("expected 0 commands created, got %d", stats.CommandsCreated)
}
if stats.BackpressureSkips != 0 {
t.Fatalf("expected 0 backpressure skips, got %d", stats.BackpressureSkips)
}
// Manually update stats (simulating processing)
s.mu.Lock()
s.stats.JobsProcessed = 100
s.stats.CommandsCreated = 95
s.stats.BackpressureSkips = 5
s.mu.Unlock()
stats = s.GetStats()
if stats.JobsProcessed != 100 {
t.Fatalf("expected 100 jobs processed, got %d", stats.JobsProcessed)
}
if stats.CommandsCreated != 95 {
t.Fatalf("expected 95 commands created, got %d", stats.CommandsCreated)
}
if stats.BackpressureSkips != 5 {
t.Fatalf("expected 5 backpressure skips, got %d", stats.BackpressureSkips)
}
}
func TestScheduler_StartStop(t *testing.T) {
config := Config{
CheckInterval: 100 * time.Millisecond, // Fast for testing
LookaheadWindow: 60 * time.Second,
MaxJitter: 1 * time.Second,
NumWorkers: 2,
BackpressureThreshold: 5,
RateLimitPerSecond: 0, // Disable rate limiting for test
}
s := NewScheduler(config, nil, nil, nil, nil)
// Start scheduler
err := s.Start()
if err != nil {
t.Fatalf("failed to start scheduler: %v", err)
}
// Let it run for a bit
time.Sleep(500 * time.Millisecond)
// Stop scheduler
err = s.Stop()
if err != nil {
t.Fatalf("failed to stop scheduler: %v", err)
}
// Should stop cleanly
}
func TestScheduler_ProcessQueueEmpty(t *testing.T) {
config := DefaultConfig()
s := NewScheduler(config, nil, nil, nil, nil)
// Process empty queue should not panic
s.processQueue()
stats := s.GetStats()
if stats.JobsProcessed != 0 {
t.Fatalf("expected 0 jobs processed on empty queue, got %d", stats.JobsProcessed)
}
}
func TestScheduler_ProcessQueueWithJobs(t *testing.T) {
config := Config{
CheckInterval: 1 * time.Second,
LookaheadWindow: 60 * time.Second,
MaxJitter: 5 * time.Second,
NumWorkers: 2,
BackpressureThreshold: 5,
RateLimitPerSecond: 0, // Disable for test
}
s := NewScheduler(config, nil, nil, nil, nil)
// Add jobs that are due now
for i := 0; i < 5; i++ {
job := &SubsystemJob{
AgentID: uuid.Must(uuid.NewV4()),
AgentHostname: "test-agent",
Subsystem: "updates",
IntervalMinutes: 15,
NextRunAt: time.Now(), // Due now
}
s.queue.Push(job)
}
if s.queue.Len() != 5 {
t.Fatalf("expected 5 jobs in queue, got %d", s.queue.Len())
}
// Process the queue
s.processQueue()
// Jobs should be dispatched to job channel
// Note: Without database, workers can't actually process them
// But we can verify they were dispatched
stats := s.GetStats()
if stats.JobsProcessed == 0 {
t.Fatal("expected some jobs to be processed")
}
}
func TestScheduler_RateLimiterRefill(t *testing.T) {
config := Config{
CheckInterval: 1 * time.Second,
LookaheadWindow: 60 * time.Second,
MaxJitter: 1 * time.Second,
NumWorkers: 2,
BackpressureThreshold: 5,
RateLimitPerSecond: 10, // 10 tokens per second
}
s := NewScheduler(config, nil, nil, nil, nil)
if s.rateLimiter == nil {
t.Fatal("rate limiter not initialized")
}
// Start refill goroutine
go s.refillRateLimiter()
// Wait for some tokens to be added
time.Sleep(200 * time.Millisecond)
// Should have some tokens available
tokensAvailable := 0
for i := 0; i < 15; i++ {
select {
case <-s.rateLimiter:
tokensAvailable++
default:
break
}
}
if tokensAvailable == 0 {
t.Fatal("expected some tokens to be available after refill")
}
// Should not exceed buffer size (10)
if tokensAvailable > 10 {
t.Fatalf("token bucket overflowed: got %d tokens, max is 10", tokensAvailable)
}
}
func TestScheduler_ConcurrentQueueAccess(t *testing.T) {
config := DefaultConfig()
s := NewScheduler(config, nil, nil, nil, nil)
done := make(chan bool)
// Concurrent pushes
go func() {
for i := 0; i < 100; i++ {
job := &SubsystemJob{
AgentID: uuid.Must(uuid.NewV4()),
Subsystem: "updates",
IntervalMinutes: 15,
NextRunAt: time.Now(),
}
s.queue.Push(job)
}
done <- true
}()
// Concurrent stats reads
go func() {
for i := 0; i < 100; i++ {
s.GetStats()
s.GetQueueStats()
}
done <- true
}()
// Wait for both
<-done
<-done
// Should not panic and should have queued jobs
if s.queue.Len() <= 0 {
t.Fatal("expected jobs in queue after concurrent pushes")
}
}
// TestGroupSubsystemsByAgent exercises the pure grouping helper that
// LoadSubsystems uses after the batch query (SCALE-001 S10). No DB needed.
func TestGroupSubsystemsByAgent(t *testing.T) {
agentA := uuid.Must(uuid.NewV4())
agentB := uuid.Must(uuid.NewV4())
agentC := uuid.Must(uuid.NewV4()) // agent with no rows
rows := []models.AgentSubsystem{
{AgentID: agentA, Subsystem: "apt"},
{AgentID: agentA, Subsystem: "storage"},
{AgentID: agentB, Subsystem: "dnf"},
}
got := groupSubsystemsByAgent(rows)
// agentA should have exactly 2 rows
if len(got[agentA]) != 2 {
t.Fatalf("agentA: expected 2 subsystems, got %d", len(got[agentA]))
}
// agentB should have exactly 1 row
if len(got[agentB]) != 1 {
t.Fatalf("agentB: expected 1 subsystem, got %d", len(got[agentB]))
}
if got[agentB][0].Subsystem != "dnf" {
t.Fatalf("agentB: expected subsystem=dnf, got %s", got[agentB][0].Subsystem)
}
// agentC has no rows — map lookup should return nil slice (zero jobs)
if len(got[agentC]) != 0 {
t.Fatalf("agentC: expected 0 subsystems, got %d", len(got[agentC]))
}
// empty input should return empty map
empty := groupSubsystemsByAgent(nil)
if len(empty) != 0 {
t.Fatalf("empty input: expected empty map, got len=%d", len(empty))
}
}
// TestGroupSubsystemsByAgent_EmptyInput confirms nil/empty slice input is safe.
func TestGroupSubsystemsByAgent_EmptyInput(t *testing.T) {
got := groupSubsystemsByAgent([]models.AgentSubsystem{})
if got == nil {
t.Fatal("expected non-nil map for empty input")
}
if len(got) != 0 {
t.Fatalf("expected empty map, got len=%d", len(got))
}
}
func BenchmarkScheduler_ProcessQueue(b *testing.B) {
config := DefaultConfig()
s := NewScheduler(config, nil, nil, nil, nil)
// Pre-fill queue with jobs
for i := 0; i < 1000; i++ {
job := &SubsystemJob{
AgentID: uuid.Must(uuid.NewV4()),
Subsystem: "updates",
IntervalMinutes: 15,
NextRunAt: time.Now(),
}
s.queue.Push(job)
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
s.processQueue()
}
}