CI / test (push) Successful in 3m23s
CI's test job has failed intermittently — two of three runs on some days — while every run here passed, including six repetitions on one CPU under the race detector with eight packages at once. The difference is a shared, loaded Linux runner, and the tests most exposed to it are the ones that sleep a fixed time and then assert: - admission: idle admission bounded at 50ms of wall clock against a 1ms tick, and a queued request checked for after a 50ms sleep; - scrape: a 10ms scrape timeout, which on a slow runner reports the engine unreachable and then the violation a second time, in a test that counts one; - supervisor: log lines, three lines of output and a removed state file each checked after a fixed sleep, and an exited worker bounded at 1.5s; - agent: a heartbeat checked for after four intervals. Each now waits for its condition up to a deadline of seconds, and the two wall-clock bounds are set against what they exist to distinguish — the admission queue's tick raised to ten seconds, the exited worker bounded below the five-second grace it must not wait out — rather than against how fast this laptop is. Without the CI logs this cannot be proven to be the failure; these are the tests that fail when the machine is slow, and they no longer do. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
921 lines
29 KiB
Go
921 lines
29 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"
|
|
|
|
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
|
"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
|
|
|
|
// Waited for rather than slept through: four heartbeat intervals was not
|
|
// always enough on a loaded runner for one to have landed.
|
|
deadline := time.Now().Add(3 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if n, _ = h.inv.Get("n1"); n.LastSeen.After(first) {
|
|
return
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestABandwidthTestIsRunOnTheAdapterAskedFor(t *testing.T) {
|
|
oldRun, oldHave := runWriteBW, haveWriteBW
|
|
t.Cleanup(func() { runWriteBW, haveWriteBW = oldRun, oldHave })
|
|
haveWriteBW = func() bool { return true }
|
|
var got discovery.WriteBW
|
|
runWriteBW = func(_ context.Context, w discovery.WriteBW) (float64, string, error) {
|
|
got = w
|
|
return 388.2, " #bytes #iterations BW peak[Gb/sec] BW average[Gb/sec]\n", nil
|
|
}
|
|
h := start(t, func(c *Config) { c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0") })
|
|
|
|
r, err := h.svc.FabricTest(t.Context(), "n1", &llmgwv1.FabricTestRequest{
|
|
Role: "client", Hca: "mlx5_0", Port: 1, Peer: "10.0.0.2", TcpPort: 18515, Seconds: 3})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.GetGbps() != 388.2 || r.GetError() != "" {
|
|
t.Errorf("reply %+v", r)
|
|
}
|
|
if got.HCA != "mlx5_0" || got.Peer != "10.0.0.2" || got.TCPPort != 18515 || got.GIDIndex != -1 {
|
|
t.Errorf("ran %+v", got)
|
|
}
|
|
|
|
// An adapter the node does not have is said so, without running anything.
|
|
got = discovery.WriteBW{}
|
|
r, _ = h.svc.FabricTest(t.Context(), "n1", &llmgwv1.FabricTestRequest{Role: "server", Hca: "mlx5_9", Port: 1, Seconds: 1})
|
|
if !strings.Contains(r.GetError(), "no port mlx5_9/1") || got.HCA != "" {
|
|
t.Errorf("reply %+v, ran %+v", r, got)
|
|
}
|
|
}
|