Files
homelab-frontend/internal/proxy/scheduler_test.go
T
rockandAdmin Bot ef5e736673
CI / CI (push) Successful in 3m20s
feat(cag): CAG integration — memory adapter, warmup semaphore, cache-aware routing (#47)
## Summary

Three issues in one PR. Implements Cache-Augmented Generation (CAG) support in the api-gw.

---

### #44 — Memory ServiceAdapter: standing-context resource

- Code-registers `memory` adapter with `standing-context` resource (`POST /memory/standing-context`)
- Fixes dispatcher bug: method-level `Auth.Capability` override now respected (was always using verb-derived capability)
- Auth: `memory:read` required via method-level override; `memory:write` for ingest
- `MEMORY_UPSTREAM_URL` env configures upstream

### #45 — Continuous batching: warmup deprioritisation + per-backend semaphore

- In-memory bounded queue per backend: `VLLM_MAX_QUEUE_DEPTH` slots (default 20)
- Concurrency limiter: `VLLM_MAX_NUM_SEQS` slots (default 4)
- `X-Poimen-Warmup: true` → sleep `WARMUP_DELAY_MS` (default 500ms) before queue entry, strip header, add `X-Priority: low`
- Queue full → 503 immediately, no goroutine pile-up
- Metrics: `warmup_requests_delayed_total`, `queue_full_requests_total` at `/metrics`

### #46 — Cache-aware routing: rendezvous hash + prefix cache hit logging

- `addresses: [...]` YAML field for multi-backend models (backward-compat with `address`)
- `X-Poimen-Cache-Key` header → rendezvous-hash backend selection (same key = same GPU)
- No cache key → round-robin
- Failed backend excluded 30s; next-highest-score fallback
- `vllm-num-cached-tokens` response header captured → `vllm_prefix_cache_hit_tokens_total` counter + structured log

---

## Verification

```
go build ./...    ✅
go vet ./...      ✅
go test -race ./... ✅ (all packages, 0 races)
```

## Reviewer verdicts

- reviewer-sre: SIGN-OFF
- reviewer-quality: SIGN-OFF

## Non-blocking findings (🟡🔵 only)

- `getOrCreateTransport` not goroutine-safe by design; pre-population invariant documented
- `time.Sleep(warmupDelay)` holds goroutine; context-cancel alternative left as TODO
- OTel trace_id not propagated into CAG log events (existing gap)

Closes #44 Closes #45 Closes #46

---------

Co-authored-by: Admin Bot <[email protected]>
Reviewed-on: #47
2026-09-27 11:16:40 +00:00

183 lines
4.8 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)
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)
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)
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"])
}
}