CI / CI (push) Successful in 43m8s
Follow-up to #65, which is already merged. The review of that PR returned BLOCK with two high-severity defects; the fix commit was pushed **after** the merge landed, so none of it is in `main`. ## What happened - `main` @ `369d51a` is the squash of the first commit only (`c5a91cf`). - `d71c884` (review fixes) is on the old branch and was never merged — PR #65 is closed. - Verified against `main` directly: `ADMISSION_MAX_INFLIGHT` 0 hits, `func (rw *responseWriter) Flush` 0 hits, `ModifyResponse` 0 hits, `Snapshot` 0 hits. - The image in production (`forgejo.riotpiao.com/rock/api-gateway@sha256:64f2bfc1…`, 3/3 replicas) is built from that squash, so the defects below are **live right now**. ## Live state confirmed `https://api.riotpiao.com/metrics` on the running pods: ``` observer_degraded 0 vllm_requests_running 1 vllm_kv_usage_perc 0.008293092588704587 admission_parked_total 0 engine_state_stale_total 0 ``` The observer itself works: it is reading the real vLLM exposition body and the startup self-check passed (`observer_degraded 0`). No requests have parked yet, so the two defects below have not been observable in the wild — they are proven from code, not from a live failure. ## Defects fixed here **1. The parked-stream keep-alive never runs in production (high).** `internal/tracing/middleware.go` wraps the `ResponseWriter` in a struct that embeds the interface and implements only `WriteHeader`/`Write`, so `w.(http.Flusher)` in `internal/proxy/proxy.go` is false for every real request (`cmd/gateway/main.go` → `tracing.Middleware` → `router`). `prewritten` therefore never becomes true, the heartbeat never starts, and a parked streaming request writes zero bytes for up to `ADMISSION_MAX_PARK_SECONDS` (120) — the client timeout the feature exists to prevent. Fixed by adding `Flush()` and `Unwrap()` to the wrapper. **2. Upstream status masked once the SSE status is spent (high, latent behind 1).** `httputil.ReverseProxy` calls `rw.WriteHeader(res.StatusCode)` unconditionally; after the park wrote 200, net/http keeps 200 and the upstream error body is appended raw into the event stream, which a streaming client reads as an empty completion and reports as success. Fixed with a `ModifyResponse` that frames an upstream `>= 400` as an `event: error` when the status has already been written. **3. Lost wakeup in the park loop.** `Get()` and `Tick()` were taken under two separate lock acquisitions, so a poll landing between them pushed the waiter onto the next generation's channel — up to one extra poll interval (2s) parked. `Snapshot()` now returns state and channel under one lock. **4. `observer_degraded` pinned at 1 forever.** It was raised only on the *first* poll's parse failure and `SetObserverDegraded(false)` had no call site, so one truncated response pinned the gauge for the pod's life, while genuine metric-name drift appearing later was never reported at all. `markDegraded` now tracks current parsability in both directions and only touches the exporter on a change. **5. `ADMISSION_MAX_INFLIGHT` silently defaulted.** Now set explicitly to `4` in `k8s/deployment.yaml` with the coupling to the engine's `--max-num-seqs` documented, instead of quietly inheriting `VLLM_MAX_NUM_SEQS`. ## Verification `go build ./...` clean · `go vet ./...` clean · `go test ./...` 368 passed, 0 failed · `structural-check.sh` 0 errors (1 pre-existing `:latest` warning) · `go.mod` unchanged. Cherry-picked onto `main` rather than merged from the old branch, so this diff is exactly the five fixes and nothing else: 5 files, +80/−8. ## Still open The reviewer's BLOCK also rested on nine functions over CRAP>30 purely from 0% coverage — the deliberate "no new tests" decision. Worth recording that defect 1 is a one-line type assertion that a `httptest` case through the real middleware chain would have caught immediately. Raising it here so the decision is visible rather than implied. --------- Co-authored-by: Poimen <[email protected]> Reviewed-on: #66 Co-authored-by: poimen <[email protected]>
225 lines
6.8 KiB
Go
225 lines
6.8 KiB
Go
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"hash/fnv"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
|
)
|
|
|
|
// ErrQueueFull is returned when the in-memory queue is at capacity.
|
|
var ErrQueueFull = errors.New("backend queue full")
|
|
|
|
// ErrAdmissionTimeout is returned when a request parked on engine admission
|
|
// exceeds the configured maximum park duration.
|
|
var ErrAdmissionTimeout = errors.New("admission timeout")
|
|
|
|
// AdmissionLimits are the engine-wide admission thresholds applied to every
|
|
// backend of a scheduler (#64).
|
|
type AdmissionLimits struct {
|
|
MaxInFlight int
|
|
MaxKVUsage float64
|
|
MaxPark time.Duration
|
|
}
|
|
|
|
// Backend represents a single vLLM upstream with a bounded in-memory queue.
|
|
// Two channels model the queue:
|
|
//
|
|
// queue — waiting requests (bounded by VLLM_MAX_QUEUE_DEPTH)
|
|
// slots — concurrent in-service requests (bounded by VLLM_MAX_NUM_SEQS)
|
|
//
|
|
// The engine gate is applied before the queue: admission reads the engine's
|
|
// own published gauges via observer, which is global across replicas.
|
|
type Backend struct {
|
|
Address string
|
|
queue chan struct{}
|
|
slots chan struct{}
|
|
observer *EngineObserver
|
|
maxInFlight int
|
|
maxKVUsage float64
|
|
maxPark time.Duration
|
|
}
|
|
|
|
// newBackend creates a Backend with given queue depth and concurrency limit.
|
|
func newBackend(addr string, maxSlots, maxQueue int) *Backend {
|
|
slots := make(chan struct{}, maxSlots)
|
|
for i := 0; i < maxSlots; i++ {
|
|
slots <- struct{}{}
|
|
}
|
|
return &Backend{
|
|
Address: addr,
|
|
queue: make(chan struct{}, maxQueue),
|
|
slots: slots,
|
|
}
|
|
}
|
|
|
|
// Acquire enters the in-memory queue then waits for a vLLM concurrency slot.
|
|
// Returns ErrQueueFull immediately if the queue channel is at capacity (no goroutine queued).
|
|
// Returns ctx.Err() if the context is cancelled while waiting for a slot.
|
|
// Returns ErrAdmissionTimeout if the engine gate parks the request past maxPark.
|
|
func (b *Backend) Acquire(ctx context.Context) error {
|
|
return b.acquire(ctx, nil)
|
|
}
|
|
|
|
// acquire is Acquire with an optional onPark callback. onPark is invoked once,
|
|
// on the calling goroutine, the first time the request has to wait for engine
|
|
// admission; the parked streaming path uses it to start an SSE heartbeat.
|
|
func (b *Backend) acquire(ctx context.Context, onPark func()) error {
|
|
// Engine gate (#64): admit on the engine's own gauges, not on this pod's
|
|
// counters. Failing OPEN on !Healthy is deliberate — a metrics outage must
|
|
// not become an inference outage; the three-replica gate is only a fairness
|
|
// improvement on top of the engine's own queueing.
|
|
if b.observer != nil {
|
|
deadline := time.Now().Add(b.maxPark)
|
|
timer := time.NewTimer(time.Until(deadline))
|
|
defer timer.Stop()
|
|
parked := false
|
|
for {
|
|
// State and tick channel are read together under one lock: taking
|
|
// them separately can miss a poll that lands between the two calls.
|
|
st, tick := b.observer.Snapshot()
|
|
if !st.Healthy || (st.InFlight() < int64(b.maxInFlight) && st.KVUsage < b.maxKVUsage) {
|
|
break
|
|
}
|
|
if !parked {
|
|
observability.Global().IncrAdmissionParked()
|
|
parked = true
|
|
if onPark != nil {
|
|
onPark()
|
|
}
|
|
}
|
|
select {
|
|
case <-tick:
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-timer.C:
|
|
return ErrAdmissionTimeout
|
|
}
|
|
}
|
|
}
|
|
|
|
// Non-blocking queue entry: reject immediately when queue is full.
|
|
select {
|
|
case b.queue <- struct{}{}:
|
|
// entered queue
|
|
default:
|
|
return ErrQueueFull
|
|
}
|
|
// Wait for a concurrency slot, then leave the queue.
|
|
select {
|
|
case <-b.slots:
|
|
<-b.queue // leave queue; now in service
|
|
return nil
|
|
case <-ctx.Done():
|
|
<-b.queue // leave queue; cancelled
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Release returns a concurrency slot to the pool after the upstream request completes.
|
|
func (b *Backend) Release() {
|
|
b.slots <- struct{}{}
|
|
}
|
|
|
|
// Scheduler routes LLM requests to backends using rendezvous hashing (cache-aware)
|
|
// or round-robin (no cache key). Failed backends are excluded for a configurable window.
|
|
type Scheduler struct {
|
|
mu sync.RWMutex
|
|
backends []*Backend
|
|
failedUntil map[string]time.Time
|
|
failWindow time.Duration
|
|
rrIdx atomic.Uint64
|
|
}
|
|
|
|
// NewScheduler creates a Scheduler for the given addresses.
|
|
// maxSlots = VLLM_MAX_NUM_SEQS, maxQueue = VLLM_MAX_QUEUE_DEPTH.
|
|
// observers holds one engine observer per backend address (may be nil).
|
|
func NewScheduler(addresses []string, maxSlots, maxQueue int, observers map[string]*EngineObserver, limits AdmissionLimits) *Scheduler {
|
|
backends := make([]*Backend, len(addresses))
|
|
for i, addr := range addresses {
|
|
backends[i] = newBackend(addr, maxSlots, maxQueue)
|
|
backends[i].observer = observers[addr]
|
|
backends[i].maxInFlight = limits.MaxInFlight
|
|
backends[i].maxKVUsage = limits.MaxKVUsage
|
|
backends[i].maxPark = limits.MaxPark
|
|
}
|
|
return &Scheduler{
|
|
backends: backends,
|
|
failedUntil: make(map[string]time.Time),
|
|
failWindow: 30 * time.Second,
|
|
}
|
|
}
|
|
|
|
// Select returns a backend. Uses rendezvous hashing when cacheKey is non-empty
|
|
// (same key always routes to the same backend, skipping unhealthy ones).
|
|
// Uses round-robin when cacheKey is empty.
|
|
func (s *Scheduler) Select(cacheKey string) *Backend {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
if len(s.backends) == 0 {
|
|
return nil
|
|
}
|
|
if len(s.backends) == 1 {
|
|
return s.backends[0]
|
|
}
|
|
if cacheKey != "" {
|
|
return s.selectRendezvous(cacheKey)
|
|
}
|
|
return s.selectRoundRobin()
|
|
}
|
|
|
|
// selectRendezvous picks the healthy backend with the highest FNV32a score
|
|
// for the key (highest random weight / rendezvous hashing).
|
|
// Falls back to next-highest score when primary is excluded by MarkFailed.
|
|
// Must be called with mu held.
|
|
func (s *Scheduler) selectRendezvous(cacheKey string) *Backend {
|
|
type scored struct {
|
|
b *Backend
|
|
score uint32
|
|
}
|
|
now := time.Now()
|
|
scores := make([]scored, len(s.backends))
|
|
h := fnv.New32a()
|
|
for i, b := range s.backends {
|
|
h.Reset()
|
|
h.Write([]byte(cacheKey + ":" + b.Address))
|
|
scores[i] = scored{b: b, score: h.Sum32()}
|
|
}
|
|
sort.Slice(scores, func(i, j int) bool {
|
|
return scores[i].score > scores[j].score
|
|
})
|
|
for _, sc := range scores {
|
|
_, failed := s.failedUntil[sc.b.Address]
|
|
if !failed {
|
|
return sc.b
|
|
}
|
|
expiry := s.failedUntil[sc.b.Address]
|
|
if now.After(expiry) {
|
|
return sc.b
|
|
}
|
|
}
|
|
// All backends unhealthy: return highest-scored (fail-open).
|
|
return scores[0].b
|
|
}
|
|
|
|
// selectRoundRobin distributes requests evenly when no cache key is present.
|
|
// Must be called with mu held.
|
|
func (s *Scheduler) selectRoundRobin() *Backend {
|
|
idx := s.rrIdx.Add(1) - 1
|
|
return s.backends[idx%uint64(len(s.backends))]
|
|
}
|
|
|
|
// MarkFailed excludes addr from routing for the fail window (default 30 s).
|
|
// Called by the proxy ErrorHandler on upstream connection failure.
|
|
func (s *Scheduler) MarkFailed(addr string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.failedUntil[addr] = time.Now().Add(s.failWindow)
|
|
}
|