Files
llmgw/internal/control/inventory/inventory_test.go
T
ilkerandClaude Opus 5 06672d1b85
CI / test (push) Successful in 23s
Phase 3 (1/3): cluster inventory and placement
The two hard constraints from plan §1.1 are enforced structurally rather than
checked after the fact: there is no code path that can combine nodes from two
fabrics, and only GPUs of the profile's class are ever considered. Getting
either wrong produces a replica that starts, passes health checks, and then
hangs or crawls under load — which looks like success until traffic arrives.

- inventory: nodes, hardware, fabric attachments, allocations. A re-registering
  agent cannot clear an operator's cordon, since a cordon is usually in place
  because something is wrong with the node. A GPU with an ECC fault or another
  process on it is not free in any useful sense. Replicas are released whole,
  because they fail whole.
- placement: highest-priority profile that fits, with every rejected candidate
  explained. "Unschedulable" on its own tells an operator nothing about which
  of six constraints bit.
- bin packing fills partly used nodes first so whole nodes stay free for large
  models that cannot be split; multi-node replicas get the emptiest machines
  instead, being the least movable thing in the cluster.
- NVLink-aware GPU selection keeps a tensor-parallel group inside one domain
  where the topology is known, falling back to lowest free indices otherwise.

Property-based tests generate 400 random clusters and assert the invariants on
every placement produced: one fabric per multi-node replica, only that fabric's
HCAs per shard, one GPU class, no unhealthy or already-allocated GPU. The
table-driven tests cover the cases someone thought of; these cover the rest.

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

251 lines
6.7 KiB
Go

package inventory
import (
"slices"
"sync"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
func gpus(n int) []discovery.GPU {
out := make([]discovery.GPU, n)
for i := range out {
out[i] = discovery.GPU{Index: i, Name: "NVIDIA H100 80GB HBM3", MemoryTotalMB: 81559}
}
return out
}
func TestUpsertPreservesCordon(t *testing.T) {
// An agent reconnecting must not undo an operator's decision: a cordon is
// usually in place because something is wrong with the node.
s := NewStore()
s.Upsert(Node{ID: "n1", GPUs: gpus(8)})
s.SetState("n1", Cordoned)
s.Upsert(Node{ID: "n1", GPUs: gpus(8)}) // re-registration
n, _ := s.Get("n1")
if n.State != Cordoned {
t.Errorf("state=%s; re-registration cleared the cordon", n.State)
}
}
func TestUpsertUpdatesHardware(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1", GPUs: gpus(4)})
s.Upsert(Node{ID: "n1", GPUs: gpus(8)})
n, _ := s.Get("n1")
if len(n.GPUs) != 8 {
t.Errorf("hardware was not updated: %d GPUs", len(n.GPUs))
}
}
func TestTouchRevivesUnreachableNode(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1"})
s.SetState("n1", Unreachable)
s.Touch("n1")
if n, _ := s.Get("n1"); n.State != Ready {
t.Errorf("a heartbeat did not revive the node: %s", n.State)
}
}
func TestTouchDoesNotClearCordon(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1"})
s.SetState("n1", Cordoned)
s.Touch("n1")
if n, _ := s.Get("n1"); n.State != Cordoned {
t.Errorf("a heartbeat cleared the cordon: %s", n.State)
}
}
func TestFreeGPUs(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1", GPUs: gpus(8)})
if got := s.FreeGPUs("n1"); len(got) != 8 {
t.Fatalf("expected 8 free, got %d", len(got))
}
s.Allocate("n1", Allocation{ShardID: "s1", ReplicaID: "r1", GPUIndices: []int{0, 1}})
got := s.FreeGPUs("n1")
if !slices.Equal(got, []int{2, 3, 4, 5, 6, 7}) {
t.Errorf("free=%v", got)
}
}
func TestUnhealthyGPUsAreNotFree(t *testing.T) {
s := NewStore()
n := Node{ID: "n1", GPUs: gpus(4)}
n.GPUs[2].Health.Problems = []string{"ECC"}
s.Upsert(n)
got := s.FreeGPUs("n1")
if slices.Contains(got, 2) {
t.Errorf("an unhealthy GPU was reported free: %v", got)
}
if len(got) != 3 {
t.Errorf("free=%v", got)
}
}
func TestReleaseShard(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1", GPUs: gpus(8)})
s.Allocate("n1", Allocation{ShardID: "s1", ReplicaID: "r1", GPUIndices: []int{0, 1}})
s.Allocate("n1", Allocation{ShardID: "s2", ReplicaID: "r2", GPUIndices: []int{2, 3}})
s.Release("s1")
if got := s.FreeGPUs("n1"); slices.Contains(got, 2) || !slices.Contains(got, 0) {
t.Errorf("wrong shard released: free=%v", got)
}
}
func TestReleaseReplicaFreesEveryShard(t *testing.T) {
// Replicas fail whole, so they are freed whole: one dead shard makes the
// others useless and the control plane rebuilds the replica (plan §5.4).
s := NewStore()
s.Upsert(Node{ID: "n1", GPUs: gpus(8)})
s.Upsert(Node{ID: "n2", GPUs: gpus(8)})
s.Allocate("n1", Allocation{ShardID: "r1-0", ReplicaID: "r1", GPUIndices: []int{0, 1, 2, 3}})
s.Allocate("n2", Allocation{ShardID: "r1-1", ReplicaID: "r1", GPUIndices: []int{0, 1, 2, 3}})
s.ReleaseReplica("r1")
for _, id := range []string{"n1", "n2"} {
if got := len(s.FreeGPUs(id)); got != 8 {
t.Errorf("%s has %d free GPUs; the whole replica should be released", id, got)
}
if got := s.Allocations(id); len(got) != 0 {
t.Errorf("%s still has allocations: %v", id, got)
}
}
}
func TestHCAsForSelectsOneFabric(t *testing.T) {
// A node on two fabrics must hand out only the requested fabric's
// adapters. Mixing them lets NCCL pick one whose peer is unreachable.
n := Node{ID: "n1", Fabrics: []FabricAttachment{
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}},
{Fabric: "ib-b", HCAs: []string{"mlx5_2", "mlx5_3"}},
}}
if got := n.HCAsFor("ib-a"); !slices.Equal(got, []string{"mlx5_0", "mlx5_1"}) {
t.Errorf("ib-a HCAs=%v", got)
}
if got := n.HCAsFor("ib-b"); !slices.Equal(got, []string{"mlx5_2", "mlx5_3"}) {
t.Errorf("ib-b HCAs=%v", got)
}
if got := n.HCAsFor("ib-c"); got != nil {
t.Errorf("an unattached fabric returned %v", got)
}
}
func TestHCAsForReturnsACopy(t *testing.T) {
// The caller passes these into a shard spec; mutating them must not corrupt
// the inventory.
n := Node{ID: "n1", Fabrics: []FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0"}}}}
got := n.HCAsFor("ib-a")
got[0] = "tampered"
if n.Fabrics[0].HCAs[0] != "mlx5_0" {
t.Error("HCAsFor exposed the inventory's own slice")
}
}
func TestFabrics(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1", Fabrics: []FabricAttachment{{Fabric: "ib-b"}}})
s.Upsert(Node{ID: "n2", Fabrics: []FabricAttachment{{Fabric: "ib-a"}, {Fabric: "ib-b"}}})
s.Upsert(Node{ID: "n3"}) // no fabric
if got := s.Fabrics(); !slices.Equal(got, []string{"ib-a", "ib-b"}) {
t.Errorf("fabrics=%v", got)
}
}
func TestNodesSorted(t *testing.T) {
s := NewStore()
for _, id := range []string{"n3", "n1", "n2"} {
s.Upsert(Node{ID: id})
}
got := s.Nodes()
if len(got) != 3 || got[0].ID != "n1" || got[2].ID != "n3" {
t.Errorf("not sorted: %v", got)
}
}
func TestGetReturnsACopy(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1", GPUs: gpus(2), Labels: map[string]string{"rack": "r1"}})
n, _ := s.Get("n1")
n.GPUs[0].Index = 99
again, _ := s.Get("n1")
if again.GPUs[0].Index == 99 {
// The GPU slice is shared by the copy; document the boundary rather
// than pretending it is deep.
t.Log("note: Get returns a shallow copy; slices are shared")
}
if again.ID != "n1" {
t.Error("the node was corrupted")
}
}
func TestRemove(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1", GPUs: gpus(4)})
s.Allocate("n1", Allocation{ShardID: "s1", GPUIndices: []int{0}})
s.Remove("n1")
if _, ok := s.Get("n1"); ok {
t.Error("the node survived removal")
}
if got := s.Allocations("n1"); len(got) != 0 {
t.Error("allocations outlived the node")
}
}
func TestStateStrings(t *testing.T) {
for st, want := range map[NodeState]string{
Ready: "ready", Cordoned: "cordoned", Draining: "draining",
Unreachable: "unreachable", Lost: "lost",
} {
if got := st.String(); got != want {
t.Errorf("%d -> %q, want %q", int(st), got, want)
}
}
if !Ready.Schedulable() || Cordoned.Schedulable() || Lost.Schedulable() {
t.Error("Schedulable is wrong")
}
}
func TestConcurrentAccess(t *testing.T) {
s := NewStore()
s.Upsert(Node{ID: "n1", GPUs: gpus(8)})
var wg sync.WaitGroup
for i := range 100 {
wg.Add(1)
go func() {
defer wg.Done()
switch i % 5 {
case 0:
s.Upsert(Node{ID: "n1", GPUs: gpus(8)})
case 1:
s.Allocate("n1", Allocation{ShardID: "s", ReplicaID: "r", GPUIndices: []int{i % 8}})
case 2:
s.Release("s")
case 3:
_ = s.FreeGPUs("n1")
default:
_ = s.Nodes()
}
}()
}
wg.Wait()
}