233 lines
5.5 KiB
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
|
|
}
|