CI / CI (pull_request) Successful in 34m5s
The per-pod semaphore was not a cap: 3 replicas x 4 slots advertised 12 concurrent requests while the engine runs 4, and nothing coordinated the replicas. Admission is now gated on the engine's own published gauges, which is global by construction because every replica contends for the same engine. - new internal/proxy/engine_state.go: 2s scrape of the backend /metrics, hand-rolled exposition parse (no new dependency), unhealthy after 3 consecutive failed polls, and a startup self-check that raises observer_degraded rather than falling back to fail-open silently - gate: park while kv_cache_usage_perc >= 0.90 or running + waiting >= ADMISSION_MAX_INFLIGHT, bounded by ADMISSION_MAX_PARK_SECONDS, then 503 + Retry-After: 5. Unknown state fails open on purpose - streaming requests receive SSE keep-alive heartbeats while parked - router: extract only the top-level model field instead of unmarshalling the whole body into a map; body cap from MAX_BODY_BYTES (1 MiB, the model path previously inherited the 100 MB default) - GOMAXPROCS from the cgroup CPU quota: the container is limited to 500m while Go defaulted to the host core count and was CFS-throttled - VLLM_MAX_QUEUE_DEPTH default 20 -> 8 - delete k8s/configmap.yaml: absent from kustomization.yaml, and the live ConfigMap of that name carries an unrelated key - correct the llm-client NetworkPolicy comment; llm-serving-ingress now selects by namespace, not by that label Observers run only for models named in ADMISSION_OBSERVE_MODELS. reasoning is the only vLLM engine here; ornith and qwen-cpu are Ollama and embeddings/rerank are TEI, none of which publish vllm:* series, so observing them would hold observer_degraded at 1 permanently. Companion change (engine flags + dashboard panels) is riotpiao-poimen/homelab#51.
183 lines
4.9 KiB
Go
183 lines
4.9 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestBackendAcquireBlocksThenUnblocksOnRelease verifies that a second Acquire
|
|
// blocks while a slot is held, then unblocks when Release is called.
|
|
func TestBackendAcquireBlocksThenUnblocksOnRelease(t *testing.T) {
|
|
b := newBackend("host:1", 1, 1)
|
|
|
|
// G1: acquire and hold the only slot.
|
|
if err := b.Acquire(context.Background()); err != nil {
|
|
t.Fatalf("G1 Acquire: unexpected error: %v", err)
|
|
}
|
|
|
|
g2Done := make(chan error, 1)
|
|
go func() {
|
|
g2Done <- b.Acquire(context.Background())
|
|
}()
|
|
|
|
// Wait until G2 enters the queue channel (proves Acquire is blocking on slots).
|
|
for len(b.queue) == 0 {
|
|
runtime.Gosched()
|
|
}
|
|
|
|
select {
|
|
case err := <-g2Done:
|
|
t.Fatalf("G2 returned before Release: %v", err)
|
|
default:
|
|
}
|
|
|
|
// G1 releases; G2 should unblock.
|
|
b.Release()
|
|
|
|
select {
|
|
case err := <-g2Done:
|
|
if err != nil {
|
|
t.Fatalf("G2 Acquire after Release: unexpected error: %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("G2 did not unblock within 1s after Release")
|
|
}
|
|
|
|
// Cleanup: release G2's slot.
|
|
b.Release()
|
|
}
|
|
|
|
// TestBackendAcquireReturnsErrQueueFullWhenQueueFull verifies that Acquire returns
|
|
// ErrQueueFull immediately when no queue capacity exists (maxQueue=0).
|
|
// With a zero-capacity queue channel the non-blocking select always hits default.
|
|
func TestBackendAcquireReturnsErrQueueFullWhenQueueFull(t *testing.T) {
|
|
b := newBackend("host:1", 1, 0)
|
|
|
|
err := b.Acquire(context.Background())
|
|
if !errors.Is(err, ErrQueueFull) {
|
|
t.Fatalf("expected ErrQueueFull, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestSchedulerSelectRendezvousHashDeterministic verifies that the same cache key
|
|
// always selects the same backend (rendezvous / highest-random-weight hashing).
|
|
func TestSchedulerSelectRendezvousHashDeterministic(t *testing.T) {
|
|
addrs := []string{"a:80", "b:80", "c:80"}
|
|
s := NewScheduler(addrs, 10, 10, nil, AdmissionLimits{})
|
|
|
|
first := s.Select("same-cache-key")
|
|
if first == nil {
|
|
t.Fatal("Select returned nil")
|
|
}
|
|
for i := 1; i < 10; i++ {
|
|
b := s.Select("same-cache-key")
|
|
if b == nil || b.Address != first.Address {
|
|
t.Fatalf("call %d: want %s, got %v", i, first.Address, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSchedulerSelectNoKeyRoundRobin verifies that round-robin distributes
|
|
// requests evenly across all backends when no cache key is given.
|
|
func TestSchedulerSelectNoKeyRoundRobin(t *testing.T) {
|
|
addrs := []string{"a:80", "b:80", "c:80"}
|
|
s := NewScheduler(addrs, 10, 10, nil, AdmissionLimits{})
|
|
|
|
counts := make(map[string]int)
|
|
for i := 0; i < 9; i++ {
|
|
b := s.Select("")
|
|
if b == nil {
|
|
t.Fatalf("call %d: Select returned nil", i)
|
|
}
|
|
counts[b.Address]++
|
|
}
|
|
|
|
for _, addr := range addrs {
|
|
if counts[addr] != 3 {
|
|
t.Errorf("addr %s: want 3 selections, got %d (all counts: %v)", addr, counts[addr], counts)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBackendAcquireCancelledContext verifies that Acquire returns ctx.Err() when the
|
|
// context is cancelled while the caller is blocked waiting for a concurrency slot,
|
|
// and that the queue depth returns to 0 after cancellation.
|
|
func TestBackendAcquireCancelledContext(t *testing.T) {
|
|
b := newBackend("host:1", 1, 1)
|
|
|
|
// G1: acquire and hold the only slot indefinitely.
|
|
if err := b.Acquire(context.Background()); err != nil {
|
|
t.Fatalf("G1 Acquire: unexpected error: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
g2Done := make(chan error, 1)
|
|
go func() {
|
|
g2Done <- b.Acquire(ctx)
|
|
}()
|
|
|
|
// Wait until G2 enters the queue channel.
|
|
for len(b.queue) == 0 {
|
|
runtime.Gosched()
|
|
}
|
|
|
|
// Verify G2 is queued (queue channel has one item).
|
|
if qd := len(b.queue); qd != 1 {
|
|
t.Errorf("expected queue depth 1 while G2 blocked, got %d", qd)
|
|
}
|
|
|
|
// Cancel ctx while G2 is waiting for the slot.
|
|
cancel()
|
|
|
|
select {
|
|
case err := <-g2Done:
|
|
if err == nil {
|
|
t.Fatal("G2 Acquire: expected ctx.Err(), got nil")
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("G2 Acquire: expected context.Canceled, got %v", err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("G2 did not return within 1s after context cancel")
|
|
}
|
|
|
|
// Queue depth must return to 0 after G2 is drained.
|
|
if qd := len(b.queue); qd != 0 {
|
|
t.Errorf("expected queue depth 0 after cancellation, got %d", qd)
|
|
}
|
|
|
|
// Cleanup: release G1's slot.
|
|
b.Release()
|
|
}
|
|
|
|
// TestSchedulerMarkFailedExcludesBackend verifies that a failed backend is not
|
|
// selected within the fail window (30 s default).
|
|
func TestSchedulerMarkFailedExcludesBackend(t *testing.T) {
|
|
addrs := []string{"a:80", "b:80"}
|
|
s := NewScheduler(addrs, 10, 10, nil, AdmissionLimits{})
|
|
|
|
s.MarkFailed("a:80")
|
|
|
|
var mu sync.Mutex
|
|
seen := make(map[string]int)
|
|
for i := 0; i < 10; i++ {
|
|
b := s.Select("any-key")
|
|
if b == nil {
|
|
t.Fatalf("call %d: Select returned nil", i)
|
|
}
|
|
mu.Lock()
|
|
seen[b.Address]++
|
|
mu.Unlock()
|
|
}
|
|
|
|
if seen["a:80"] > 0 {
|
|
t.Errorf("failed backend a:80 was selected %d times; expected 0 within fail window", seen["a:80"])
|
|
}
|
|
}
|