package routing import ( "errors" "sync" "sync/atomic" "testing" "time" ) type fixedRandomSource struct { values []int next int } func (s *fixedRandomSource) Intn(n int) int { value := s.values[s.next] s.next++ return value % n } func TestSelectorsReturnStableErrorWhenNoCandidateIsEligible(t *testing.T) { selectors := map[string]Selector{ "random": NewRandom(), "round robin": NewRoundRobin(), "weighted": NewWeighted(), "least connections": NewLeastConnections(), } for name, selector := range selectors { t.Run(name, func(t *testing.T) { _, err := selector.Select([]Candidate{{Name: "disabled"}}) if !errors.Is(err, ErrNoCandidate) { t.Fatalf("Select() error = %v, want ErrNoCandidate", err) } }) } } func TestRandomSelectsOnlyFromEligibleCandidates(t *testing.T) { selector := NewRandom(&fixedRandomSource{values: []int{1}}) candidates := []Candidate{ {Name: "disabled", Eligible: false}, {Name: "a", Eligible: true}, {Name: "b", Eligible: true}, } got, err := selector.Select(candidates) if err != nil { t.Fatalf("Select(): %v", err) } if got.Name != "b" { t.Fatalf("Select() = %q, want b", got.Name) } } func TestRoundRobinCyclesThroughEligibleCandidates(t *testing.T) { selector := NewRoundRobin() candidates := []Candidate{ {Name: "disabled", Eligible: false}, {Name: "a", Eligible: true}, {Name: "b", Eligible: true}, } for call, want := range []string{"a", "b", "a"} { got, err := selector.Select(candidates) if err != nil { t.Fatalf("Select() call %d: %v", call+1, err) } if got.Name != want { t.Fatalf("Select() call %d = %q, want %q", call+1, got.Name, want) } } } func TestRoundRobinKeepsInputOrderWhenEligibilityChanges(t *testing.T) { selector := NewRoundRobin() candidates := []Candidate{ {Name: "a", Eligible: true}, {Name: "b", Eligible: true}, {Name: "c", Eligible: true}, } first, err := selector.Select(candidates) if err != nil { t.Fatalf("first Select(): %v", err) } if first.Name != "a" { t.Fatalf("first Select() = %q, want a", first.Name) } candidates[0].Eligible = false second, err := selector.Select(candidates) if err != nil { t.Fatalf("second Select(): %v", err) } if second.Name != "b" { t.Fatalf("second Select() = %q, want b", second.Name) } } func TestWeightedSelectsByEligibleCandidateWeight(t *testing.T) { selector := NewWeighted(&fixedRandomSource{values: []int{0, 1, 2, 4}}) candidates := []Candidate{ {Name: "disabled", Weight: 100, Eligible: false}, {Name: "a", Weight: 2, Eligible: true}, {Name: "b", Weight: 3, Eligible: true}, } for call, want := range []string{"a", "a", "b", "b"} { got, err := selector.Select(candidates) if err != nil { t.Fatalf("Select() call %d: %v", call+1, err) } if got.Name != want { t.Fatalf("Select() call %d = %q, want %q", call+1, got.Name, want) } } } func TestWeightedReturnsStableErrorWhenWeightSumOverflows(t *testing.T) { selector := NewWeighted(panicRandomSource{}) maxInt := int(^uint(0) >> 1) defer func() { if recovered := recover(); recovered != nil { t.Fatalf("Select() panicked: %v", recovered) } }() _, err := selector.Select([]Candidate{ {Name: "a", Weight: maxInt, Eligible: true}, {Name: "b", Weight: 1, Eligible: true}, }) if !errors.Is(err, ErrNoCandidate) { t.Fatalf("Select() error = %v, want ErrNoCandidate", err) } } type panicRandomSource struct{} func (panicRandomSource) Intn(int) int { panic("random source should not be called") } func TestLeastConnectionsChoosesFirstEligibleMinimum(t *testing.T) { selector := NewLeastConnections() candidates := []Candidate{ {Name: "disabled", Active: 0, Eligible: false}, {Name: "busy", Active: 8, Eligible: true}, {Name: "first-idle", Active: 2, Eligible: true}, {Name: "second-idle", Active: 2, Eligible: true}, } got, err := selector.Select(candidates) if err != nil { t.Fatalf("Select(): %v", err) } if got.Name != "first-idle" { t.Fatalf("Select() = %q, want first-idle", got.Name) } } func TestRoundRobinIsSafeForConcurrentCalls(t *testing.T) { selector := NewRoundRobin() candidates := []Candidate{ {Name: "a", Eligible: true}, {Name: "b", Eligible: true}, } results := make(chan string, 1000) var wg sync.WaitGroup for range 1000 { wg.Add(1) go func() { defer wg.Done() candidate, err := selector.Select(candidates) if err != nil { t.Errorf("Select(): %v", err) return } results <- candidate.Name }() } wg.Wait() close(results) counts := map[string]int{} for name := range results { counts[name]++ } if counts["a"] != 500 || counts["b"] != 500 { t.Fatalf("concurrent selections = %v, want a:500 b:500", counts) } } func TestInjectedRandomSourcesAreSerialized(t *testing.T) { for name, selector := range map[string]Selector{ "random": NewRandom(&concurrencyDetectingSource{}), "weighted": NewWeighted(&concurrencyDetectingSource{}), } { t.Run(name, func(t *testing.T) { candidates := []Candidate{ {Name: "a", Weight: 1, Eligible: true}, {Name: "b", Weight: 1, Eligible: true}, } var wg sync.WaitGroup for range 100 { wg.Add(1) go func() { defer wg.Done() if _, err := selector.Select(candidates); err != nil { t.Errorf("Select(): %v", err) } }() } wg.Wait() }) } } type concurrencyDetectingSource struct { active atomic.Bool } func (s *concurrencyDetectingSource) Intn(int) int { if !s.active.CompareAndSwap(false, true) { panic("concurrent RandomSource call") } time.Sleep(100 * time.Microsecond) s.active.Store(false) return 0 }