Files
llmgw/internal/agent/trace_test.go
Ilker ManapandClaude Opus 5.5 e725adc2f0 Tracing beyond the router: reconcile passes, placement and a replica's start traced down to the node
A reconcile pass that does something is a trace, with placement (where it
went, or every candidate turned down), scale-up/down, drain, stop, eviction,
preemption, reaping and restarts beneath it. A replica's start is a span open
from placement until it serves; the start command carries its context in a
new ControlMessage.trace field, and the agent continues the trace through
the weights fetch, engine launch, rendezvous and readiness wait. Stops,
drains, adoption and engine installs are traced on the node, model jobs are
a trace with a span per step linked to the replica's start, and agent.yaml
takes the same tracing block as server.yaml. Off, every span is the no-op one
and commands carry nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:03:17 +02:00

136 lines
4.8 KiB
Go

package agent
import (
"testing"
"time"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs/obstest"
)
func TestAStartOnTheNodeIsInTheControlPlanesTrace(t *testing.T) {
rec := obstest.Record(t)
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = servingEngine(t)
})
// The control plane's side, as the reconciler sends it: the command
// dispatched under a span of its own, over the real stream.
ctx, sent := obs.Tracer("test").Start(t.Context(), "send start",
trace.WithSpanKind(trace.SpanKindProducer))
if err := h.svc.StartShard(ctx, shardCommand(t, "shard-a")); err != nil {
t.Fatal(err)
}
sent.End()
h.rec.await(t, 30*time.Second, func(s status) bool {
return s.shardID == "shard-a" && s.state == supervisor.Ready
})
// The node's part ends just after it reports the shard ready.
tid := sent.SpanContext().TraceID()
node := rec.Await(t, tid, "start shard", 5*time.Second)
if node.Parent().SpanID() != sent.SpanContext().SpanID() {
t.Fatal("the node's start is not beneath the control plane's command")
}
if node.SpanKind() != trace.SpanKindConsumer {
t.Errorf("the node's start is a %v span", node.SpanKind())
}
if a := obstest.Attrs(node); a["llmgw.node"] != "n1" || a["llmgw.replica"] != "rep-1" ||
a["llmgw.model"] != "test-model" || a["llmgw.engine"] != "vllm" {
t.Errorf("the node's start does not say what it started: %v", a)
}
if node.Status().Code == codes.Error {
t.Errorf("a start that served is %v", node.Status())
}
for _, name := range []string{"fetch weights", "launch engine", "wait for engine"} {
if !obstest.ChildOf(rec.One(t, tid, name), node) {
t.Errorf("%s is not beneath the node's start", name)
}
}
if a := obstest.Attrs(rec.One(t, tid, "fetch weights")); a["llmgw.weights.outcome"] != "local" {
t.Errorf("the weights do not say how they were found: %v", a)
}
if a := obstest.Attrs(rec.One(t, tid, "wait for engine")); a["llmgw.polls"] == "" || a["llmgw.polls"] == "0" {
t.Errorf("the readiness wait does not say how often it asked: %v", a)
}
// A stop joins the trace it was sent in, too.
sctx, stop := obs.Tracer("test").Start(t.Context(), "stop replica")
if err := h.svc.StopShard(sctx, "n1", "shard-a"); err != nil {
t.Fatal(err)
}
stop.End()
h.rec.await(t, 15*time.Second, func(s status) bool {
return s.shardID == "shard-a" && s.state == supervisor.Stopped
})
stid := stop.SpanContext().TraceID()
nodeStop := rec.Await(t, stid, "stop shard", 5*time.Second)
if !obstest.ChildOf(nodeStop, rec.One(t, stid, "stop replica")) {
t.Error("the node's stop is not beneath the control plane's")
}
if !obstest.ChildOf(rec.One(t, stid, "stop engine"), nodeStop) {
t.Error("stopping the engine is not beneath the node's stop")
}
}
func TestAnAdoptionIsTracedAndItsStopsAreInIt(t *testing.T) {
rec := obstest.Record(t)
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = fakeEngineScript(t, `sleep 60`)
})
if err := h.svc.StartShard(t.Context(), shardCommand(t, "shard-orphaned")); 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
})
// The control plane, restarted, claims nothing the node is running.
h.rec.markUnclaimed("shard-orphaned")
h.ln.drop()
h.rec.await(t, 20*time.Second, func(s status) bool {
return s.shardID == "shard-orphaned" && s.state == supervisor.Stopped
})
adopt := rec.Named("adopt")
if len(adopt) != 1 {
t.Fatalf("%d adoptions traced", len(adopt))
}
if a := obstest.Attrs(adopt[0]); a["llmgw.node"] != "n1" || a["llmgw.shards"] != "1" || a["llmgw.unclaimed"] != "1" {
t.Errorf("the adoption does not say what it found: %v", a)
}
stop := rec.Await(t, adopt[0].SpanContext().TraceID(), "stop shard", 5*time.Second)
if !obstest.ChildOf(stop, adopt[0]) {
t.Error("stopping the unclaimed shard is not beneath the adoption")
}
}
func TestAStartThatFailsOnTheNodeSaysWhyInTheTrace(t *testing.T) {
rec := obstest.Record(t)
h := start(t, func(c *Config) {
c.Engines.VLLM.Bin = fakeEngineScript(t, `echo "CUDA out of memory" >&2; exit 1`)
})
ctx, sent := obs.Tracer("test").Start(t.Context(), "send start")
if err := h.svc.StartShard(ctx, shardCommand(t, "shard-b")); err != nil {
t.Fatal(err)
}
sent.End()
h.rec.await(t, 30*time.Second, func(s status) bool {
return s.shardID == "shard-b" && s.state == supervisor.Failed
})
tid := sent.SpanContext().TraceID()
node := rec.Await(t, tid, "start shard", 5*time.Second)
if node.Status().Code != codes.Error {
t.Errorf("a start that failed is %v", node.Status())
}
if wait := rec.One(t, tid, "wait for engine"); wait.Status().Code != codes.Error {
t.Errorf("the readiness wait of an engine that exited is %v", wait.Status())
}
}