Files
llmgw/internal/control/adminapi/summary_test.go
Ilker ManapandClaude Opus 5 c13c567cc5
CI / test (push) Successful in 1m32s
Know where the weights are, without owning the storage
Storage will be different at every installation — Lustre, NFS over RDMA, an
image exported with SRP or NVMe-oF, local NVMe, an rsync — so llmgw observes
it rather than managing it. An agent told which directories to look in
reports what it can see; how the files got there is nobody's business here,
and a node told nothing reports nothing and behaves exactly as every node
did before this existed.

That fact buys two things. Placement prefers a node that already holds the
model over one that must first move tens of gigabytes, as a preference and
never a filter: fetching them is a valid answer, and refusing to place would
turn a slow start into no start at all. And ctl status shows what each
fabric holds.

Per fabric because fabrics are not connected to one another. Weights in
ib-a are not weights in ib-b — the only path between them is the management
network — so a model seeded into one fabric leaves the rest of the cluster
unable to serve it without a slow copy, and nothing said so before now. One
shared filer counts the same as twenty local copies, because "can this
fabric serve it" is the question being asked.

Three distinctions the tests hold to, because each is a way to get this
quietly wrong. A node that was never asked to look must rank with one that
looked and has nothing, or enabling the feature on a single node would
drain every other. An empty directory is not weights, because a
half-finished download looks exactly like one and would send placement to a
node whose engine then fails to load. And local storage is never mistaken
for shared: a filer read as local costs an unnecessary copy, while local
read as shared would have the cluster believe a model is everywhere when it
is on one node.

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

156 lines
5.0 KiB
Go

package adminapi
import (
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
)
func TestSummaryCountsCapacityByClass(t *testing.T) {
// The question a per-node table answers badly at two hundred nodes: how
// full am I.
a, _ := setup(t, testModels(), gpuNode("n1", 8), gpuNode("n2", 8))
s := a.Summary()
if s.Nodes.Total != 2 || s.Nodes.Ready != 2 {
t.Errorf("nodes = %+v", s.Nodes)
}
if len(s.GPUs) != 1 {
t.Fatalf("expected one GPU class, got %+v", s.GPUs)
}
if g := s.GPUs[0]; g.Total != 16 || g.Free() != 16 {
t.Errorf("class totals wrong: %+v", g)
}
}
func TestUnhealthyGPUsAreNotCountedAsFree(t *testing.T) {
// Counting them free is how a cluster looks like it has room it does not,
// which is worse than showing less capacity than exists.
n := gpuNode("n1", 4)
n.GPUs[0].Health.Problems = []string{"3 uncorrected ECC errors"}
a, _ := setup(t, testModels(), n)
g := a.Summary().GPUs[0]
if g.Unhealthy != 1 {
t.Errorf("unhealthy = %d", g.Unhealthy)
}
if g.Free() != 3 {
t.Errorf("free = %d; a faulty card was counted as available", g.Free())
}
}
func TestFabricFreeBlocksCountWholeNodesOnly(t *testing.T) {
// A multi-node replica may never span fabrics, and a node with one card in
// use cannot contribute a full-width shard. Free cards scattered across
// partly-used nodes are not capacity for the model that needs them.
free := gpuNode("free", 8, "ib-a")
partial := gpuNode("partial", 8, "ib-a")
a, ctl := setup(t, testModels(), free, partial)
// Take one GPU on the second node.
ctl.Inventory().Allocate("partial", inventory.Allocation{
ShardID: "s", ReplicaID: "r", GPUIndices: []int{0},
})
s := a.Summary()
if len(s.Fabrics) != 1 {
t.Fatalf("fabrics = %+v", s.Fabrics)
}
if got := s.Fabrics[0].LargestFreeBlock; got != 1 {
t.Errorf("fully free nodes = %d, want 1: a partly used node is not one", got)
}
}
func TestANodeWithNoGPUsDoesNotInventAClass(t *testing.T) {
// The CPU-only node this fleet acquired. It has no class, and a summary
// that invents one for it would report capacity nobody has.
a, _ := setup(t, testModels(), inventory.Node{ID: "cpu1", State: inventory.Ready})
s := a.Summary()
for _, g := range s.GPUs {
if g.Class == "" {
t.Errorf("an empty class was reported: %+v", g)
}
}
if s.Nodes.Total != 1 {
t.Errorf("the node was not counted: %+v", s.Nodes)
}
}
func TestIdleReplicasNeedRealIdleness(t *testing.T) {
// A replica the router has said nothing about carries -1, and must not be
// read as idle since the epoch — otherwise a router restart nominates the
// whole fleet for eviction at once.
for _, tc := range []struct {
name string
idle int64
want bool
}{
{"never reported", -1, false},
{"just used", 5, false},
{"idle for an hour", int64(time.Hour.Seconds()), true},
} {
t.Run(tc.name, func(t *testing.T) {
got := tc.idle >= int64(idleThreshold.Seconds())
if got != tc.want {
t.Errorf("idle=%d counted as idle=%v, want %v", tc.idle, got, tc.want)
}
})
}
}
func TestSummaryNamesModelsThatCannotBePlaced(t *testing.T) {
// The line that turns "something is wrong" into a place to look.
a, _ := setup(t, testModels()) // no nodes at all
s := a.Summary()
if len(s.Unplaceable) == 0 {
t.Fatal("a model with no hardware to run on was not named")
}
if s.Models.Short == 0 {
t.Error("no model was counted short")
}
}
func TestWeightsAreCountedPerFabric(t *testing.T) {
// Fabrics are not connected, so weights in one are not weights in another.
// A model seeded into a single fabric leaves the rest of the cluster unable
// to serve it without first moving tens of gigabytes over the management
// network, and nothing said so.
a1 := gpuNode("a1", 4, "ib-a")
a1.Weights = []discovery.LocalWeights{{Path: "/models/llama.gguf"}}
a2 := gpuNode("a2", 4, "ib-a")
a2.Weights = []discovery.LocalWeights{{Path: "/models/llama.gguf"}}
b1 := gpuNode("b1", 4, "ib-b") // the same fabric capacity, none of the weights
api, _ := setup(t, testModels(), a1, a2, b1)
s := api.Summary()
byName := map[string]FabricUse{}
for _, f := range s.Fabrics {
byName[f.Fabric] = f
}
if got := byName["ib-a"]; got.Models != 1 || got.NodesWithWeights != 2 {
t.Errorf("ib-a = %+v, want one model on two nodes", got)
}
if got := byName["ib-b"]; got.Models != 0 || got.NodesWithWeights != 0 {
t.Errorf("ib-b = %+v; it holds nothing and should say so", got)
}
}
func TestOneSharedCopyCountsAsMuchAsTwenty(t *testing.T) {
// A filer mounted on every node and a local copy on every node are the same
// answer to "can this fabric serve it", which is the question being asked.
var nodes []inventory.Node
for _, id := range []string{"n1", "n2", "n3"} {
n := gpuNode(id, 4, "ib-a")
n.Weights = []discovery.LocalWeights{{Path: "/mnt/shared/llama.gguf", Shared: true}}
nodes = append(nodes, n)
}
api, _ := setup(t, testModels(), nodes...)
f := api.Summary().Fabrics[0]
if f.Models != 1 {
t.Errorf("counted %d models; three nodes seeing one file is one model", f.Models)
}
}