Files
llmgw/internal/control/reconciler/evict.go
T
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

131 lines
4.3 KiB
Go

package reconciler
import (
"context"
"fmt"
"sort"
"time"
"go.opentelemetry.io/otel/attribute"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
)
// evictionCandidate is a replica that could be stopped to make room.
type evictionCandidate struct {
ID string
Model string
Idle time.Duration
GPUs int
// Nodes is how many machines the replica spans. More than one is left
// alone — see makeRoomFor.
Nodes int
}
// makeRoomFor stops an idle replica so a wanted model can be placed.
//
// Without this, on-demand loading works right up to the moment the cluster is
// full and then stops meaning anything: the model somebody just asked for
// cannot be placed, and the cards are held by a model nobody has asked for in
// an hour. That is the exact situation the feature exists to resolve, and it is
// the only situation in which it matters.
//
// Deliberately conservative, on three counts.
//
// Only idle replicas of on-demand models. A model at min 1 is a promise that it
// stays up, and quietly breaking that to serve a different request would make
// every guarantee in the catalog conditional on load.
//
// Only single-node replicas. Evicting a multi-node replica frees several
// machines at once and takes tens of minutes to rebuild, which is a decision
// worth making deliberately rather than as a side effect of one request.
//
// One at a time. Freeing everything that qualifies would empty a cluster to
// satisfy a request that may only have needed one node, and the next pass can
// always free more.
func (r *Reconciler) makeRoomFor(ctx context.Context, m catalog.Model) bool {
candidates := r.evictable(m.Name)
if len(candidates) == 0 {
return false
}
// The longest idle first. It is the replica least likely to be wanted
// again, and the one whose KV cache is worth the least.
best := candidates[0]
r.log.Info("evicting an idle replica to make room",
"stopping", best.ID, "idle_model", best.Model,
"idle", best.Idle.Round(time.Second), "gpus", best.GPUs, "for", m.Name)
ctx, span := startSpan(ctx, "evict",
attribute.String("llmgw.for", m.Name),
attribute.String("llmgw.replica", best.ID),
attribute.String("llmgw.model", best.Model),
attribute.Float64("llmgw.idle_seconds", best.Idle.Seconds()),
attribute.Int("llmgw.gpus", best.GPUs),
attribute.Int("llmgw.candidates", len(candidates)))
defer span.End()
r.stopReplica(ctx, best.ID, "evicted, idle, to make room for "+m.Name)
return true
}
// evictable lists replicas that could be stopped for a model that needs room,
// longest idle first.
func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
cat := r.cat.Load()
now := r.now()
r.mu.Lock()
defer r.mu.Unlock()
var out []evictionCandidate
for _, rep := range r.replicas {
if rep.Model == wantedBy || !rep.State.Counts() {
continue
}
other, ok := cat.Resolve(rep.Model)
if !ok || !other.Scaling.OnDemand() {
continue
}
// Unknown is not idle. A replica the router has said nothing about —
// after a restart, that is all of them — must not be evicted on the
// strength of a fact nobody has reported.
if rep.LastUsedAt.IsZero() {
continue
}
idle := now.Sub(rep.LastUsedAt)
if idle < r.cfg.EvictionGrace {
continue
}
// A replica that has only just started is left alone however idle it
// looks. Two on-demand models on a cluster with room for one would
// otherwise take turns evicting each other, paying a model load each
// time — and a load is expensive enough that waiting is strictly
// better than winning.
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.cfg.MinLifetime {
continue
}
nodes := map[string]struct{}{}
gpus := 0
for _, sh := range rep.Shards {
nodes[sh.NodeID] = struct{}{}
gpus += len(sh.GPUIndices)
}
if len(nodes) > 1 {
continue
}
out = append(out, evictionCandidate{
ID: rep.ID, Model: rep.Model, Idle: idle, GPUs: gpus, Nodes: len(nodes),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Idle > out[j].Idle })
return out
}
// EvictionReason explains why room could not be made, for placement failures.
func (r *Reconciler) EvictionReason(model string) string {
if c := r.evictable(model); len(c) > 0 {
return fmt.Sprintf("%d idle replica(s) could be stopped to make room", len(c))
}
return "nothing idle enough to stop"
}