proxy-pool/internal/domain/routing/strategy_test.go

233 lines
5.5 KiB
Go

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
}