The control plane asks one node on each physical fabric to run
ibnetdiscover every five minutes and parses the text itself, so a
format the parser has not met is a control plane upgrade, not one on
every node. From the map it works out the switch tiers (leaf, spine,
core, by distance from the adapters), which adapter is which node's,
which cables trained below the rate the rest of their kind run at, and
whether the rails line up: every node's Nth adapter on its own leaf.
A page per fabric (/admin/fabrics) draws it: switches in their tiers,
adapters fanned out under their leaves, cables coloured by rate and
bundled, degraded ones marching red with a marker, and the cluster's
own adapters live - down ports glowing red, and particles running each
cable as fast as its adapter is sending and receiving, from the
counters. It zooms, pans, fades what a hovered switch or adapter does
not touch, opens a panel of detail on a click, colours by rail, finds a
node, and refreshes every fifteen seconds. The data is also at
/api/v1/fabrics/{id}/topology.
Placement reads the map: a replica spanning nodes takes nodes of one
leaf together when it can, and nodes whose cable trained short last.
Static assets are named by their content, as the stylesheet already
was.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
884 lines
27 KiB
Go
884 lines
27 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/net/http2"
|
|
"golang.org/x/net/http2/h2c"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
|
)
|
|
|
|
// statusRecorder captures what the control plane hears about shards, and what
|
|
// it is asked to adopt.
|
|
type statusRecorder struct {
|
|
ch chan status
|
|
|
|
mu sync.Mutex
|
|
calls []adoptCall
|
|
adopted []reconciler.AdoptedShard
|
|
unclaim []string // shard ids to report back as unclaimed
|
|
fetches []fetchReport
|
|
}
|
|
|
|
type status struct {
|
|
replicaID, shardID string
|
|
state supervisor.State
|
|
err string
|
|
}
|
|
|
|
func newRecorder() *statusRecorder { return &statusRecorder{ch: make(chan status, 128)} }
|
|
|
|
func (r *statusRecorder) OnShardStatus(replicaID, shardID string, st supervisor.State, errMsg string) {
|
|
select {
|
|
case r.ch <- status{replicaID, shardID, st, errMsg}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// fetches records download progress, so a test can assert that a fetch is
|
|
// visible from the control plane rather than only in the node's log.
|
|
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source string, done, total int64) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
|
|
}
|
|
|
|
func (r *statusRecorder) fetchReports() []fetchReport {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return append([]fetchReport(nil), r.fetches...)
|
|
}
|
|
|
|
type fetchReport struct {
|
|
replicaID, shardID, source string
|
|
done, total int64
|
|
}
|
|
|
|
func (r *statusRecorder) Adopt(nodeID string, shards []reconciler.AdoptedShard) []reconciler.AdoptedShard {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.calls = append(r.calls, adoptCall{nodeID: nodeID, shards: shards})
|
|
r.adopted = append(r.adopted, shards...)
|
|
|
|
var unclaimed []reconciler.AdoptedShard
|
|
for _, s := range shards {
|
|
if slices.Contains(r.unclaim, s.ShardID) {
|
|
unclaimed = append(unclaimed, s)
|
|
}
|
|
}
|
|
return unclaimed
|
|
}
|
|
|
|
// adoptCall records that the service reached the reconciler at all, which an
|
|
// empty shard list used to prevent.
|
|
type adoptCall struct {
|
|
nodeID string
|
|
shards []reconciler.AdoptedShard
|
|
}
|
|
|
|
func (r *statusRecorder) adoptCalls() []adoptCall {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return slices.Clone(r.calls)
|
|
}
|
|
|
|
func (r *statusRecorder) adoptedShards() []reconciler.AdoptedShard {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return slices.Clone(r.adopted)
|
|
}
|
|
|
|
func (r *statusRecorder) markUnclaimed(ids ...string) {
|
|
r.mu.Lock()
|
|
r.unclaim = append(r.unclaim, ids...)
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
func (r *statusRecorder) await(t *testing.T, limit time.Duration, pred func(status) bool) status {
|
|
t.Helper()
|
|
deadline := time.After(limit)
|
|
var seen []status
|
|
for {
|
|
select {
|
|
case s := <-r.ch:
|
|
seen = append(seen, s)
|
|
if pred(s) {
|
|
return s
|
|
}
|
|
case <-deadline:
|
|
t.Fatalf("no matching status within %v; saw %+v", limit, seen)
|
|
return status{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// fakeEngineScript writes a script that stands in for vLLM.
|
|
//
|
|
// It parses the real --port flag the vLLM adapter generates rather than being
|
|
// told out of band, so the adapter's argument construction is exercised too.
|
|
func fakeEngineScript(t *testing.T, body string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "fake-vllm")
|
|
script := `#!/bin/sh
|
|
port=""
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--port) port="$2"; shift 2 ;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
` + body + "\n"
|
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// servingEngine answers /health and /v1/models, which is what readiness needs.
|
|
func servingEngine(t *testing.T) string {
|
|
t.Helper()
|
|
py, err := exec.LookPath("python3")
|
|
if err != nil {
|
|
t.Skip("python3 is required for the fake engine")
|
|
}
|
|
return fakeEngineScript(t, `exec `+py+` -c '
|
|
import http.server, json, sys
|
|
port = int(sys.argv[1])
|
|
class H(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
if self.path == "/health":
|
|
self.send_response(200); self.end_headers(); return
|
|
if self.path == "/v1/models":
|
|
b = json.dumps({"object":"list","data":[{"id":"test-model"}]}).encode()
|
|
self.send_response(200)
|
|
self.send_header("content-type","application/json")
|
|
self.send_header("content-length", str(len(b)))
|
|
self.end_headers(); self.wfile.write(b); return
|
|
self.send_response(404); self.end_headers()
|
|
def log_message(self, *a): pass
|
|
http.server.HTTPServer(("127.0.0.1", port), H).serve_forever()
|
|
' "$port"`)
|
|
}
|
|
|
|
type harness struct {
|
|
svc *agentsvc.Service
|
|
inv *inventory.Store
|
|
rec *statusRecorder
|
|
srv *httptest.Server
|
|
ag *Agent
|
|
ln *dropListener
|
|
// streams counts how many times an agent has opened the Connect stream,
|
|
// which is the only honest way to tell a reconnect from a stale entry.
|
|
streams *atomic.Int64
|
|
}
|
|
|
|
// dropListener tracks accepted connections so a test can sever them.
|
|
//
|
|
// httptest's CloseClientConnections does not work here: h2c with prior
|
|
// knowledge hijacks the connection, and httptest stops tracking hijacked
|
|
// connections. A test relying on it would pass without ever reconnecting.
|
|
type dropListener struct {
|
|
net.Listener
|
|
mu sync.Mutex
|
|
conns []net.Conn
|
|
}
|
|
|
|
func (l *dropListener) Accept() (net.Conn, error) {
|
|
c, err := l.Listener.Accept()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
l.mu.Lock()
|
|
l.conns = append(l.conns, c)
|
|
l.mu.Unlock()
|
|
return c, nil
|
|
}
|
|
|
|
// drop severs every connection, as a control plane restart would.
|
|
func (l *dropListener) drop() {
|
|
l.mu.Lock()
|
|
conns := l.conns
|
|
l.conns = nil
|
|
l.mu.Unlock()
|
|
for _, c := range conns {
|
|
_ = c.Close()
|
|
}
|
|
}
|
|
|
|
// start wires a real agent to a real service over h2c, so this exercises the
|
|
// protocol rather than two structs calling each other.
|
|
func start(t *testing.T, cfgFns ...func(*Config)) *harness {
|
|
t.Helper()
|
|
return startWith(t, slog.New(slog.DiscardHandler), cfgFns...)
|
|
}
|
|
|
|
// startLogged is start with logging on, for diagnosing protocol behaviour.
|
|
func startLogged(t *testing.T, cfgFns ...func(*Config)) *harness {
|
|
t.Helper()
|
|
return startWith(t, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})), cfgFns...)
|
|
}
|
|
|
|
func startWith(t *testing.T, log *slog.Logger, cfgFns ...func(*Config)) *harness {
|
|
t.Helper()
|
|
|
|
inv := inventory.NewStore()
|
|
rec := newRecorder()
|
|
svc := agentsvc.New(inv, rec, false, log)
|
|
|
|
path, handler := svc.Handler()
|
|
mux := http.NewServeMux()
|
|
mux.Handle(path, handler)
|
|
|
|
var streams atomic.Int64
|
|
counting := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
streams.Add(1)
|
|
mux.ServeHTTP(w, r)
|
|
})
|
|
|
|
raw, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ln := &dropListener{Listener: raw}
|
|
// connect's bidirectional streams need HTTP/2; h2c provides it without TLS.
|
|
srv := httptest.NewUnstartedServer(h2c.NewHandler(counting, &http2.Server{}))
|
|
srv.Listener = ln
|
|
srv.Start()
|
|
|
|
cfg := DefaultConfig()
|
|
cfg.NodeID = "n1"
|
|
cfg.Server = srv.URL
|
|
cfg.MgmtAddr = "127.0.0.1"
|
|
cfg.HeartbeatInterval = 50 * time.Millisecond
|
|
cfg.ReconnectBase = 20 * time.Millisecond
|
|
cfg.ReconnectMax = 100 * time.Millisecond
|
|
cfg.Discovery.SkipGPUs = true
|
|
cfg.Discovery.IBRoot = filepath.Join(t.TempDir(), "absent")
|
|
cfg.Superv = supervisor.Config{
|
|
WorkDir: t.TempDir(), LogLines: 64,
|
|
StopTimeout: 2 * time.Second, HealthInterval: 20 * time.Millisecond,
|
|
}
|
|
for _, f := range cfgFns {
|
|
f(&cfg)
|
|
}
|
|
|
|
client := &http.Client{Transport: &http2.Transport{
|
|
AllowHTTP: true,
|
|
DialTLSContext: func(ctx context.Context, network, addr string, _ *tls.Config) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, network, addr)
|
|
},
|
|
}}
|
|
|
|
ag, err := New(cfg, client, log)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() { _ = ag.Run(ctx) }()
|
|
|
|
h := &harness{svc: svc, inv: inv, rec: rec, srv: srv, ag: ag, ln: ln, streams: &streams}
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
ag.Supervisor().StopAll(context.Background())
|
|
srv.Close()
|
|
})
|
|
h.waitRegistered(t)
|
|
return h
|
|
}
|
|
|
|
func (h *harness) waitRegistered(t *testing.T) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if _, ok := h.inv.Get("n1"); ok && len(h.svc.ConnectedNodes()) == 1 {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatal("the agent never registered")
|
|
}
|
|
|
|
// shardCommand builds a single-node vLLM shard on a free port.
|
|
//
|
|
// The model path is a real file. It used to be "/models/x", which stopped
|
|
// working once the agent began checking that weights exist before launching an
|
|
// engine — the check doing exactly what it is for, on a test that was relying
|
|
// on nobody looking.
|
|
func shardCommand(t *testing.T, shardID string) reconciler.ShardCommand {
|
|
t.Helper()
|
|
modelPath := filepath.Join(t.TempDir(), "model.gguf")
|
|
if err := os.WriteFile(modelPath, []byte("weights"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return reconciler.ShardCommand{
|
|
ReplicaID: "rep-1", ShardID: shardID, NodeID: "n1",
|
|
Spec: engines.ShardSpec{
|
|
ReplicaID: "rep-1", ShardID: shardID, Rank: 0, NNodes: 1,
|
|
GPUIndices: []int{0}, HTTPPort: freePort(t),
|
|
Profile: engines.Profile{
|
|
Name: "p", Engine: "vllm", TP: 1,
|
|
ModelPath: modelPath, ServedModelName: "test-model",
|
|
},
|
|
},
|
|
StartupTimeout: 15 * time.Second,
|
|
}
|
|
}
|
|
|
|
func freePort(t *testing.T) int {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer ln.Close()
|
|
return ln.Addr().(*net.TCPAddr).Port
|
|
}
|
|
|
|
func TestRegistrationCarriesInventory(t *testing.T) {
|
|
// The node must actually have the adapters it declares. It used to declare
|
|
// mlx5_0 and mlx5_1 on a node whose sysfs held neither, which passed
|
|
// because nothing compared the two — and that is the gap a real cluster
|
|
// would have found as a replica hanging on its first all-reduce.
|
|
ibRoot := ibTreeWithActivePorts(t, "mlx5_0", "mlx5_1")
|
|
h := start(t, func(c *Config) {
|
|
c.Hostname = "gpu-a-01"
|
|
c.Labels = map[string]string{"rack": "r12"}
|
|
c.Discovery.IBRoot = ibRoot
|
|
c.Fabrics = []inventory.FabricAttachment{
|
|
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}, RateGbps: 200},
|
|
}
|
|
})
|
|
|
|
n, ok := h.inv.Get("n1")
|
|
if !ok {
|
|
t.Fatal("node missing from inventory")
|
|
}
|
|
if n.Hostname != "gpu-a-01" || n.MgmtAddr != "127.0.0.1" {
|
|
t.Errorf("identity wrong: %+v", n)
|
|
}
|
|
if n.Labels["rack"] != "r12" {
|
|
t.Errorf("labels lost: %v", n.Labels)
|
|
}
|
|
// The fabric label cannot be inferred from the wire, so losing it in
|
|
// transit would silently disable multi-node placement.
|
|
if !n.HasFabric("ib-a") {
|
|
t.Fatalf("fabric attachment lost: %+v", n.Fabrics)
|
|
}
|
|
if got := n.HCAsFor("ib-a"); !slices.Equal(got, []string{"mlx5_0", "mlx5_1"}) {
|
|
t.Errorf("HCAs=%v", got)
|
|
}
|
|
}
|
|
|
|
func TestHeartbeatKeepsNodeAlive(t *testing.T) {
|
|
h := start(t)
|
|
|
|
n, _ := h.inv.Get("n1")
|
|
first := n.LastSeen
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
n, _ = h.inv.Get("n1")
|
|
if !n.LastSeen.After(first) {
|
|
t.Error("heartbeats are not refreshing LastSeen; the node would be declared lost")
|
|
}
|
|
}
|
|
|
|
func TestStartAndStopShardEndToEnd(t *testing.T) {
|
|
h := start(t, func(c *Config) {
|
|
c.Engines.VLLM.Bin = servingEngine(t)
|
|
})
|
|
|
|
cmd := shardCommand(t, "shard-a")
|
|
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got := h.rec.await(t, 30*time.Second, func(s status) bool {
|
|
return s.shardID == "shard-a" && s.state == supervisor.Ready
|
|
})
|
|
if got.replicaID != "rep-1" {
|
|
t.Errorf("replica id lost in transit: %q", got.replicaID)
|
|
}
|
|
|
|
if err := h.svc.StopShard(t.Context(), "n1", "shard-a"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.rec.await(t, 15*time.Second, func(s status) bool {
|
|
return s.shardID == "shard-a" && s.state == supervisor.Stopped
|
|
})
|
|
}
|
|
|
|
func TestFailedShardCarriesDiagnosis(t *testing.T) {
|
|
// When a model fails to load the engine's output is the only diagnosis, so
|
|
// it has to survive the trip to the control plane.
|
|
h := start(t, func(c *Config) {
|
|
c.Engines.VLLM.Bin = fakeEngineScript(t, `echo 'CUDA out of memory' >&2; exit 1`)
|
|
})
|
|
|
|
if err := h.svc.StartShard(t.Context(), shardCommand(t, "shard-bad")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := h.rec.await(t, 30*time.Second, func(s status) bool {
|
|
return s.shardID == "shard-bad" && s.state == supervisor.Failed
|
|
})
|
|
if got.err == "" {
|
|
t.Error("the failure carried no error message")
|
|
}
|
|
}
|
|
|
|
func TestEngineThatNeverBecomesReady(t *testing.T) {
|
|
// A process that runs but never serves must time out rather than hang the
|
|
// replica forever.
|
|
h := start(t, func(c *Config) {
|
|
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 60`)
|
|
})
|
|
|
|
cmd := shardCommand(t, "shard-stuck")
|
|
cmd.StartupTimeout = 500 * time.Millisecond
|
|
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.rec.await(t, 20*time.Second, func(s status) bool {
|
|
return s.shardID == "shard-stuck" && s.state == supervisor.Failed
|
|
})
|
|
}
|
|
|
|
func TestValidationRejectsBeforeLaunch(t *testing.T) {
|
|
// An engine that refuses a profile does so after minutes of loading;
|
|
// catching it at dispatch costs nothing.
|
|
h := start(t, func(c *Config) {
|
|
c.Engines.VLLM.Bin = servingEngine(t)
|
|
})
|
|
|
|
cmd := shardCommand(t, "shard-invalid")
|
|
cmd.Spec.Profile.ModelPath = "" // validation requires one
|
|
|
|
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := h.rec.await(t, 15*time.Second, func(s status) bool {
|
|
return s.shardID == "shard-invalid" && s.state == supervisor.Failed
|
|
})
|
|
if got.err == "" {
|
|
t.Error("validation failure carried no reason")
|
|
}
|
|
}
|
|
|
|
func TestDispatchToUnconnectedNode(t *testing.T) {
|
|
h := start(t)
|
|
|
|
err := h.svc.StartShard(t.Context(), reconciler.ShardCommand{NodeID: "nowhere", ShardID: "x"})
|
|
if err == nil {
|
|
t.Fatal("dispatching to an unconnected node succeeded")
|
|
}
|
|
if !errors.Is(err, agentsvc.ErrNotConnected) {
|
|
t.Errorf("err=%v, expected ErrNotConnected", err)
|
|
}
|
|
}
|
|
|
|
func TestAgentReconnects(t *testing.T) {
|
|
// A control plane restart must not require touching every GPU node.
|
|
h := start(t)
|
|
|
|
before := h.streams.Load()
|
|
h.ln.drop()
|
|
|
|
// Counting streams, not connected nodes: a stale entry in the connection
|
|
// map would make a node look connected without any reconnect having
|
|
// happened, and an assertion on it would pass vacuously.
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if h.streams.Load() > before && len(h.svc.ConnectedNodes()) == 1 {
|
|
if err := h.svc.DrainShard(t.Context(), "n1", "nothing"); err != nil {
|
|
t.Fatalf("the reconnected stream does not accept commands: %v", err)
|
|
}
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatalf("the agent never reconnected (streams %d -> %d)", before, h.streams.Load())
|
|
}
|
|
|
|
func TestIdempotentStart(t *testing.T) {
|
|
// The control plane retries commands; a repeat must not start a second
|
|
// process (plan §5.6).
|
|
h := start(t, func(c *Config) {
|
|
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 30`)
|
|
})
|
|
|
|
cmd := shardCommand(t, "shard-idem")
|
|
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.rec.await(t, 15*time.Second, func(s status) bool {
|
|
return s.shardID == "shard-idem" && s.state == supervisor.Starting
|
|
})
|
|
|
|
sup := h.ag.Supervisor()
|
|
sh, err := sup.Get("shard-idem")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pid := sh.Status().PID
|
|
|
|
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
time.Sleep(400 * time.Millisecond)
|
|
|
|
sh2, err := sup.Get("shard-idem")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if sh2.Status().PID != pid {
|
|
t.Errorf("a second process was started: pid %d then %d", pid, sh2.Status().PID)
|
|
}
|
|
}
|
|
|
|
// TestRunningShardsReportedOnReconnect covers the control plane restart hole
|
|
// (plan §6.5): a reconnecting agent has to describe what it is already running,
|
|
// or the control plane concludes the cluster is empty and places duplicates
|
|
// while the originals keep holding GPUs.
|
|
func TestRunningShardsReportedOnReconnect(t *testing.T) {
|
|
h := start(t, func(c *Config) {
|
|
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 60`)
|
|
})
|
|
|
|
cmd := shardCommand(t, "shard-live")
|
|
cmd.Pool = "ib-a/h100-80g"
|
|
cmd.Fabric = "ib-a"
|
|
cmd.Spec.HCAs = []string{"mlx5_0"}
|
|
|
|
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.rec.await(t, 15*time.Second, func(s status) bool {
|
|
return s.shardID == "shard-live" && s.state == supervisor.Starting
|
|
})
|
|
|
|
// Drop the stream, as a control plane restart would.
|
|
h.ln.drop()
|
|
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if got := h.rec.adoptedShards(); len(got) > 0 {
|
|
a := got[0]
|
|
if a.ShardID != "shard-live" || a.ReplicaID != "rep-1" {
|
|
t.Fatalf("wrong shard reported: %+v", a)
|
|
}
|
|
// Everything the control plane needs to rebuild the replica.
|
|
if a.Model != "test-model" || a.Profile != "p" {
|
|
t.Errorf("catalog identity lost: model=%q profile=%q", a.Model, a.Profile)
|
|
}
|
|
if a.Pool != "ib-a/h100-80g" || a.Fabric != "ib-a" {
|
|
t.Errorf("pool/fabric lost: %q %q", a.Pool, a.Fabric)
|
|
}
|
|
if !slices.Equal(a.HCAs, []string{"mlx5_0"}) {
|
|
t.Errorf("HCAs lost: %v", a.HCAs)
|
|
}
|
|
if a.Endpoint == "" {
|
|
t.Error("rank 0 reported no endpoint; the router would have nowhere to send")
|
|
}
|
|
if len(a.GPUIndices) == 0 {
|
|
t.Error("GPU indices lost; the inventory could not reclaim them")
|
|
}
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
t.Fatal("the reconnecting agent never reported its running shard")
|
|
}
|
|
|
|
// TestUnclaimedShardIsStopped covers the other half: a shard whose model left
|
|
// the catalog is holding GPUs nothing will ever route to.
|
|
func TestUnclaimedShardIsStopped(t *testing.T) {
|
|
h := start(t, func(c *Config) {
|
|
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 60`)
|
|
})
|
|
|
|
cmd := shardCommand(t, "shard-orphaned")
|
|
if err := h.svc.StartShard(t.Context(), cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h.rec.await(t, 15*time.Second, func(s status) bool {
|
|
return s.shardID == "shard-orphaned" && s.state == supervisor.Starting
|
|
})
|
|
|
|
sh, err := h.ag.Supervisor().Get("shard-orphaned")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pid := sh.Status().PID
|
|
|
|
// The control plane will refuse to claim it on reconnect.
|
|
h.rec.markUnclaimed("shard-orphaned")
|
|
h.ln.drop()
|
|
|
|
deadline := time.Now().Add(20 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if !processAliveForTest(pid) {
|
|
return
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
t.Error("an unclaimed shard kept running and holding its GPUs")
|
|
}
|
|
|
|
// processAliveForTest mirrors the supervisor's liveness check.
|
|
func processAliveForTest(pid int) bool {
|
|
return syscall.Kill(pid, 0) == nil
|
|
}
|
|
|
|
func TestAShardWithMissingWeightsFailsBeforeLaunching(t *testing.T) {
|
|
// The commonest setup mistake on a fresh node. Left to the engine it
|
|
// surfaces minutes into a load, in the engine's own words, on a machine
|
|
// nobody is watching — so it is caught here, where the message can say
|
|
// what is actually wrong.
|
|
a := &Agent{cfg: Config{WeightsCache: t.TempDir()}, log: slog.New(slog.DiscardHandler)}
|
|
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"))
|
|
if err == nil {
|
|
t.Fatal("a missing weights file was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "not-there.gguf") {
|
|
t.Errorf("the error does not name the file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTheCacheDefaultsToTheFirstModelDirectory(t *testing.T) {
|
|
// A node that has said where its models live has already answered this;
|
|
// asking twice is a second thing to get out of step.
|
|
dir := t.TempDir()
|
|
a := &Agent{cfg: Config{ModelDirs: []string{dir, "/elsewhere"}}, log: slog.New(slog.DiscardHandler)}
|
|
if got := a.weights().CacheDir; got != dir {
|
|
t.Errorf("cache = %q, want the first model directory %q", got, dir)
|
|
}
|
|
}
|
|
|
|
// ibTreeWithActivePorts builds a sysfs InfiniBand tree with the named adapters,
|
|
// each with one active port, shaped like the real thing.
|
|
func ibTreeWithActivePorts(t *testing.T, names ...string) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for _, name := range names {
|
|
dir := filepath.Join(root, name, "ports", "1")
|
|
if err := os.MkdirAll(filepath.Join(dir, "gids"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for file, content := range map[string]string{
|
|
"state": "4: ACTIVE",
|
|
"phys_state": "5: LinkUp",
|
|
"rate": "200 Gb/sec (4X HDR)",
|
|
"link_layer": "InfiniBand",
|
|
"lid": "0x1a",
|
|
"gids/0": "fec0:0000:0000:000a:0002:c903:00a0:1234",
|
|
} {
|
|
if err := os.WriteFile(filepath.Join(dir, file), []byte(content+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
func TestAFabricWhoseAdaptersAreAbsentIsNotAdvertised(t *testing.T) {
|
|
// The failure this prevents: a typo, or a card that enumerated differently
|
|
// after a reboot, produced NCCL_IB_HCA naming an adapter that is not there.
|
|
// The replica starts, passes its health checks, and hangs on its first
|
|
// all-reduce — the exact case the fabric invariant exists to prevent,
|
|
// arriving through a door nobody was watching.
|
|
h := start(t, func(c *Config) {
|
|
c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0")
|
|
c.Fabrics = []inventory.FabricAttachment{
|
|
{Fabric: "ib-typo", HCAs: []string{"mlx5_9"}, RateGbps: 200},
|
|
}
|
|
})
|
|
|
|
n, ok := h.inv.Get("n1")
|
|
if !ok {
|
|
t.Fatal("node missing from inventory")
|
|
}
|
|
if n.HasFabric("ib-typo") {
|
|
t.Errorf("a fabric with no usable adapter was advertised: %+v", n.Fabrics)
|
|
}
|
|
}
|
|
|
|
func TestAFabricRateIsTheSlowestActiveLink(t *testing.T) {
|
|
// A collective runs at the speed of its slowest link, so reporting the
|
|
// fastest would have the scheduler believe a fabric is quicker than it is.
|
|
root := t.TempDir()
|
|
for name, rate := range map[string]string{"mlx5_0": "200", "mlx5_1": "100"} {
|
|
dir := filepath.Join(root, name, "ports", "1")
|
|
if err := os.MkdirAll(filepath.Join(dir, "gids"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for file, content := range map[string]string{
|
|
"state": "4: ACTIVE", "phys_state": "5: LinkUp",
|
|
"rate": rate + " Gb/sec (4X HDR)", "link_layer": "InfiniBand",
|
|
"lid": "0x1a", "gids/0": "fec0::1",
|
|
} {
|
|
if err := os.WriteFile(filepath.Join(dir, file), []byte(content+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
h := start(t, func(c *Config) {
|
|
c.Discovery.IBRoot = root
|
|
c.Fabrics = []inventory.FabricAttachment{
|
|
{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}},
|
|
}
|
|
})
|
|
|
|
n, _ := h.inv.Get("n1")
|
|
for _, f := range n.Fabrics {
|
|
if f.Fabric == "ib-a" && f.RateGbps != 100 {
|
|
t.Errorf("fabric rate = %d, want the slower link's 100", f.RateGbps)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAPortGoingDownIsToldToTheControlPlaneOnce(t *testing.T) {
|
|
// Checked only at registration, a port going down went unnoticed until
|
|
// the agent restarted, and the node went on being offered multi-node
|
|
// work that would hang on its first collective.
|
|
root := ibTreeWithActivePorts(t, "mlx5_0")
|
|
h := start(t, func(c *Config) {
|
|
c.Discovery.IBRoot = root
|
|
c.Fabrics = []inventory.FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0"}}}
|
|
})
|
|
if n, _ := h.inv.Get("n1"); !n.HasFabric("ib-a") {
|
|
t.Fatalf("the fabric should be advertised at registration: %+v", n.Fabrics)
|
|
}
|
|
|
|
hb := h.ag.heartbeat().GetHeartbeat()
|
|
if hb.GetFabricsChanged() || len(hb.GetIbPorts()) != 1 || hb.GetIbPorts()[0].GetState() != "ACTIVE" {
|
|
t.Fatalf("a quiet heartbeat should carry the port and no change: %+v", hb)
|
|
}
|
|
|
|
if err := os.WriteFile(filepath.Join(root, "mlx5_0", "ports", "1", "state"), []byte("1: DOWN\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hb = h.ag.heartbeat().GetHeartbeat()
|
|
if !hb.GetFabricsChanged() || len(hb.GetFabrics()) != 0 {
|
|
t.Errorf("the port going down should withdraw the fabric: changed=%v fabrics=%v",
|
|
hb.GetFabricsChanged(), hb.GetFabrics())
|
|
}
|
|
if hb = h.ag.heartbeat().GetHeartbeat(); hb.GetFabricsChanged() {
|
|
t.Error("the change should be sent once, not on every heartbeat")
|
|
}
|
|
}
|
|
|
|
func TestAShardUsesThisNodesInterfaceAndRoCEIndex(t *testing.T) {
|
|
// The control plane sends one interface name for every node; a node whose
|
|
// management network is on another had NCCL bind nothing. And which RoCE
|
|
// GID entry routes is a property of this node's adapters.
|
|
a := &Agent{cfg: Config{MgmtInterface: "ens3"}}
|
|
a.inv.HCAs = []discovery.HCA{{Name: "mlx5_0", Ports: []discovery.Port{
|
|
{Num: 1, State: "ACTIVE", LinkLayer: discovery.LinkEthernet, RoCEv2GID: 3}}}}
|
|
spec := engines.ShardSpec{MgmtInterface: "eth0", HCAs: []string{"mlx5_0"}}
|
|
a.localizeSpec(&spec)
|
|
if spec.MgmtInterface != "ens3" {
|
|
t.Errorf("interface = %q", spec.MgmtInterface)
|
|
}
|
|
if spec.GIDIndex == nil || *spec.GIDIndex != 3 {
|
|
t.Errorf("GID index = %v", spec.GIDIndex)
|
|
}
|
|
|
|
ib := engines.ShardSpec{HCAs: []string{"mlx5_1"}}
|
|
a.inv.HCAs = append(a.inv.HCAs, discovery.HCA{Name: "mlx5_1", Ports: []discovery.Port{
|
|
{Num: 1, State: "ACTIVE", LinkLayer: discovery.LinkInfiniBand, RoCEv2GID: -1}}})
|
|
a.localizeSpec(&ib)
|
|
if ib.GIDIndex != nil {
|
|
t.Error("an InfiniBand shard was given a GID index")
|
|
}
|
|
}
|
|
|
|
func TestTheManagementInterfaceIsFoundNotAssumed(t *testing.T) {
|
|
// It was defaulted to eth0 before it could be looked for, so it never
|
|
// was: a node on ens3 or bond0 had NCCL bootstrap over nothing.
|
|
cfg := DefaultConfig()
|
|
cfg.Server = "http://127.0.0.1:7443"
|
|
cfg.NodeID = "n1"
|
|
cfg.MgmtAddr = "127.0.0.1"
|
|
if err := cfg.normalize(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.MgmtInterface == "" || cfg.MgmtInterface == "eth0" {
|
|
t.Errorf("interface = %q, want the loopback interface holding 127.0.0.1", cfg.MgmtInterface)
|
|
}
|
|
}
|
|
|
|
func TestEachFabricsSubnetManagersAreAskedOnce(t *testing.T) {
|
|
// Two adapters on one fabric see one master. Its managers are listed
|
|
// through one of them — the same list twice is a query too many — and
|
|
// every port says which master it sees.
|
|
oldSM, oldSMs := querySM, querySMs
|
|
t.Cleanup(func() { querySM, querySMs = oldSM, oldSMs })
|
|
querySM = func(string, int) (*discovery.SMInfo, error) {
|
|
return &discovery.SMInfo{GUID: "0x01", LID: 1, State: "SMINFO_MASTER"}, nil
|
|
}
|
|
lists := 0
|
|
querySMs = func(string, int) ([]discovery.SMRecord, error) {
|
|
lists++
|
|
return []discovery.SMRecord{{GUID: "0x01", State: "master"}, {GUID: "0x02", State: "standby"}}, nil
|
|
}
|
|
h := start(t, func(c *Config) {
|
|
c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0", "mlx5_1")
|
|
c.Fabrics = []inventory.FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}}}
|
|
})
|
|
hb := h.ag.heartbeat().GetHeartbeat()
|
|
withList := 0
|
|
for _, p := range hb.GetIbPorts() {
|
|
if p.GetSmGuid() != "0x01" {
|
|
t.Errorf("%s sees master %q", p.GetHca(), p.GetSmGuid())
|
|
}
|
|
if len(p.GetSubnetManagers()) > 0 {
|
|
withList++
|
|
}
|
|
}
|
|
if lists != 1 || withList != 1 {
|
|
t.Errorf("listed %d times, carried on %d ports; want once, on one", lists, withList)
|
|
}
|
|
}
|
|
|
|
func TestAFabricScanIsAskedForAndAnswered(t *testing.T) {
|
|
// One node per fabric maps it; the control plane asks and parses.
|
|
old := scanFabric
|
|
t.Cleanup(func() { scanFabric = old })
|
|
var asked string
|
|
scanFabric = func(_ context.Context, hca string, port int) (string, error) {
|
|
asked = fmt.Sprintf("%s/%d", hca, port)
|
|
return "Switch\t36 \"S-00000000000000a1\"\t\t# \"MF0;leaf-a:QM8700/U1\" enhanced port 0 lid 1 lmc 0\n", nil
|
|
}
|
|
h := start(t)
|
|
r, err := h.svc.ScanFabric(t.Context(), "n1", "mlx5_0", 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if asked != "mlx5_0/1" || !strings.Contains(r.GetOutput(), "leaf-a") || r.GetError() != "" {
|
|
t.Errorf("asked %q, got %+v", asked, r)
|
|
}
|
|
}
|