The desired-versus-actual loop, with a Dispatcher interface where the agent stream will go. The loop's logic is where the interesting failures live, not the transport, so it is tested against a fake dispatcher and a synthetic clock. - catalog: models, profiles, aliases, fallbacks, scaling bounds. Aliases and fallbacks resolve at load rather than at request time, because a typo in a fallback should stop a deployment instead of surfacing during the outage when the fallback is finally needed. The node/GPU/TP arithmetic is checked here too, so a bad profile fails at config load rather than minutes into loading. - reconciler: one replica placed per pass, since placement depends on what is already allocated and several from one snapshot would be handed the same GPUs. Workers start before rank 0: SGLang's ranks rendezvous and vLLM under Ray needs its workers attached before the head serves. - a failed shard fails the whole replica. The others are waiting on collectives that will never complete while holding GPUs, so every shard is stopped and the replica rebuilt, with a jittered exponential backoff carried on the model rather than on the replica that was just destroyed. - node expiry has two stages, Unreachable then Lost, deliberately far apart: a network blip must not tear down replicas that are still serving, and rebuilding a multi-node replica costs tens of minutes of model loading. - scale-down drains before stopping, so in-flight requests finish. The phase 3 exit criterion is half met. TestLostNodeMovesReplicas covers the reconciliation half end to end. The router continuing to serve throughout needs the EndpointSource wiring and the agent stream. Writing the tests caught a real property of the design worth recording: advancing the clock makes every node look silent, so placement correctly stops. The test harness now heartbeats nodes as it advances time, except where a node going silent is the point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
236 lines
6.4 KiB
Go
236 lines
6.4 KiB
Go
package catalog
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func model(name string, profiles ...Profile) Model {
|
|
if len(profiles) == 0 {
|
|
profiles = []Profile{{Name: "p1", GPUClass: "h100-80g", TP: 4}}
|
|
}
|
|
return Model{Name: name, Source: "/models/" + name, Profiles: profiles}
|
|
}
|
|
|
|
func TestResolveNameAndAlias(t *testing.T) {
|
|
m := model("llama-3.3-70b")
|
|
m.Aliases = []string{"llama", "llama3"}
|
|
c, err := New([]Model{m})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, name := range []string{"llama-3.3-70b", "llama", "llama3"} {
|
|
got, ok := c.Resolve(name)
|
|
if !ok {
|
|
t.Fatalf("%q did not resolve", name)
|
|
}
|
|
if got.Name != "llama-3.3-70b" {
|
|
t.Errorf("%q resolved to %q", name, got.Name)
|
|
}
|
|
}
|
|
if _, ok := c.Resolve("nope"); ok {
|
|
t.Error("an unknown name resolved")
|
|
}
|
|
}
|
|
|
|
func TestAliasCollisionRejected(t *testing.T) {
|
|
// An alias shadowing another model would misroute every request for it,
|
|
// silently, at request time.
|
|
a := model("qwen")
|
|
b := model("llama")
|
|
b.Aliases = []string{"qwen"}
|
|
|
|
_, err := New([]Model{a, b})
|
|
if err == nil || !strings.Contains(err.Error(), "collides") {
|
|
t.Errorf("collision not rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDuplicateAliasRejected(t *testing.T) {
|
|
a := model("a")
|
|
a.Aliases = []string{"shared"}
|
|
b := model("b")
|
|
b.Aliases = []string{"shared"}
|
|
|
|
_, err := New([]Model{a, b})
|
|
if err == nil || !strings.Contains(err.Error(), "claimed by both") {
|
|
t.Errorf("duplicate alias not rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUnknownFallbackRejected(t *testing.T) {
|
|
// A typo in a fallback should stop a deployment, not surface during the
|
|
// outage when the fallback is finally needed.
|
|
m := model("llama")
|
|
m.Fallbacks = []string{"qwen-typo"}
|
|
|
|
_, err := New([]Model{m})
|
|
if err == nil || !strings.Contains(err.Error(), "fallback") {
|
|
t.Errorf("unknown fallback not rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFallbackToAliasAccepted(t *testing.T) {
|
|
a := model("qwen2.5-32b")
|
|
a.Aliases = []string{"qwen"}
|
|
b := model("llama")
|
|
b.Fallbacks = []string{"qwen"}
|
|
|
|
if _, err := New([]Model{a, b}); err != nil {
|
|
t.Errorf("a fallback naming an alias was rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParallelismArithmeticValidated(t *testing.T) {
|
|
// Wrong arithmetic hands the engine a spec it cannot satisfy, and it fails
|
|
// minutes later during model loading rather than at config load.
|
|
_, err := New([]Model{model("m", Profile{
|
|
Name: "bad", GPUClass: "h100-80g", TP: 4, Nodes: 2, GPUsPerNode: 8,
|
|
})})
|
|
if err == nil || !strings.Contains(err.Error(), "does not match") {
|
|
t.Errorf("bad arithmetic accepted: %v", err)
|
|
}
|
|
|
|
// 2 nodes x 8 GPUs = TP 16 is consistent.
|
|
if _, err := New([]Model{model("m", Profile{
|
|
Name: "ok", GPUClass: "h100-80g", TP: 16, Nodes: 2, GPUsPerNode: 8,
|
|
})}); err != nil {
|
|
t.Errorf("valid arithmetic rejected: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDefaults(t *testing.T) {
|
|
c, err := New([]Model{model("m", Profile{Name: "p", GPUClass: "h100-80g", TP: 4})})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, _ := c.Resolve("m")
|
|
p := m.Profiles[0]
|
|
|
|
if p.Engine != "vllm" {
|
|
t.Errorf("engine=%q", p.Engine)
|
|
}
|
|
if p.Nodes != 1 {
|
|
t.Errorf("nodes=%d", p.Nodes)
|
|
}
|
|
if p.GPUsPerNode != 4 {
|
|
t.Errorf("gpus_per_node=%d; a single-node profile should default to TP", p.GPUsPerNode)
|
|
}
|
|
if p.StartupTimeout != 20*time.Minute {
|
|
t.Errorf("startup_timeout=%v", p.StartupTimeout)
|
|
}
|
|
if p.Weight <= 0 {
|
|
t.Errorf("weight=%v", p.Weight)
|
|
}
|
|
if m.Scaling.Max < 1 {
|
|
t.Errorf("scaling max=%d", m.Scaling.Max)
|
|
}
|
|
}
|
|
|
|
func TestRequiredFields(t *testing.T) {
|
|
cases := map[string]Model{
|
|
"no name": {Source: "/m", Profiles: []Profile{{Name: "p", GPUClass: "c", TP: 1}}},
|
|
"no source": {Name: "m", Profiles: []Profile{{Name: "p", GPUClass: "c", TP: 1}}},
|
|
"no profiles": {Name: "m", Source: "/m"},
|
|
"no class": {Name: "m", Source: "/m", Profiles: []Profile{{Name: "p", TP: 1}}},
|
|
}
|
|
for name, m := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := New([]Model{m}); err == nil {
|
|
t.Error("accepted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestScalingBounds(t *testing.T) {
|
|
m := model("m")
|
|
m.Scaling = Scaling{Min: 5, Max: 2}
|
|
if _, err := New([]Model{m}); err == nil {
|
|
t.Error("max below min was accepted")
|
|
}
|
|
}
|
|
|
|
func TestDuplicateProfileRejected(t *testing.T) {
|
|
_, err := New([]Model{model("m",
|
|
Profile{Name: "p", GPUClass: "h100-80g", TP: 1},
|
|
Profile{Name: "p", GPUClass: "a100-40g", TP: 1},
|
|
)})
|
|
if err == nil || !strings.Contains(err.Error(), "twice") {
|
|
t.Errorf("duplicate profile accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPlacementProfilesCarryPriority(t *testing.T) {
|
|
c, _ := New([]Model{model("m",
|
|
Profile{Name: "fast", GPUClass: "h100-80g", TP: 4, Priority: 100},
|
|
Profile{Name: "slow", GPUClass: "a100-40g", TP: 4, Priority: 50},
|
|
)})
|
|
m, _ := c.Resolve("m")
|
|
|
|
pp := m.PlacementProfiles()
|
|
if len(pp) != 2 {
|
|
t.Fatalf("got %d placement profiles", len(pp))
|
|
}
|
|
byName := map[string]int{}
|
|
for _, p := range pp {
|
|
byName[p.Name] = p.Priority
|
|
}
|
|
if byName["fast"] != 100 || byName["slow"] != 50 {
|
|
t.Errorf("priorities lost in conversion: %v", byName)
|
|
}
|
|
}
|
|
|
|
func TestEngineProfileCarriesModelIdentity(t *testing.T) {
|
|
// The served name is what clients ask for and need not match the path.
|
|
c, _ := New([]Model{model("llama-3.3-70b")})
|
|
m, _ := c.Resolve("llama-3.3-70b")
|
|
|
|
ep, ok := m.EngineProfile("p1")
|
|
if !ok {
|
|
t.Fatal("engine profile not found")
|
|
}
|
|
if ep.ModelPath != "/models/llama-3.3-70b" {
|
|
t.Errorf("ModelPath=%q", ep.ModelPath)
|
|
}
|
|
if ep.ServedModelName != "llama-3.3-70b" {
|
|
t.Errorf("ServedModelName=%q", ep.ServedModelName)
|
|
}
|
|
|
|
if _, ok := m.EngineProfile("nope"); ok {
|
|
t.Error("an unknown profile resolved")
|
|
}
|
|
}
|
|
|
|
func TestEngineProfileArgsAreCopied(t *testing.T) {
|
|
// The reconciler hands these to an engine adapter; mutating them must not
|
|
// corrupt the catalog for every later replica.
|
|
c, _ := New([]Model{model("m", Profile{
|
|
Name: "p", GPUClass: "h100-80g", TP: 1, Args: []string{"--max-model-len", "32768"},
|
|
})})
|
|
m, _ := c.Resolve("m")
|
|
|
|
ep, _ := m.EngineProfile("p")
|
|
ep.Args[0] = "tampered"
|
|
|
|
again, _ := m.EngineProfile("p")
|
|
if again.Args[0] != "--max-model-len" {
|
|
t.Error("EngineProfile exposed the catalog's own slice")
|
|
}
|
|
}
|
|
|
|
func TestNamesAndModelsOrder(t *testing.T) {
|
|
c, _ := New([]Model{model("zeta"), model("alpha")})
|
|
|
|
// Models keeps declaration order, so configuration reads as written.
|
|
if got := c.Models(); got[0].Name != "zeta" {
|
|
t.Errorf("Models order=%q", got[0].Name)
|
|
}
|
|
// Names is sorted, for stable CLI output.
|
|
if got := c.Names(); got[0] != "alpha" {
|
|
t.Errorf("Names order=%v", got)
|
|
}
|
|
}
|