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
337 lines
9.7 KiB
Go
337 lines
9.7 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mostlygeek/llama-swap/internal/config"
|
|
"github.com/mostlygeek/llama-swap/internal/logmon"
|
|
"github.com/mostlygeek/llama-swap/internal/process"
|
|
"github.com/mostlygeek/llama-swap/internal/router/scheduler"
|
|
"github.com/mostlygeek/llama-swap/internal/shared"
|
|
)
|
|
|
|
// These tests cover baseRouter's own machinery — the run loop, process
|
|
// lifecycle (doSwap), grant/ServeHTTP plumbing, Unload, and Shutdown. The
|
|
// scheduling decision logic (queueing, collation, eviction collisions) lives in
|
|
// the scheduler package and is tested directly there; see fifo_test.go.
|
|
|
|
// stubPlanner evicts nothing. baseRouter tests drive the run loop through the
|
|
// default FIFO scheduler without exercising any particular eviction policy.
|
|
type stubPlanner struct{}
|
|
|
|
func (s *stubPlanner) EvictionFor(string, []string) []string { return nil }
|
|
func (s *stubPlanner) OnSwapStart(string, []string) {}
|
|
|
|
func newTestBase(t *testing.T, processes map[string]process.Process, planner scheduler.Swapper) *baseRouter {
|
|
t.Helper()
|
|
conf := config.Config{HealthCheckTimeout: 5}
|
|
b, err := newBaseRouter("test", conf, processes, logmon.NewWriter(io.Discard), planner)
|
|
if err != nil {
|
|
t.Fatalf("newBaseRouter: %v", err)
|
|
}
|
|
b.testProcessed = make(chan struct{}, 64)
|
|
go b.run()
|
|
t.Cleanup(func() {
|
|
if !b.shuttingDown.Load() {
|
|
_ = b.Shutdown(time.Second)
|
|
}
|
|
})
|
|
return b
|
|
}
|
|
|
|
func TestBaseRouter_RunningModels(t *testing.T) {
|
|
ready := newFakeProcess("ready")
|
|
ready.markReady()
|
|
starting := newFakeProcess("starting")
|
|
starting.setState(process.StateStarting)
|
|
stopped := newFakeProcess("stopped")
|
|
|
|
b := newTestBase(t, map[string]process.Process{
|
|
"ready": ready, "starting": starting, "stopped": stopped,
|
|
}, &stubPlanner{})
|
|
|
|
running := b.RunningModels()
|
|
if len(running) != 2 {
|
|
t.Fatalf("running=%v want 2 entries", running)
|
|
}
|
|
if running["ready"] != process.StateReady {
|
|
t.Errorf("ready state=%q want ready", running["ready"])
|
|
}
|
|
if running["starting"] != process.StateStarting {
|
|
t.Errorf("starting state=%q want starting", running["starting"])
|
|
}
|
|
if _, ok := running["stopped"]; ok {
|
|
t.Errorf("stopped process should be excluded from RunningModels")
|
|
}
|
|
}
|
|
|
|
func TestBaseRouter_UnloadAll(t *testing.T) {
|
|
a := newFakeProcess("a")
|
|
a.markReady()
|
|
c := newFakeProcess("c")
|
|
c.markReady()
|
|
|
|
b := newTestBase(t, map[string]process.Process{"a": a, "c": c}, &stubPlanner{})
|
|
b.Unload(time.Second)
|
|
|
|
if a.State() != process.StateStopped || c.State() != process.StateStopped {
|
|
t.Fatalf("Unload() should stop every process: a=%q c=%q", a.State(), c.State())
|
|
}
|
|
}
|
|
|
|
func TestBaseRouter_UnloadSpecificModel(t *testing.T) {
|
|
a := newFakeProcess("a")
|
|
a.markReady()
|
|
c := newFakeProcess("c")
|
|
c.markReady()
|
|
|
|
b := newTestBase(t, map[string]process.Process{"a": a, "c": c}, &stubPlanner{})
|
|
b.Unload(time.Second, "a")
|
|
|
|
if a.State() != process.StateStopped {
|
|
t.Errorf("a should be stopped, got %q", a.State())
|
|
}
|
|
if c.State() != process.StateReady {
|
|
t.Errorf("c should remain ready, got %q", c.State())
|
|
}
|
|
}
|
|
|
|
// TestBaseRouter_Unload_StopsInParallel verifies that Unload fans out its
|
|
// Stop calls concurrently rather than stopping each process serially. Each
|
|
// fakeProcess.Stop is pinned via stopBlock; the test only releases them
|
|
// after observing every stopStarted, proving all three Stops were in
|
|
// flight simultaneously.
|
|
func TestBaseRouter_Unload_StopsInParallel(t *testing.T) {
|
|
a := newFakeProcess("a")
|
|
a.markReady()
|
|
a.stopBlock = make(chan struct{})
|
|
pb := newFakeProcess("b")
|
|
pb.markReady()
|
|
pb.stopBlock = make(chan struct{})
|
|
pc := newFakeProcess("c")
|
|
pc.markReady()
|
|
pc.stopBlock = make(chan struct{})
|
|
|
|
b := newTestBase(t, map[string]process.Process{"a": a, "b": pb, "c": pc}, &stubPlanner{})
|
|
|
|
unloadDone := make(chan struct{})
|
|
go func() {
|
|
b.Unload(time.Second, "a", "b", "c")
|
|
close(unloadDone)
|
|
}()
|
|
|
|
// All three Stop calls must start before any of them are allowed to
|
|
// complete. If Unload was serial, only one stopStarted would fire
|
|
// until we released its stopBlock, and this would deadlock.
|
|
for _, p := range []*fakeProcess{a, pb, pc} {
|
|
select {
|
|
case <-p.stopStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("Stop on %s never started — Unload is not parallel", p.id)
|
|
}
|
|
}
|
|
|
|
// Release them; Unload should now return.
|
|
close(a.stopBlock)
|
|
close(pb.stopBlock)
|
|
close(pc.stopBlock)
|
|
|
|
select {
|
|
case <-unloadDone:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("Unload did not return after stops released")
|
|
}
|
|
|
|
for _, p := range []*fakeProcess{a, pb, pc} {
|
|
if p.State() != process.StateStopped {
|
|
t.Errorf("%s state=%q want stopped", p.id, p.State())
|
|
}
|
|
if got := p.stopCalls.Load(); got != 1 {
|
|
t.Errorf("%s stopCalls=%d want 1", p.id, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBaseRouter_OnDemandStart(t *testing.T) {
|
|
a := newFakeProcess("a")
|
|
a.autoReady = true
|
|
|
|
b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})
|
|
|
|
w := httptest.NewRecorder()
|
|
b.ServeHTTP(w, newRequest("a"))
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%q", w.Code, w.Body.String())
|
|
}
|
|
if got := a.runCalls.Load(); got != 1 {
|
|
t.Errorf("runCalls=%d want 1", got)
|
|
}
|
|
if got := a.serveCalls.Load(); got != 1 {
|
|
t.Errorf("serveCalls=%d want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseRouter_ContextCancel(t *testing.T) {
|
|
a := newFakeProcess("a")
|
|
// autoReady=false so swap parks forever until we mark ready.
|
|
|
|
b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
w1 := httptest.NewRecorder()
|
|
done1 := make(chan struct{})
|
|
go func() {
|
|
b.ServeHTTP(w1, newRequestCtx(ctx, "a"))
|
|
close(done1)
|
|
}()
|
|
|
|
w2 := httptest.NewRecorder()
|
|
done2 := make(chan struct{})
|
|
go func() {
|
|
b.ServeHTTP(w2, newRequest("a"))
|
|
close(done2)
|
|
}()
|
|
|
|
waitProcessed(t, b.testProcessed, 2) // both requests joined the active swap
|
|
<-a.runStarted
|
|
|
|
cancel()
|
|
select {
|
|
case <-done1:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("cancelled ServeHTTP did not return after ctx cancel")
|
|
}
|
|
|
|
a.markReady()
|
|
select {
|
|
case <-done2:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("non-cancelled ServeHTTP did not complete after swap")
|
|
}
|
|
if w2.Code != http.StatusOK {
|
|
t.Errorf("second request status=%d body=%q", w2.Code, w2.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestBaseRouter_SerialPriorityHeader is the end-to-end path for issue #9: the
|
|
// X-LlamaSwap-Priority header on an HTTP request reaches the serial scheduler
|
|
// and changes which queued request runs next.
|
|
//
|
|
// "hold" occupies the single slot. batch and urgent queue behind it in that
|
|
// order; when the slot frees, urgent must go first despite arriving later.
|
|
// urgent blocks inside its handler, so observing urgent's handler entry proves
|
|
// the scheduler chose it — if batch had won, batch's (unblocked) handler would
|
|
// already have run by then.
|
|
func TestBaseRouter_SerialPriorityHeader(t *testing.T) {
|
|
hold := newFakeProcess("hold")
|
|
hold.autoReady = true
|
|
hold.serveBlock = make(chan struct{})
|
|
batch := newFakeProcess("batch")
|
|
batch.autoReady = true
|
|
urgent := newFakeProcess("urgent")
|
|
urgent.autoReady = true
|
|
urgent.serveBlock = make(chan struct{})
|
|
|
|
conf := config.Config{HealthCheckTimeout: 5}
|
|
conf.Routing.Scheduler.Use = "serial"
|
|
b, err := newBaseRouter("test", conf, map[string]process.Process{
|
|
"hold": hold, "batch": batch, "urgent": urgent,
|
|
}, logmon.NewWriter(io.Discard), &stubPlanner{})
|
|
if err != nil {
|
|
t.Fatalf("newBaseRouter: %v", err)
|
|
}
|
|
b.testProcessed = make(chan struct{}, 64)
|
|
go b.run()
|
|
|
|
releaseHold := sync.OnceFunc(func() { close(hold.serveBlock) })
|
|
releaseUrgent := sync.OnceFunc(func() { close(urgent.serveBlock) })
|
|
t.Cleanup(func() {
|
|
releaseHold()
|
|
releaseUrgent()
|
|
if !b.shuttingDown.Load() {
|
|
_ = b.Shutdown(time.Second)
|
|
}
|
|
})
|
|
|
|
serve := func(model, priority string) {
|
|
r := newRequest(model)
|
|
if priority != "" {
|
|
r.Header.Set(shared.PriorityHeader, priority)
|
|
}
|
|
go b.ServeHTTP(httptest.NewRecorder(), r)
|
|
}
|
|
|
|
serve("hold", "")
|
|
waitProcessed(t, b.testProcessed, 2) // OnRequest, then the swap completing
|
|
<-hold.serveStarted
|
|
|
|
// Both queue behind hold; batch arrives first.
|
|
serve("batch", "batch")
|
|
waitProcessed(t, b.testProcessed, 1)
|
|
serve("urgent", "interactive")
|
|
waitProcessed(t, b.testProcessed, 1)
|
|
|
|
releaseHold()
|
|
|
|
select {
|
|
case <-urgent.serveStarted:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("interactive request never started")
|
|
}
|
|
if got := batch.serveCalls.Load(); got != 0 {
|
|
t.Errorf("batch serveCalls=%d want 0 — the batch job ran before the interactive request", got)
|
|
}
|
|
}
|
|
|
|
func TestBaseRouter_ModelNotFound(t *testing.T) {
|
|
a := newFakeProcess("a")
|
|
b := newTestBase(t, map[string]process.Process{"a": a}, &stubPlanner{})
|
|
|
|
w := httptest.NewRecorder()
|
|
b.ServeHTTP(w, newRequest("unknown"))
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("status=%d want %d body=%q", w.Code, http.StatusNotFound, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestBaseRouter_Shutdown_StopsAllProcesses(t *testing.T) {
|
|
a := newFakeProcess("a")
|
|
a.markReady()
|
|
go a.Run(0)
|
|
pb := newFakeProcess("b")
|
|
pb.markReady()
|
|
go pb.Run(0)
|
|
|
|
b := newTestBase(t, map[string]process.Process{"a": a, "b": pb}, &stubPlanner{})
|
|
|
|
if err := b.Shutdown(time.Second); err != nil {
|
|
t.Fatalf("Shutdown: %v", err)
|
|
}
|
|
if got := a.stopCalls.Load(); got != 1 {
|
|
t.Errorf("a.stopCalls=%d want 1", got)
|
|
}
|
|
if got := pb.stopCalls.Load(); got != 1 {
|
|
t.Errorf("b.stopCalls=%d want 1", got)
|
|
}
|
|
|
|
// Subsequent ServeHTTP should report 5xx.
|
|
w := httptest.NewRecorder()
|
|
b.ServeHTTP(w, newRequest("a"))
|
|
if w.Code != http.StatusInternalServerError && w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("post-shutdown status=%d want 5xx body=%q", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Second Shutdown should report already in progress.
|
|
if err := b.Shutdown(0); err == nil {
|
|
t.Errorf("second Shutdown returned nil, want error")
|
|
}
|
|
}
|