Files
llmgw/test/e2e/phase1_test.go
T
ilkerandClaude Opus 5 a3f71aa5ee
CI / test (push) Successful in 1m1s
Phase 1: the router serves traffic
llmgw server is real now. It fronts a static set of engine replicas with an
OpenAI-compatible API, scoring endpoints through the pipeline added in the
previous commit.

- proxy: SSE relayed event by event with a flush after each, cancellation
  propagated upstream, and separate deadlines for dial, headers, first token,
  inter-token idle and total. The idle watchdog is what distinguishes a wedged
  engine from a slow one.
- router: dispatch with retry and model fallback. Retry only happens while
  nothing has reached the client; after that, splicing two responses together
  would be worse than the short stream the client already sees.
- config: YAML via koanf overlaid on defaults, with LLMGW_ env overrides.
  Validation rejects duplicate endpoint ids and aliases that shadow a model,
  because both misroute silently at request time rather than failing loudly.
- metrics: requests, duration, TTFT, tokens, plus per-scorer values and
  per-filter drops on a separate admin listener with health and pprof.

Phase 1 exit criterion, measured in test/e2e: 1000 concurrent streams across
3 sim-engines complete at ~6800 req/s; every client disconnect frees the KV
blocks on every engine; router overhead p99 is about 0.9 ms against the plan's
2 ms budget.

The handlers live in internal/router rather than an internal/router/openai
package as the plan's tree sketched: they need the dispatch loop, and splitting
them apart would have meant an interface with exactly one implementation. Plan
§4 updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 01:41:13 +02:00

198 lines
5.4 KiB
Go

package e2e
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"slices"
"sync"
"sync/atomic"
"testing"
"time"
)
func bytesReader(b []byte) *bytes.Reader { return bytes.NewReader(b) }
func drainBody(resp *http.Response) {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
// TestConcurrentStreams is the first half of the phase 1 exit criterion:
// 1000 concurrent streams behind 3 sim-engines, all of them completing.
func TestConcurrentStreams(t *testing.T) {
concurrency := envInt("LLMGW_E2E_CONCURRENCY", 1000)
if testing.Short() {
concurrency = 50
}
const tokens = 16
h := start(t, 3)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
var (
ok atomic.Int64
short atomic.Int64
failed atomic.Int64
firstErr atomic.Value
wg sync.WaitGroup
)
start := time.Now()
for i := range concurrency {
wg.Add(1)
go func() {
defer wg.Done()
n, _, err := streamRequest(ctx, h.router.URL,
chatBody(fmt.Sprintf("request %d", i), tokens, true),
map[string]string{"x-session-id": fmt.Sprintf("s%d", i%97)})
switch {
case err != nil:
failed.Add(1)
firstErr.CompareAndSwap(nil, err)
case n < tokens:
short.Add(1)
default:
ok.Add(1)
}
}()
}
wg.Wait()
elapsed := time.Since(start)
t.Logf("%d streams in %v (%.0f req/s): ok=%d short=%d failed=%d",
concurrency, elapsed.Round(time.Millisecond),
float64(concurrency)/elapsed.Seconds(), ok.Load(), short.Load(), failed.Load())
if f := failed.Load(); f > 0 {
t.Fatalf("%d of %d streams failed; first error: %v", f, concurrency, firstErr.Load())
}
if s := short.Load(); s > 0 {
t.Errorf("%d streams ended before %d tokens", s, tokens)
}
// Load must actually have been spread; a single engine taking everything
// would pass the checks above while defeating the point of the router.
used := 0
for _, e := range h.engines {
if st := e.eng.Stats(); st.PrefixQueries > 0 || st.CachedBlocks > 0 {
used++
}
}
if used < 2 {
t.Errorf("only %d of %d engines saw traffic", used, len(h.engines))
}
}
// TestClientDisconnectStopsUpstream is the second half of the exit criterion:
// when a client goes away, the GPU must stop working on its request.
func TestClientDisconnectStopsUpstream(t *testing.T) {
const streams = 40
h := start(t, 3)
ctx, cancel := context.WithCancel(context.Background())
var wg sync.WaitGroup
for i := range streams {
wg.Add(1)
go func() {
defer wg.Done()
// Long generations, so every request is still running when cancelled.
_, _, _ = streamRequest(ctx, h.router.URL,
chatBody(fmt.Sprintf("long request %d", i), 100000, true), nil)
}()
}
// Wait until the engines are actually busy before pulling the plug.
deadline := time.Now().Add(10 * time.Second)
for h.totalRunning() < streams && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if busy := h.totalRunning(); busy < streams {
t.Fatalf("only %d of %d requests reached the engines", busy, streams)
}
cancel()
wg.Wait()
// The engines must drain: cancellation has to propagate router -> engine.
deadline = time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if h.totalRunning() == 0 {
// And the KV blocks must come back, not just the request slots.
for _, e := range h.engines {
if u := e.eng.Stats().KVCacheUsage; u != 0 {
t.Fatalf("KV cache still held after cancellation: %v", u)
}
}
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("engines still working %d requests after every client disconnected", h.totalRunning())
}
// TestRouterOverhead is the third part of the exit criterion: the router should
// add little latency of its own.
//
// It measures the same request against an engine directly and through the
// router, and reports the p99 difference. The assertion is deliberately loose,
// because a shared CI machine cannot hold a 2 ms bound reliably; set
// LLMGW_E2E_STRICT=1 on quiet hardware to enforce the plan's actual target.
func TestRouterOverhead(t *testing.T) {
samples := envInt("LLMGW_E2E_SAMPLES", 300)
if testing.Short() {
samples = 50
}
h := start(t, 1)
direct := h.engines[0].srv.URL
body := chatBody("overhead probe", 1, false)
measure := func(url string) []time.Duration {
out := make([]time.Duration, 0, samples)
for range samples {
start := time.Now()
resp, err := http.Post(url+"/v1/chat/completions", "application/json", bytesReader(body)) //nolint:noctx // test
if err != nil {
t.Fatal(err)
}
drainBody(resp)
out = append(out, time.Since(start))
}
slices.Sort(out)
return out
}
// Warm both paths so connection setup does not land in the samples.
measure(direct)
measure(h.router.URL)
d := measure(direct)
r := measure(h.router.URL)
p := func(s []time.Duration, q float64) time.Duration {
return s[min(int(float64(len(s))*q), len(s)-1)]
}
overheadP50 := p(r, 0.50) - p(d, 0.50)
overheadP99 := p(r, 0.99) - p(d, 0.99)
t.Logf("direct p50=%v p99=%v | routed p50=%v p99=%v | overhead p50=%v p99=%v",
p(d, 0.50).Round(time.Microsecond), p(d, 0.99).Round(time.Microsecond),
p(r, 0.50).Round(time.Microsecond), p(r, 0.99).Round(time.Microsecond),
overheadP50.Round(time.Microsecond), overheadP99.Round(time.Microsecond))
limit := 25 * time.Millisecond
if os.Getenv("LLMGW_E2E_STRICT") == "1" {
limit = 2 * time.Millisecond
}
if overheadP99 > limit {
t.Errorf("router added %v at p99, over the %v budget", overheadP99, limit)
}
}