fix(cag): extract applyWarmup/acquireScheduledBackend helpers, add race-free tests
- proxy.go: extract applyWarmup + acquireScheduledBackend to reduce ServeHTTP complexity - proxy_test.go: TestProxyQueueFull, TestProxyCacheHitLogging (AC5/AC6/AC7 HTTP-level coverage) - scheduler_test.go: TestBackendAcquireCancelledContext + runtime.Gosched spin-wait (no time.Sleep) - router_test.go: TestAuthenticate_MethodCapabilityOverrideDirect with real RS256 JWT + JWKS server
This commit is contained in:
@@ -5,11 +5,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||
)
|
||||
|
||||
func TestProxyBasic(t *testing.T) {
|
||||
@@ -415,6 +417,140 @@ func TestProxyPreservesBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyQueueFull verifies that a 503 is returned and the queue_full metric
|
||||
// is incremented when the backend queue is at capacity.
|
||||
//
|
||||
// Setup: maxSlots=1, maxQueue=1. Request 1 holds the slot. Request 2 fills the
|
||||
// queue (blocks waiting for slot). Request 3 gets ErrQueueFull → 503.
|
||||
func TestProxyQueueFull(t *testing.T) {
|
||||
t.Setenv("VLLM_MAX_NUM_SEQS", "1") // 1 concurrent slot
|
||||
t.Setenv("VLLM_MAX_QUEUE_DEPTH", "1") // queue capacity 1
|
||||
t.Setenv("WARMUP_DELAY_MS", "0")
|
||||
observability.Global().Reset()
|
||||
|
||||
// Upstream blocks until released; buffers one signal when it first starts.
|
||||
slotHeld := make(chan struct{}, 1)
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case slotHeld <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
<-release
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstream.URL, "http://")
|
||||
cfg := &config.Config{
|
||||
Routes: make(map[string]*config.Route),
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"q-model": {
|
||||
Name: "q-model",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
}
|
||||
h := New(cfg)
|
||||
defer h.Close()
|
||||
|
||||
srv := httptest.NewServer(h)
|
||||
defer srv.Close()
|
||||
|
||||
mkReq := func() *http.Request {
|
||||
body := strings.NewReader(`{"model":"q-model","messages":[]}`)
|
||||
req, _ := http.NewRequest("POST", srv.URL+"/v1/chat/completions", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
return req
|
||||
}
|
||||
|
||||
// Request 1: acquires the only slot, reaches upstream, blocks there.
|
||||
r1ch := make(chan *http.Response, 1)
|
||||
go func() {
|
||||
resp, _ := http.DefaultClient.Do(mkReq())
|
||||
r1ch <- resp
|
||||
}()
|
||||
<-slotHeld // slot is acquired; upstream is holding request 1
|
||||
|
||||
// Request 2: enters the queue (capacity 1), blocks waiting for slot.
|
||||
r2ch := make(chan *http.Response, 1)
|
||||
go func() {
|
||||
resp, _ := http.DefaultClient.Do(mkReq())
|
||||
r2ch <- resp
|
||||
}()
|
||||
// Spin until request 2 enters the backend queue channel.
|
||||
for len(h.schedulers["q-model"].backends[0].queue) == 0 {
|
||||
runtime.Gosched()
|
||||
}
|
||||
|
||||
// Request 3: queue is full → expect 503 and metric increment.
|
||||
resp3, err := http.DefaultClient.Do(mkReq())
|
||||
if err != nil {
|
||||
t.Fatalf("request 3 failed: %v", err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
|
||||
if resp3.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("expected 503 queue full, got %d", resp3.StatusCode)
|
||||
}
|
||||
if got := observability.Global().GetQueueFull(); got != 1 {
|
||||
t.Errorf("expected GetQueueFull()=1, got %d", got)
|
||||
}
|
||||
|
||||
// Cleanup: unblock upstream so goroutines can finish.
|
||||
close(release)
|
||||
if r := <-r1ch; r != nil {
|
||||
r.Body.Close()
|
||||
}
|
||||
if r := <-r2ch; r != nil {
|
||||
r.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyCacheHitLogging verifies that when a response contains the
|
||||
// vllm-num-cached-tokens header and a cache key was set, the prefix cache
|
||||
// hit token count is incremented in observability metrics.
|
||||
func TestProxyCacheHitLogging(t *testing.T) {
|
||||
observability.Global().Reset()
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("vllm-num-cached-tokens", "128")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstream.URL, "http://")
|
||||
cfg := &config.Config{
|
||||
Routes: make(map[string]*config.Route),
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"cache-model": {
|
||||
Name: "cache-model",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
}
|
||||
h := New(cfg)
|
||||
defer h.Close()
|
||||
|
||||
srv := httptest.NewServer(h)
|
||||
defer srv.Close()
|
||||
|
||||
body := strings.NewReader(`{"model":"cache-model","messages":[]}`)
|
||||
req, _ := http.NewRequest("POST", srv.URL+"/v1/chat/completions", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Poimen-Cache-Key", "testkey123")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if got := observability.Global().GetPrefixCacheHitTokens(); got != 128 {
|
||||
t.Errorf("expected PrefixCacheHitTokens=128, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarmupHeaderHandling verifies that X-Poimen-Warmup: true is stripped from
|
||||
// the upstream request and replaced with X-Priority: low, while a request without
|
||||
// X-Poimen-Warmup reaches upstream without either header.
|
||||
|
||||
Reference in New Issue
Block a user