diff --git a/modules/caddyhttp/reverseproxy/selectionpolicies.go b/modules/caddyhttp/reverseproxy/selectionpolicies.go index 86a3d0d7c..83f8f8a07 100644 --- a/modules/caddyhttp/reverseproxy/selectionpolicies.go +++ b/modules/caddyhttp/reverseproxy/selectionpolicies.go @@ -233,12 +233,23 @@ func (r RandomChoiceSelection) Validate() error { // Select returns an available host, if any. func (r RandomChoiceSelection) Select(pool UpstreamPool, _ *http.Request, _ http.ResponseWriter) *Upstream { k := min(r.Choose, len(pool)) - choices := make([]*Upstream, k) - for i, upstream := range pool { + + // reservoir sampling (Algorithm R) over the available upstreams: + // the first k available upstreams fill the reservoir, then each + // subsequent one replaces a random reservoir entry with probability + // k/n, so every available upstream is sampled uniformly + choices := make([]*Upstream, 0, k) + var available int + for _, upstream := range pool { if !upstream.Available() { continue } - j := weakrand.IntN(i + 1) //nolint:gosec + available++ + if len(choices) < k { + choices = append(choices, upstream) + continue + } + j := weakrand.IntN(available) //nolint:gosec if j < k { choices[j] = upstream } diff --git a/modules/caddyhttp/reverseproxy/selectionpolicies_test.go b/modules/caddyhttp/reverseproxy/selectionpolicies_test.go index 7c912ce01..84fd4493c 100644 --- a/modules/caddyhttp/reverseproxy/selectionpolicies_test.go +++ b/modules/caddyhttp/reverseproxy/selectionpolicies_test.go @@ -725,6 +725,33 @@ func TestRandomChoicePolicy(t *testing.T) { } } +func TestRandomChoicePolicyLeastLoaded(t *testing.T) { + // when the number of available upstreams does not exceed the choose + // count, all of them must be candidates, so the least-loaded one + // must always be selected; the pool intentionally starts with an + // unavailable upstream to verify that reservoir sampling counts + // available upstreams rather than pool indices + pool := testPool() + pool[0].Dial = "localhost:8080" + pool[1].Dial = "localhost:8081" + pool[2].Dial = "localhost:8082" + pool[0].setHealthy(false) + pool[1].setHealthy(true) + pool[2].setHealthy(true) + pool[1].countRequest(30) + // pool[2] has no active requests + + request := httptest.NewRequest(http.MethodGet, "/test", nil) + randomChoicePolicy := RandomChoiceSelection{Choose: 2} + + for i := 0; i < 100; i++ { + h := randomChoicePolicy.Select(pool, request, nil) + if h != pool[2] { + t.Fatalf("with 2 available upstreams and choose=2, the least-loaded upstream (pool[2]) must always be selected; got %v on iteration %d", h, i) + } + } +} + func TestCookieHashPolicy(t *testing.T) { ctx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()}) defer cancel()