Files
llama-swap/internal/server/server_test.go
T
steveandClaude Opus 5 0358fe321e internal/router: priority queues so batch jobs yield to interactive requests
The GPU is a size-1 resource, so a single long job monopolises the box for its
whole duration and every interactive request queues behind it. Callers can now
declare intent with an X-LlamaSwap-Priority header and the serial scheduler
dispatches by score instead of by arrival.

- X-LlamaSwap-Priority: signed integer, 0 default, absent/unparseable means 0.
  interactive/normal/batch aliases resolve to +100/0/-100. Values are not
  clamped: the caller composes band and any per-user offset itself.
- serial dispatch score = priority + swap affinity + aging. Bands sit 100 apart
  so a small caller offset orders work inside a band without crossing one;
  aging is unbounded so low-priority work cannot starve.
- routing.scheduler.settings.serial.{agingDivisor,swapAffinityBonus}, defaulting
  to 60s/point and +10. swapAffinityBonus is capped at 99 so it can never
  promote a request into the next band.
- fifo adds the header to its per-model priority, so the header is not silently
  ignored under that scheduler.
- /metrics exports per-band queue depth, oldest wait and dispatch counts, plus
  counters for how often aging or swap affinity changed the pick. Each request
  records its priority, band, score and queue wait in the activity log.

Note swapAffinityBonus defaults to 10, so equal-priority requests for the
already-loaded model now run before older requests that need a swap. Set it to
0 for the previous strict arrival order.

fixes #9

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01WUyhZBgv8BBCC5MduX88gE
2026-08-07 02:12:08 -04:00

406 lines
12 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/mostlygeek/llama-swap/internal/config"
"github.com/mostlygeek/llama-swap/internal/event"
"github.com/mostlygeek/llama-swap/internal/logmon"
"github.com/mostlygeek/llama-swap/internal/process"
"github.com/mostlygeek/llama-swap/internal/router"
"github.com/mostlygeek/llama-swap/internal/router/scheduler"
"github.com/mostlygeek/llama-swap/internal/shared"
)
// stubRouter is a minimal router.LocalRouter for Server dispatch tests.
type stubRouter struct {
models map[string]bool
response string
shutdownCalls atomic.Int32
running map[string]process.ProcessState
unloadCalls atomic.Int32
loggers map[string]*logmon.Monitor
queueStats *scheduler.QueueStats
}
func newStubRouter(models []string, response string) *stubRouter {
m := make(map[string]bool, len(models))
for _, id := range models {
m[id] = true
}
return &stubRouter{models: m, response: response}
}
func (s *stubRouter) Handles(model string) bool { return s.models[model] }
func (s *stubRouter) Shutdown(_ time.Duration) error { s.shutdownCalls.Add(1); return nil }
func (s *stubRouter) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(s.response))
}
func (s *stubRouter) RunningModels() map[string]process.ProcessState { return s.running }
func (s *stubRouter) Unload(_ time.Duration, _ ...string) { s.unloadCalls.Add(1) }
func (s *stubRouter) ProcessLogger(modelID string) (*logmon.Monitor, bool) {
if s.loggers != nil {
if lg, ok := s.loggers[modelID]; ok {
return lg, true
}
}
return nil, false
}
func (s *stubRouter) SchedulerStats() (scheduler.QueueStats, bool) {
if s.queueStats == nil {
return scheduler.QueueStats{}, false
}
return *s.queueStats, true
}
// newTestServer wires a Server with stub routers and a built mux.
func newTestServer(local router.LocalRouter, peer router.Router) *Server {
ctx, cancel := context.WithCancel(context.Background())
proxylog := logmon.NewWriter(io.Discard)
s := &Server{
cfg: config.Config{},
muxlog: logmon.NewWriter(io.Discard),
proxylog: proxylog,
upstreamlog: logmon.NewWriter(io.Discard),
inflight: &inflightCounter{},
metrics: newMetricsMonitor(proxylog, 0, 0),
local: local,
peer: peer,
shutdownCtx: ctx,
shutdownFn: cancel,
}
s.routes()
return s
}
func chatRequest(model string) *http.Request {
body := strings.NewReader(`{"model":"` + model + `"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", body)
req.Header.Set("Content-Type", "application/json")
return req
}
func TestServer_New_GroupConfig(t *testing.T) {
discard := logmon.NewWriter(io.Discard)
cfg := config.Config{HealthCheckTimeout: 15}
cfg.Routing.Router.Use = "group"
s, err := New(cfg, discard, discard, discard, nil, BuildInfo{})
if err != nil {
t.Fatalf("New (group): %v", err)
}
if _, ok := s.local.(*router.Group); !ok {
t.Fatalf("localRouter=%T want *router.Group", s.local)
}
if err := s.Shutdown(time.Second); err != nil {
t.Fatalf("Shutdown: %v", err)
}
}
func TestServer_New_MatrixConfig(t *testing.T) {
discard := logmon.NewWriter(io.Discard)
cfg := config.Config{HealthCheckTimeout: 15}
cfg.Routing.Router.Use = "matrix"
cfg.Routing.Router.Settings.Matrix = &config.MatrixConfig{}
s, err := New(cfg, discard, discard, discard, nil, BuildInfo{})
if err != nil {
t.Fatalf("New (matrix): %v", err)
}
if _, ok := s.local.(*router.Matrix); !ok {
t.Fatalf("localRouter=%T want *router.Matrix", s.local)
}
if err := s.Shutdown(time.Second); err != nil {
t.Fatalf("Shutdown: %v", err)
}
}
func TestServer_RouteToLocalModel(t *testing.T) {
s := newTestServer(
newStubRouter([]string{"local-model"}, "local response"),
newStubRouter(nil, ""),
)
w := httptest.NewRecorder()
s.ServeHTTP(w, chatRequest("local-model"))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
}
if w.Body.String() != "local response" {
t.Errorf("body=%q want %q", w.Body.String(), "local response")
}
}
func TestServer_RouteToPeerModel(t *testing.T) {
s := newTestServer(
newStubRouter(nil, ""),
newStubRouter([]string{"peer-model"}, "peer response"),
)
w := httptest.NewRecorder()
s.ServeHTTP(w, chatRequest("peer-model"))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
}
if w.Body.String() != "peer response" {
t.Errorf("body=%q want %q", w.Body.String(), "peer response")
}
}
func TestServer_UnknownModelReturns404(t *testing.T) {
s := newTestServer(
newStubRouter([]string{"local-model"}, ""),
newStubRouter(nil, ""),
)
w := httptest.NewRecorder()
s.ServeHTTP(w, chatRequest("unknown-model"))
if w.Code != http.StatusNotFound {
t.Errorf("status=%d want 404 body=%q", w.Code, w.Body.String())
}
}
func TestServer_UnknownPathReturns404(t *testing.T) {
s := newTestServer(newStubRouter(nil, ""), newStubRouter(nil, ""))
w := httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/does-not-exist", nil))
if w.Code != http.StatusNotFound {
t.Errorf("status=%d want 404", w.Code)
}
}
func TestServer_Health(t *testing.T) {
s := newTestServer(newStubRouter(nil, ""), newStubRouter(nil, ""))
for _, path := range []string{"/health", "/wol-health"} {
w := httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
if w.Code != http.StatusOK || w.Body.String() != "OK" {
t.Errorf("%s: status=%d body=%q", path, w.Code, w.Body.String())
}
}
}
func TestServer_CORSPreflight(t *testing.T) {
s := newTestServer(newStubRouter(nil, ""), newStubRouter(nil, ""))
req := httptest.NewRequest(http.MethodOptions, "/v1/chat/completions", nil)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("status=%d want 204", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("Access-Control-Allow-Origin=%q want *", got)
}
}
func TestServer_Unload(t *testing.T) {
local := newStubRouter([]string{"m1"}, "")
s := newTestServer(local, newStubRouter(nil, ""))
w := httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/unload", nil))
if w.Code != http.StatusOK || w.Body.String() != "OK" {
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
}
if got := local.unloadCalls.Load(); got != 1 {
t.Errorf("unloadCalls=%d want 1", got)
}
}
func TestServer_Running(t *testing.T) {
local := newStubRouter([]string{"m1"}, "")
local.running = map[string]process.ProcessState{"m1": process.StateReady}
s := newTestServer(local, newStubRouter(nil, ""))
s.cfg = config.Config{Models: map[string]config.ModelConfig{
"m1": {
Cmd: "llama-server",
Proxy: "http://localhost:9999",
UnloadAfter: 300,
Name: "Model One",
Description: "the first model",
},
}}
w := httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/running", nil))
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
}
var resp struct {
Running []runningModel `json:"running"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v body=%q", err, w.Body.String())
}
if len(resp.Running) != 1 {
t.Fatalf("running=%v want 1 entry", resp.Running)
}
want := runningModel{
Model: "m1",
State: "ready",
Cmd: "llama-server",
Proxy: "http://localhost:9999",
TTL: 300,
Name: "Model One",
Description: "the first model",
}
if resp.Running[0] != want {
t.Errorf("got %+v want %+v", resp.Running[0], want)
}
}
func TestServer_Preload(t *testing.T) {
local := newStubRouter([]string{"m1"}, "ok")
s := newTestServer(local, newStubRouter(nil, ""))
s.cfg = config.Config{Hooks: config.HooksConfig{
OnStartup: config.HookOnStartup{Preload: []string{"m1"}},
}}
got := make(chan shared.ModelPreloadedEvent, 1)
cancel := event.On(func(e shared.ModelPreloadedEvent) { got <- e })
defer cancel()
s.startPreload()
select {
case e := <-got:
if e.ModelName != "m1" || !e.Success {
t.Errorf("event=%+v want {ModelName:m1 Success:true}", e)
}
case <-time.After(2 * time.Second):
t.Fatal("preload event not received")
}
}
func TestServer_Shutdown_StopsRoutersAndIsIdempotent(t *testing.T) {
local := newStubRouter([]string{"local-model"}, "")
peer := newStubRouter(nil, "")
s := newTestServer(local, peer)
if err := s.Shutdown(time.Second); err != nil {
t.Fatalf("Shutdown: %v", err)
}
if err := s.Shutdown(time.Second); err != nil {
t.Fatalf("second Shutdown: %v", err)
}
if got := local.shutdownCalls.Load(); got != 1 {
t.Errorf("local shutdownCalls=%d want 1", got)
}
if got := peer.shutdownCalls.Load(); got != 1 {
t.Errorf("peer shutdownCalls=%d want 1", got)
}
}
func TestServer_LogStream_ModelID(t *testing.T) {
buf := logmon.NewWriter(io.Discard)
buf.Write([]byte("hello from model"))
local := newStubRouter([]string{"mymodel"}, "")
local.loggers = map[string]*logmon.Monitor{"mymodel": buf}
s := newTestServer(local, newStubRouter(nil, ""))
s.cfg = config.Config{Models: map[string]config.ModelConfig{"mymodel": {}}}
// Pre-cancel the context so the streaming loop exits immediately after
// flushing history.
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/logs/stream/mymodel", nil).WithContext(ctx)
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
}
if got := w.Body.String(); got != "hello from model" {
t.Errorf("body=%q want %q", got, "hello from model")
}
}
func TestServer_LogStream_UnknownID_Returns400(t *testing.T) {
s := newTestServer(newStubRouter(nil, ""), newStubRouter(nil, ""))
w := httptest.NewRecorder()
s.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/logs/stream/no-such-model", nil))
if w.Code != http.StatusBadRequest {
t.Errorf("status=%d want 400", w.Code)
}
}
func TestServer_VideoRoutesDispatch(t *testing.T) {
s := newTestServer(
newStubRouter([]string{"videogen-model"}, "video response"),
newStubRouter(nil, ""),
)
// Multipart form on /v1/videos/sync.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mw.WriteField("model", "videogen-model")
mw.WriteField("prompt", "a cat")
mw.Close()
req := httptest.NewRequest(http.MethodPost, "/v1/videos/sync", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
w := httptest.NewRecorder()
s.ServeHTTP(w, req)
if w.Code != http.StatusOK || w.Body.String() != "video response" {
t.Fatalf("/v1/videos/sync: status=%d body=%q", w.Code, w.Body.String())
}
// JSON bodies dispatch too (extraction is content-type driven).
jsonReq := httptest.NewRequest(http.MethodPost, "/v1/videos/sync",
strings.NewReader(`{"model":"videogen-model","prompt":"a cat"}`))
jsonReq.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
s.ServeHTTP(w, jsonReq)
if w.Code != http.StatusOK || w.Body.String() != "video response" {
t.Fatalf("/v1/videos/sync json: status=%d body=%q", w.Code, w.Body.String())
}
// Unknown model on the video route still 404s.
nopeReq := httptest.NewRequest(http.MethodPost, "/v1/videos/sync",
strings.NewReader(`{"model":"nope","prompt":"a cat"}`))
nopeReq.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
s.ServeHTTP(w, nopeReq)
if w.Code != http.StatusNotFound {
t.Fatalf("/v1/videos/sync unknown model: status=%d want 404", w.Code)
}
// The async job-creation route is NOT dispatched (no poll routes to
// complete the flow) — a stray OpenAI-videos client gets a clean 404
// instead of an unretrievable upstream job.
asyncReq := httptest.NewRequest(http.MethodPost, "/v1/videos",
strings.NewReader(`{"model":"videogen-model","prompt":"a cat"}`))
asyncReq.Header.Set("Content-Type", "application/json")
w = httptest.NewRecorder()
s.ServeHTTP(w, asyncReq)
if w.Code != http.StatusNotFound {
t.Fatalf("POST /v1/videos: status=%d want 404 (async family not routed)", w.Code)
}
}