CI / CI (push) Successful in 41m20s
Closes #64 Replaces the per-pod concurrency semaphore as the effective cap with admission gated on the vLLM engine's own scheduler gauges. ## Why `internal/proxy/proxy.go:119` built a semaphore of 4 slots *inside each gateway process*, and `api-gateway` runs 3 replicas, so the advertised cap was 12 while the engine's `--max-num-seqs` is 4. Nothing coordinated replicas, and the mismatch was invisible because the queue never rejected anything (`queue_full_requests_total 0`). Measured on the engine while investigating a 10-minute TTFT: - `vllm:cache_config_info{num_gpu_blocks="9527",block_size="16"}` = **152,432 tokens** of KV capacity for the whole engine. PP=2 splits layers, so both ranks hold KV for the same tokens — not 2x that. - Uncached prefill runs at **262 tok/s** contended, 525 tok/s over 7d (`vllm:request_prefill_time_seconds_sum/count`). - One request showed `llm_ttft_seconds_sum 590.981 / count 5` at the gateway and a `(480, 960]` s entry in `vllm:request_prefill_time_seconds_bucket`, with engine-side generation throughput at 0.0-0.1 tok/s throughout: a 65k-token prompt costs ~250 s of prefill and starves every other request while it runs. - `vllm:num_preemptions_total = 0`; KV peaked at `0.5399`. Reading the engine's own gauges is global by construction, needs no lock and no new datastore, and it also accounts for callers that never pass the gateway — namespace `poimen` is admitted directly on 80/8080 by `llm-serving-ingress`, and `memory/crates/mem-cli/src/kv_warmer.rs` is such a caller. A distributed lock would have been blind to that traffic. ## Changes - **new `internal/proxy/engine_state.go`** — 2s scrape of `http://<addr>/metrics` on its own client (the proxy transports carry socket options tuned for inference), hand-rolled exposition parse, `Healthy=false` after 3 consecutive failed polls with `engine_state_stale_total` counted once at the transition, and a startup self-check that raises `observer_degraded` and logs at error level instead of degrading to fail-open silently. - **admission gate** (`internal/proxy/scheduler.go`) — parks 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 or unhealthy state fails **open** on purpose: a metrics outage must not become an inference outage. - **parked streaming heartbeat** (`internal/proxy/proxy.go`) — `stream: true` requests get 200 + `text/event-stream` and a `: keep-alive` comment every 15s while parked, so a client idle timeout does not kill a request that is legitimately waiting. Non-streaming requests park silently. - **`internal/proxy/router.go`** — extract only the top-level `model` field with a `json.Decoder` token walk instead of unmarshalling the whole body into `map[string]interface{}` (that built the entire `messages`+`tools` tree per request, ~300 KB for a 65k-token prompt). `MaxBodySize` now comes from `MAX_BODY_BYTES`; the model path previously inherited the 100 MB default at `proxy.go:93` against a 512Mi container limit. - **`cmd/gateway/main.go`** — `GOMAXPROCS` from the cgroup CPU quota (cgroup v2 `cpu.max`, v1 fallback). The container is limited to `500m` while Go defaulted to the host core count and was being CFS-throttled. - **`k8s/deployment.yaml`** — `ADMISSION_MAX_PARK_SECONDS=120`, `ADMISSION_OBSERVE_MODELS=reasoning`, `VLLM_MAX_QUEUE_DEPTH=8`, `MAX_BODY_BYTES=1048576`; corrected the `llm-client` comment (the NetworkPolicy selects by namespace now, not by that label). - **deleted `k8s/configmap.yaml`** — absent from `k8s/kustomization.yaml`, and the live ConfigMap of that name carries an unrelated key (`gw-config-fixed.yaml`) plus a stale routing table. - **`internal/observability/`** — `admission_parked_total`, `admission_rejected_total{reason}`, `engine_state_stale_total`, and gauges `vllm_requests_running` / `vllm_kv_usage_perc` (the gateway's own reading, deliberately mirroring the vLLM series so a disagreement is visible) and `observer_degraded`. Names follow the existing convention, which carries no prefix. Out of scope here, in `riotpiao-poimen/homelab#51`: the engine flags (`--max-num-partial-prefills`, `--max-long-partial-prefills`, `--long-prefill-token-threshold`, `--max-num-batched-tokens`, `--max-num-seqs`, `--gpu-memory-utilization`) and the four dashboard panels in `generate-dashboards.py`. ## Verification - `go build ./...` clean, `go vet ./...` clean. - `go test ./...` — 368 tests, 0 failures (baseline on `main` is 368/0). - `structural-check.sh` on the changed files — 0 errors, 1 pre-existing warning (`:latest` image tag). - `go.mod` / `go.sum` byte-identical: no new dependency. Not yet verified: the real-engine load test (2x65k + 15x8k against `reasoning`). That run also produces the number for `ADMISSION_MAX_PARK_SECONDS`, which today is the provisional 120; retuning it is a follow-up since it is env-only and needs no rebuild. ## Accepted risks - **No tests were added** for the observer or the gate (deliberate: production is the test bed). The mitigation is the startup self-check, which is production self-diagnosis rather than a test, plus the `observer_degraded` alert. A parse failure that outlives the first poll logs and surfaces as a metric; it does not silently disable gating without a signal. - **The cap is soft, not hard.** Between polls each replica may admit more than one request, so there is no clean bound. Exposure is low (~11,500 requests over 7d ≈ 1.4/min) and the engine remains the hard limit — a race parks requests in vLLM's queue rather than over-committing KV. - **`ADMISSION_MAX_INFLIGHT` is manually coupled to `--max-num-seqs`** and can drift silently. It defaults to `VLLM_MAX_NUM_SEQS`, and startup logs the effective value. - The first scrape waits one interval, so the first ~2s of admission is ungated (fail-open). Scraping at `Start()` instead would make the gateway contact every observed backend before any request needs one, which is wasted work and a surprising side effect. ## Rollback Revert this commit and redeploy. The admission path is fail-open, so the code can also be neutralised without reverting by unsetting `ADMISSION_OBSERVE_MODELS`. Separately, the orphaned cluster object can be removed by hand: `kubectl delete cm -n api api-gateway-config` (not a code change, deliberately left to a human). --------- Reviewed-on: #65 Co-authored-by: poimen <[email protected]>
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"])
|
|
}
|
|
}
|