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
732 lines
24 KiB
Go
732 lines
24 KiB
Go
package scheduler
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"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/shared"
|
|
)
|
|
|
|
// Serial methods all run on the router's single run-loop goroutine, so these
|
|
// tests drive them directly and synchronously, reusing fakeEffects and the
|
|
// req/reqCh helpers from fifo_test.go. A load completes via OnSwapDone and a
|
|
// served request finishes via OnServeDone — the events the run loop delivers.
|
|
|
|
// newSerial builds a Serial with the production defaults: aging at one point
|
|
// per minute, swap affinity at +10.
|
|
func newSerial(eff Effects) *Serial {
|
|
return NewSerial("test", logmon.NewWriter(io.Discard), config.SerialConfig{}, eff)
|
|
}
|
|
|
|
// newSerialCfg builds a Serial with explicit scoring settings. Passing 0 for
|
|
// either term disables it.
|
|
func newSerialCfg(eff Effects, agingDivisor, swapAffinityBonus int) *Serial {
|
|
return NewSerial("test", logmon.NewWriter(io.Discard), config.SerialConfig{
|
|
AgingDivisor: &agingDivisor,
|
|
SwapAffinityBonus: &swapAffinityBonus,
|
|
}, eff)
|
|
}
|
|
|
|
// fakeClock replaces a Serial's clock with one the test advances by hand, so
|
|
// aging is exercised without sleeping.
|
|
type fakeClock struct{ t time.Time }
|
|
|
|
func newFakeClock(s *Serial) *fakeClock {
|
|
c := &fakeClock{t: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
|
|
s.now = func() time.Time { return c.t }
|
|
return c
|
|
}
|
|
|
|
func (c *fakeClock) advance(d time.Duration) { c.t = c.t.Add(d) }
|
|
|
|
// reqP is a HandlerReq carrying an explicit caller priority.
|
|
func reqP(model string, priority int) HandlerReq {
|
|
return HandlerReq{Model: model, Priority: priority}
|
|
}
|
|
|
|
// lastStart returns the most recent StartSwap record.
|
|
func lastStart(t *testing.T, eff *fakeEffects) startRec {
|
|
t.Helper()
|
|
if len(eff.starts) == 0 {
|
|
t.Fatal("no StartSwap recorded")
|
|
}
|
|
return eff.starts[len(eff.starts)-1]
|
|
}
|
|
|
|
func sameSet(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
m := map[string]int{}
|
|
for _, x := range a {
|
|
m[x]++
|
|
}
|
|
for _, x := range b {
|
|
m[x]--
|
|
}
|
|
for _, v := range m {
|
|
if v != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// servedOrder returns the model IDs of every successful serve grant in order.
|
|
func servedOrder(eff *fakeEffects) []string {
|
|
var out []string
|
|
for _, g := range eff.grants {
|
|
if g.err == nil && g.serve {
|
|
out = append(out, g.model)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestSerial_FastPath_AlreadyLoaded(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateReady
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a"))
|
|
|
|
if got := len(eff.starts); got != 0 {
|
|
t.Errorf("StartSwap calls=%d want 0 (already loaded, no swap)", got)
|
|
}
|
|
if got := eff.served("a"); got != 1 {
|
|
t.Errorf("served(a)=%d want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestSerial_ColdStart_LoadsThenServes(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a"))
|
|
if got := eff.startsFor("a"); got != 1 {
|
|
t.Fatalf("StartSwap(a)=%d want 1", got)
|
|
}
|
|
if got := eff.served("a"); got != 0 {
|
|
t.Errorf("served(a)=%d want 0 before load completes", got)
|
|
}
|
|
|
|
eff.states["a"] = process.StateReady
|
|
s.OnSwapDone(SwapDone{ModelID: "a"})
|
|
if got := eff.served("a"); got != 1 {
|
|
t.Errorf("served(a)=%d want 1 after load", got)
|
|
}
|
|
}
|
|
|
|
func TestSerial_UnknownModel(t *testing.T) {
|
|
eff := newFakeEffects() // no states => unknown
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("ghost"))
|
|
|
|
if len(eff.starts) != 0 {
|
|
t.Errorf("StartSwap calls=%d want 0", len(eff.starts))
|
|
}
|
|
if eff.errored("ghost") != 1 {
|
|
t.Fatalf("errored(ghost)=%d want 1", eff.errored("ghost"))
|
|
}
|
|
if !errors.Is(eff.grants[0].err, ErrModelNotFound) {
|
|
t.Errorf("err=%v want ErrModelNotFound", eff.grants[0].err)
|
|
}
|
|
}
|
|
|
|
func TestSerial_EvictsEveryOtherModel(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["x"] = process.StateReady // already running
|
|
eff.states["y"] = process.StateReady // also running (e.g. left over)
|
|
eff.states["a"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a"))
|
|
|
|
st := lastStart(t, eff)
|
|
if st.model != "a" {
|
|
t.Fatalf("loading %s want a", st.model)
|
|
}
|
|
if !sameSet(st.evict, []string{"x", "y"}) {
|
|
t.Errorf("evict=%v want [x y] (serial evicts ALL other models)", st.evict)
|
|
}
|
|
}
|
|
|
|
// TestSerial_OneJobAtATime verifies a second request waits while the first is
|
|
// serving, and only starts after the first finishes.
|
|
func TestSerial_OneJobAtATime(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateReady
|
|
eff.states["b"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a")) // served immediately
|
|
s.OnRequest(req("b")) // must wait — a is serving
|
|
|
|
if got := eff.startsFor("b"); got != 0 {
|
|
t.Fatalf("StartSwap(b)=%d want 0 while a is serving", got)
|
|
}
|
|
if got := eff.served("a"); got != 1 {
|
|
t.Fatalf("served(a)=%d want 1", got)
|
|
}
|
|
|
|
// a finishes -> b may now load (evicting a).
|
|
s.OnServeDone(ServeDoneEvent{ModelID: "a"})
|
|
if got := eff.startsFor("b"); got != 1 {
|
|
t.Fatalf("StartSwap(b)=%d want 1 after a finished", got)
|
|
}
|
|
if st := lastStart(t, eff); !sameSet(st.evict, []string{"a"}) {
|
|
t.Errorf("b evict=%v want [a]", st.evict)
|
|
}
|
|
}
|
|
|
|
// TestSerial_SameModelConsecutive_NoReload verifies back-to-back requests for the
|
|
// already-loaded model run without a reload, one after another.
|
|
func TestSerial_SameModelConsecutive_NoReload(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a")) // cold load
|
|
s.OnRequest(req("a")) // queued behind the first
|
|
|
|
eff.states["a"] = process.StateReady
|
|
s.OnSwapDone(SwapDone{ModelID: "a"}) // first serves
|
|
if got := eff.served("a"); got != 1 {
|
|
t.Fatalf("served(a)=%d want 1 (one at a time)", got)
|
|
}
|
|
|
|
s.OnServeDone(ServeDoneEvent{ModelID: "a"}) // first done -> second serves
|
|
if got := eff.served("a"); got != 2 {
|
|
t.Fatalf("served(a)=%d want 2", got)
|
|
}
|
|
if got := eff.startsFor("a"); got != 1 {
|
|
t.Errorf("StartSwap(a)=%d want 1 (second request must not reload)", got)
|
|
}
|
|
}
|
|
|
|
// TestSerial_StrictArrivalOrder covers the scoring function's degenerate case:
|
|
// with equal priorities and swap affinity disabled, qwen36, qwen35, sdxl,
|
|
// qwen36 execute in EXACTLY that order with evictions between each model
|
|
// switch, including reloading qwen36 at the end even though it ran first.
|
|
// Aging cannot reorder them either — they all arrive at the same instant, so
|
|
// they age at the same rate.
|
|
func TestSerial_StrictArrivalOrder(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
for _, m := range []string{"qwen36", "qwen35", "sdxl"} {
|
|
eff.states[m] = process.StateStopped
|
|
}
|
|
s := newSerialCfg(eff, config.DefaultAgingDivisor, 0)
|
|
|
|
for _, m := range []string{"qwen36", "qwen35", "sdxl", "qwen36"} {
|
|
s.OnRequest(req(m))
|
|
}
|
|
|
|
// Only the first job starts loading; the rest wait their turn.
|
|
if len(eff.starts) != 1 || eff.starts[0].model != "qwen36" {
|
|
t.Fatalf("starts=%+v want only [qwen36] loading first", eff.starts)
|
|
}
|
|
|
|
// step completes the current model's load+serve and returns control to the
|
|
// scheduler, which must start the next queued model.
|
|
step := func(model string, wantEvict []string) {
|
|
t.Helper()
|
|
st := lastStart(t, eff)
|
|
if st.model != model {
|
|
t.Fatalf("loading %q want %q", st.model, model)
|
|
}
|
|
if !sameSet(st.evict, wantEvict) {
|
|
t.Fatalf("loading %q evict=%v want %v", model, st.evict, wantEvict)
|
|
}
|
|
// Simulate the eviction + load actually happening.
|
|
for _, e := range st.evict {
|
|
eff.states[e] = process.StateStopped
|
|
}
|
|
eff.states[model] = process.StateReady
|
|
s.OnSwapDone(SwapDone{ModelID: model})
|
|
s.OnServeDone(ServeDoneEvent{ModelID: model})
|
|
}
|
|
|
|
step("qwen36", nil) // cold load, nothing else running
|
|
step("qwen35", []string{"qwen36"}) // evict qwen36
|
|
step("sdxl", []string{"qwen35"}) // evict qwen35
|
|
step("qwen36", []string{"sdxl"}) // RELOAD qwen36, evict sdxl
|
|
|
|
want := []string{"qwen36", "qwen35", "sdxl", "qwen36"}
|
|
if got := servedOrder(eff); !sameOrder(got, want) {
|
|
t.Fatalf("serve order=%v want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func sameOrder(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// stepModel completes the current load+serve for model and returns control to
|
|
// the scheduler so it dispatches the next queued request.
|
|
func stepModel(t *testing.T, s *Serial, eff *fakeEffects, model string) {
|
|
t.Helper()
|
|
if len(eff.starts) > 0 {
|
|
if st := eff.starts[len(eff.starts)-1]; st.model == model {
|
|
for _, e := range st.evict {
|
|
eff.states[e] = process.StateStopped
|
|
}
|
|
eff.states[model] = process.StateReady
|
|
s.OnSwapDone(SwapDone{ModelID: model})
|
|
}
|
|
}
|
|
s.OnServeDone(ServeDoneEvent{ModelID: model})
|
|
}
|
|
|
|
// TestSerial_HigherPriorityDispatchesFirst is the point of the whole exercise:
|
|
// a batch job already queued must yield to an interactive request that arrives
|
|
// later, because dispatch order is by score, not arrival.
|
|
func TestSerial_HigherPriorityDispatchesFirst(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
for _, m := range []string{"running", "batch", "interactive"} {
|
|
eff.states[m] = process.StateStopped
|
|
}
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(reqP("running", shared.PriorityNormal)) // dispatches immediately
|
|
s.OnRequest(reqP("batch", shared.PriorityBatch)) // queued first...
|
|
s.OnRequest(reqP("interactive", shared.PriorityInteractive)) // ...but this jumps it
|
|
|
|
stepModel(t, s, eff, "running")
|
|
|
|
if got := eff.startsFor("interactive"); got != 1 {
|
|
t.Fatalf("StartSwap(interactive)=%d want 1 (must overtake the queued batch job)", got)
|
|
}
|
|
if got := eff.startsFor("batch"); got != 0 {
|
|
t.Fatalf("StartSwap(batch)=%d want 0 (still waiting behind interactive)", got)
|
|
}
|
|
|
|
stepModel(t, s, eff, "interactive")
|
|
if got := eff.startsFor("batch"); got != 1 {
|
|
t.Fatalf("StartSwap(batch)=%d want 1 once nothing outranks it", got)
|
|
}
|
|
}
|
|
|
|
// TestSerial_TierOffsetBreaksTieWithinBand verifies the composition rule the
|
|
// design depends on: a small per-caller offset orders requests inside a band
|
|
// and never promotes one across a band. A "max member" batch job at -98 beats
|
|
// other batch work but still loses to every normal request at 0.
|
|
func TestSerial_TierOffsetBreaksTieWithinBand(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
for _, m := range []string{"running", "free", "max", "normal"} {
|
|
eff.states[m] = process.StateStopped
|
|
}
|
|
s := newSerialCfg(eff, 0, 0) // isolate the priority term
|
|
|
|
s.OnRequest(reqP("running", 0))
|
|
s.OnRequest(reqP("free", shared.PriorityBatch)) // -100
|
|
s.OnRequest(reqP("max", shared.PriorityBatch+2)) // -98, max tier
|
|
s.OnRequest(reqP("normal", shared.PriorityNormal)) // 0
|
|
|
|
stepModel(t, s, eff, "running")
|
|
if got := eff.startsFor("normal"); got != 1 {
|
|
t.Fatalf("StartSwap(normal)=%d want 1 (a tier bonus must not cross a band)", got)
|
|
}
|
|
|
|
stepModel(t, s, eff, "normal")
|
|
if got := eff.startsFor("max"); got != 1 {
|
|
t.Fatalf("StartSwap(max)=%d want 1 (max tier outranks free within the batch band)", got)
|
|
}
|
|
|
|
stepModel(t, s, eff, "max")
|
|
if got := eff.startsFor("free"); got != 1 {
|
|
t.Fatalf("StartSwap(free)=%d want 1 (last)", got)
|
|
}
|
|
}
|
|
|
|
// TestSerial_AgingPreventsStarvation verifies the one term allowed to cross
|
|
// bands: a batch job that has waited long enough eventually beats an
|
|
// interactive request that arrived just now.
|
|
func TestSerial_AgingPreventsStarvation(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
for _, m := range []string{"running", "batch", "interactive"} {
|
|
eff.states[m] = process.StateStopped
|
|
}
|
|
s := newSerialCfg(eff, 60, 0) // one point per minute, no affinity
|
|
clock := newFakeClock(s)
|
|
|
|
s.OnRequest(reqP("running", shared.PriorityNormal))
|
|
s.OnRequest(reqP("batch", shared.PriorityBatch)) // -100
|
|
|
|
// The batch job waits long enough to gain 201 points: -100 + 201 = 101,
|
|
// just past a fresh interactive request at +100.
|
|
clock.advance(201 * time.Minute)
|
|
s.OnRequest(reqP("interactive", shared.PriorityInteractive))
|
|
|
|
stepModel(t, s, eff, "running")
|
|
if got := eff.startsFor("batch"); got != 1 {
|
|
t.Fatalf("StartSwap(batch)=%d want 1 (aging must eventually beat a fresh interactive request)", got)
|
|
}
|
|
if got := eff.startsFor("interactive"); got != 0 {
|
|
t.Fatalf("StartSwap(interactive)=%d want 0 (outranked by the aged batch job)", got)
|
|
}
|
|
}
|
|
|
|
// TestSerial_AgingCannotCrossBandTooEarly is the other half of aging: a batch
|
|
// job that has waited only a little still loses to interactive traffic.
|
|
func TestSerial_AgingCannotCrossBandTooEarly(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
for _, m := range []string{"running", "batch", "interactive"} {
|
|
eff.states[m] = process.StateStopped
|
|
}
|
|
s := newSerialCfg(eff, 60, 0)
|
|
clock := newFakeClock(s)
|
|
|
|
s.OnRequest(reqP("running", shared.PriorityNormal))
|
|
s.OnRequest(reqP("batch", shared.PriorityBatch))
|
|
clock.advance(30 * time.Minute) // -100 + 30 = -70
|
|
s.OnRequest(reqP("interactive", shared.PriorityInteractive))
|
|
|
|
stepModel(t, s, eff, "running")
|
|
if got := eff.startsFor("interactive"); got != 1 {
|
|
t.Fatalf("StartSwap(interactive)=%d want 1 (30 minutes is not enough aging)", got)
|
|
}
|
|
}
|
|
|
|
// TestSerial_SwapAffinity_PrefersResidentModel verifies the bounded bonus keeps
|
|
// the model cache from thrashing: with equal priorities, a queued request for
|
|
// the already-loaded model runs before earlier requests that would each force a
|
|
// cold load.
|
|
func TestSerial_SwapAffinity_PrefersResidentModel(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
for _, m := range []string{"qwen36", "qwen35", "sdxl"} {
|
|
eff.states[m] = process.StateStopped
|
|
}
|
|
s := newSerial(eff) // affinity +10 by default
|
|
|
|
for _, m := range []string{"qwen36", "qwen35", "sdxl", "qwen36"} {
|
|
s.OnRequest(req(m))
|
|
}
|
|
|
|
stepModel(t, s, eff, "qwen36") // qwen36 now resident and ready
|
|
if got := eff.served("qwen36"); got != 2 {
|
|
t.Fatalf("served(qwen36)=%d want 2 (the trailing qwen36 should skip the swap queue)", got)
|
|
}
|
|
if len(eff.starts) != 1 {
|
|
t.Fatalf("starts=%+v want only the initial qwen36 load (no reload)", eff.starts)
|
|
}
|
|
|
|
stepModel(t, s, eff, "qwen36")
|
|
if got := eff.startsFor("qwen35"); got != 1 {
|
|
t.Fatalf("StartSwap(qwen35)=%d want 1 once no resident request remains", got)
|
|
}
|
|
}
|
|
|
|
// TestSerial_SwapAffinity_CannotCrossBand verifies the bonus is bounded: an
|
|
// interactive request that needs a cold load still beats a batch job for the
|
|
// already-resident model.
|
|
func TestSerial_SwapAffinity_CannotCrossBand(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["resident"] = process.StateReady
|
|
eff.states["cold"] = process.StateStopped
|
|
s := newSerialCfg(eff, 0, 99) // the largest bonus config permits
|
|
|
|
s.OnRequest(reqP("resident", shared.PriorityNormal)) // dispatches immediately
|
|
s.OnRequest(reqP("resident", shared.PriorityBatch)) // queued, would get +99
|
|
s.OnRequest(reqP("cold", shared.PriorityInteractive))
|
|
|
|
s.OnServeDone(ServeDoneEvent{ModelID: "resident"})
|
|
if got := eff.startsFor("cold"); got != 1 {
|
|
t.Fatalf("StartSwap(cold)=%d want 1 (-100+99 must still lose to +100)", got)
|
|
}
|
|
}
|
|
|
|
// TestSerial_DispatchSetsMetadata verifies the dispatch decision is recorded on
|
|
// the request context, which is what puts it in the activity log.
|
|
func TestSerial_DispatchSetsMetadata(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["hold"] = process.StateReady
|
|
eff.states["a"] = process.StateStopped
|
|
s := newSerialCfg(eff, 60, 0)
|
|
clock := newFakeClock(s)
|
|
|
|
s.OnRequest(req("hold")) // serves immediately, occupying the slot
|
|
|
|
ctx := shared.SetContext(context.Background(), shared.ReqContextData{ModelID: "a", Metadata: make(map[string]string)})
|
|
s.OnRequest(HandlerReq{Model: "a", Priority: shared.PriorityBatch, Ctx: ctx})
|
|
|
|
clock.advance(2 * time.Minute) // -100 + 2 aging points
|
|
s.OnServeDone(ServeDoneEvent{ModelID: "hold"})
|
|
|
|
// a is dispatched (and annotated) here; finish its load so it is granted.
|
|
eff.states["hold"] = process.StateStopped
|
|
eff.states["a"] = process.StateReady
|
|
s.OnSwapDone(SwapDone{ModelID: "a"})
|
|
|
|
data, ok := shared.ReadContext(eff.lastServeReq.Ctx)
|
|
if !ok {
|
|
t.Fatal("context data missing from granted request")
|
|
}
|
|
want := map[string]string{
|
|
"serial_priority": "-100",
|
|
"serial_band": shared.BandBatch,
|
|
"serial_score": "-98",
|
|
"serial_queue_wait_ms": "120000",
|
|
}
|
|
for k, v := range want {
|
|
if got := data.Metadata[k]; got != v {
|
|
t.Errorf("%s = %q, want %q", k, got, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSerial_QueueStats checks the numbers the /metrics endpoint reports:
|
|
// per-band depth and wait while queued, dispatch counts after the fact, and the
|
|
// reorder counters that say whether the scoring terms changed anything.
|
|
func TestSerial_QueueStats(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
for _, m := range []string{"running", "batch", "interactive"} {
|
|
eff.states[m] = process.StateStopped
|
|
}
|
|
s := newSerialCfg(eff, 60, 0)
|
|
clock := newFakeClock(s)
|
|
|
|
s.OnRequest(reqP("running", shared.PriorityNormal)) // dispatched, not queued
|
|
s.OnRequest(reqP("batch", shared.PriorityBatch))
|
|
clock.advance(5 * time.Minute)
|
|
s.OnRequest(reqP("interactive", shared.PriorityInteractive))
|
|
|
|
stats := s.QueueStats()
|
|
if got := stats.Bands[shared.BandBatch].Depth; got != 1 {
|
|
t.Errorf("batch depth=%d want 1", got)
|
|
}
|
|
if got := stats.Bands[shared.BandBatch].OldestWait; got != 5*time.Minute {
|
|
t.Errorf("batch oldest wait=%s want 5m", got)
|
|
}
|
|
if got := stats.Bands[shared.BandInteractive].Depth; got != 1 {
|
|
t.Errorf("interactive depth=%d want 1", got)
|
|
}
|
|
if got := stats.Bands[shared.BandNormal].Depth; got != 0 {
|
|
t.Errorf("normal depth=%d want 0 (it dispatched immediately)", got)
|
|
}
|
|
if got := stats.Bands[shared.BandNormal].Dispatched; got != 1 {
|
|
t.Errorf("normal dispatched=%d want 1", got)
|
|
}
|
|
|
|
// Dispatching interactive over the older batch job is priority doing its
|
|
// job, not aging: with only 5 minutes of aging the winner is unchanged.
|
|
stepModel(t, s, eff, "running")
|
|
stats = s.QueueStats()
|
|
if got := stats.Bands[shared.BandInteractive].Dispatched; got != 1 {
|
|
t.Errorf("interactive dispatched=%d want 1", got)
|
|
}
|
|
if stats.AgingReorders != 0 {
|
|
t.Errorf("aging reorders=%d want 0", stats.AgingReorders)
|
|
}
|
|
if stats.AffinityReorders != 0 {
|
|
t.Errorf("affinity reorders=%d want 0 (affinity disabled)", stats.AffinityReorders)
|
|
}
|
|
}
|
|
|
|
// TestSerial_QueueStats_CountsReorders verifies each scoring term is credited
|
|
// when it actually changes the dispatch decision.
|
|
func TestSerial_QueueStats_CountsReorders(t *testing.T) {
|
|
t.Run("aging", func(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
for _, m := range []string{"running", "old", "new"} {
|
|
eff.states[m] = process.StateStopped
|
|
}
|
|
s := newSerialCfg(eff, 60, 0)
|
|
clock := newFakeClock(s)
|
|
|
|
s.OnRequest(reqP("running", 0))
|
|
s.OnRequest(reqP("old", -10))
|
|
clock.advance(30 * time.Minute) // old: -10 + 30 = 20
|
|
s.OnRequest(reqP("new", 0)) // new: 0
|
|
|
|
stepModel(t, s, eff, "running")
|
|
if got := eff.startsFor("old"); got != 1 {
|
|
t.Fatalf("StartSwap(old)=%d want 1 (aging promoted it)", got)
|
|
}
|
|
if got := s.QueueStats().AgingReorders; got != 1 {
|
|
t.Errorf("aging reorders=%d want 1", got)
|
|
}
|
|
})
|
|
|
|
t.Run("swap affinity", func(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["resident"] = process.StateReady
|
|
eff.states["cold"] = process.StateStopped
|
|
s := newSerialCfg(eff, 0, 10)
|
|
|
|
s.OnRequest(req("resident")) // dispatches immediately
|
|
s.OnRequest(reqP("cold", 5)) // higher priority, but needs a swap
|
|
s.OnRequest(reqP("resident", 0)) // +10 affinity beats it
|
|
s.OnServeDone(ServeDoneEvent{ModelID: "resident"})
|
|
|
|
if got := eff.served("resident"); got != 2 {
|
|
t.Fatalf("served(resident)=%d want 2 (affinity outranked the cold request)", got)
|
|
}
|
|
if got := s.QueueStats().AffinityReorders; got != 1 {
|
|
t.Errorf("affinity reorders=%d want 1", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSerial_SwapError_FailsCallerAndAdvances(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateStopped
|
|
eff.states["b"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a"))
|
|
s.OnRequest(req("b")) // queued behind a
|
|
|
|
// a's load fails: its caller is errored and b proceeds.
|
|
s.OnSwapDone(SwapDone{ModelID: "a", Err: errors.New("boom")})
|
|
if eff.errored("a") != 1 {
|
|
t.Fatalf("errored(a)=%d want 1", eff.errored("a"))
|
|
}
|
|
if got := eff.startsFor("b"); got != 1 {
|
|
t.Fatalf("StartSwap(b)=%d want 1 after a's load failed", got)
|
|
}
|
|
}
|
|
|
|
// TestSerial_GrantServeFalse_Advances verifies that when the active request's
|
|
// caller has disconnected by serve time, the queue advances to the next request.
|
|
func TestSerial_GrantServeFalse_Advances(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateStopped
|
|
eff.states["b"] = process.StateStopped
|
|
eff.serveResult["a"] = false // a's caller is gone by grant time
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a"))
|
|
s.OnRequest(req("b")) // queued
|
|
|
|
eff.states["a"] = process.StateReady
|
|
s.OnSwapDone(SwapDone{ModelID: "a"}) // grant fails -> advance to b
|
|
|
|
if got := eff.served("a"); got != 0 {
|
|
t.Errorf("served(a)=%d want 0 (caller gone)", got)
|
|
}
|
|
if got := eff.startsFor("b"); got != 1 {
|
|
t.Fatalf("StartSwap(b)=%d want 1 (advanced after gone caller)", got)
|
|
}
|
|
}
|
|
|
|
func TestSerial_OnCancel_QueuedRequest(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateStopped
|
|
eff.states["b"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(reqCh("a")) // starts loading a
|
|
cancelled := reqCh("b")
|
|
s.OnRequest(cancelled) // queued behind a
|
|
if len(s.queued) != 1 {
|
|
t.Fatalf("queued=%d want 1", len(s.queued))
|
|
}
|
|
|
|
s.OnCancel(cancelled)
|
|
if len(s.queued) != 0 {
|
|
t.Fatalf("queued=%d want 0 after cancel", len(s.queued))
|
|
}
|
|
|
|
// a completes; b is gone, so nothing starts for it.
|
|
eff.states["a"] = process.StateReady
|
|
s.OnSwapDone(SwapDone{ModelID: "a"})
|
|
s.OnServeDone(ServeDoneEvent{ModelID: "a"})
|
|
if got := eff.startsFor("b"); got != 0 {
|
|
t.Errorf("StartSwap(b)=%d want 0 (cancelled before its turn)", got)
|
|
}
|
|
}
|
|
|
|
func TestSerial_OnShutdown_FailsQueuedAndActiveLoad(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateStopped
|
|
eff.states["b"] = process.StateStopped
|
|
eff.states["c"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a")) // active (loading)
|
|
s.OnRequest(req("b")) // queued
|
|
s.OnRequest(req("c")) // queued
|
|
|
|
s.OnShutdown(errors.New("shutting down"))
|
|
|
|
if got := eff.errored(""); got != 3 {
|
|
t.Errorf("error grants=%d want 3 (active load + 2 queued)", got)
|
|
}
|
|
if len(s.queued) != 0 {
|
|
t.Errorf("queued=%d want 0 after shutdown", len(s.queued))
|
|
}
|
|
}
|
|
|
|
// TestSerial_OnUnload_WhileServing verifies that unloading the model that is
|
|
// actively serving does not strand the queue: OnUnload stops the process but
|
|
// leaves the active request to end via OnServeDone, which then advances.
|
|
func TestSerial_OnUnload_WhileServing(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateReady
|
|
eff.states["b"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a")) // served immediately (a ready)
|
|
s.OnRequest(req("b")) // queued behind a
|
|
if got := eff.served("a"); got != 1 {
|
|
t.Fatalf("served(a)=%d want 1", got)
|
|
}
|
|
|
|
// Unload a while it is serving: the process is stopped, but the queue must
|
|
// not advance yet — the active serve is still outstanding.
|
|
s.OnUnload([]string{"a"}, time.Second)
|
|
if len(eff.stops) != 1 || !sameSet(eff.stops[0].ids, []string{"a"}) {
|
|
t.Errorf("StopProcesses=%+v want one call stopping [a]", eff.stops)
|
|
}
|
|
if got := eff.startsFor("b"); got != 0 {
|
|
t.Fatalf("StartSwap(b)=%d want 0 before the serving request ends", got)
|
|
}
|
|
|
|
// The killed handler returns -> OnServeDone advances to b.
|
|
eff.states["a"] = process.StateStopped
|
|
s.OnServeDone(ServeDoneEvent{ModelID: "a"})
|
|
if got := eff.startsFor("b"); got != 1 {
|
|
t.Fatalf("StartSwap(b)=%d want 1 after the serving request ended", got)
|
|
}
|
|
}
|
|
|
|
func TestSerial_OnUnload_DropsQueuedAndStops(t *testing.T) {
|
|
eff := newFakeEffects()
|
|
eff.states["a"] = process.StateStopped
|
|
eff.states["b"] = process.StateStopped
|
|
s := newSerial(eff)
|
|
|
|
s.OnRequest(req("a")) // active (loading a)
|
|
s.OnRequest(req("b")) // queued
|
|
|
|
// Unload a: its active load is failed and a is stopped.
|
|
s.OnUnload([]string{"a"}, time.Second)
|
|
|
|
if eff.errored("a") != 1 {
|
|
t.Errorf("errored(a)=%d want 1 (active load failed)", eff.errored("a"))
|
|
}
|
|
if len(eff.stops) != 1 || !sameSet(eff.stops[0].ids, []string{"a"}) {
|
|
t.Errorf("StopProcesses=%+v want one call stopping [a]", eff.stops)
|
|
}
|
|
// b was queued and not unloaded; with a's load cancelled it now starts.
|
|
if got := eff.startsFor("b"); got != 1 {
|
|
t.Errorf("StartSwap(b)=%d want 1 after unload advanced the queue", got)
|
|
}
|
|
}
|