Files
llmgw/internal/control/adminapi/summary.go
T
Ilker ManapandClaude Opus 5.5 37a80a9b44
Release / build (push) Successful in 1m3s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m34s
Park a model that fails every time it starts, and say so everywhere
On the live cluster kev-0.5b — an architecture the nodes' llama.cpp did not
know — was started 155 times in eight hours, failing the same way each time,
and phi-4, which fitted on no node, logged "no placement" 250 times in two
days. Neither showed up anywhere an operator looked: the backoff never gave
up, no metric said a model was failing or unplaceable, and the one alert for
a model with nothing serving could not fire, because its series was published
only while a replica was ready.

After five failures in a row a model is parked: not placed again until its
definition changes — a new source or profile — or `llmgw ctl retry` is run.
Status, the model list and the admin API show it with its last error. The
placement warning is logged when its reason changes or every ten minutes.

A request for a parked or unplaceable model is answered at once with why,
rather than "loading; retry shortly", which a client retries forever. Each
request still clears the previous placement result and triggers a fresh
attempt, so a model that fits once room is made is not refused on a stale
answer; the router waits for that attempt, not for its full activation
window.

Metrics now cover model health (desired, ready at 0 for what should run,
consecutive start failures, parked, unplaceable), engine installs per node,
leadership, and certificate expiry read from disk at each scrape, with alerts
for each. `ctl status` counts a model against what it should have now, so an
on-demand model that was asked for and fitted nowhere no longer reads as
satisfied.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 01:21:57 +02:00

234 lines
7.5 KiB
Go

package adminapi
import (
"sort"
"time"
)
// Summary is the whole fleet in one answer.
//
// A per-node listing is the right tool for four nodes and the wrong one for two
// hundred: the questions that matter at that size — how full am I, what is
// broken, what is holding cards it is not using — are aggregates, and reading
// them off a long table is a chore that gets skipped. Everything here is
// derived from the same state the tables show, so it can never disagree with
// them; it is a different question asked of the same facts.
type Summary struct {
Nodes NodeTotals `json:"nodes"`
GPUs []ClassUse `json:"gpus"`
// Fabrics matter because a multi-node replica may never span two of them,
// so free capacity in the wrong fabric is not capacity for the model that
// needs it.
Fabrics []FabricUse `json:"fabrics,omitempty"`
Replicas ReplicaTotal `json:"replicas"`
Models ModelTotals `json:"models"`
// Unplaceable names models the scheduler currently cannot satisfy. This is
// the line that turns "something is wrong" into a place to look.
Unplaceable []string `json:"unplaceable,omitempty"`
// Parked are models that failed every time they started and are no
// longer retried, with the last error.
Parked []ParkedModel `json:"parked,omitempty"`
// IdleReplicas are serving replicas nothing has asked for in a while, which
// on a fleet with more models than GPUs is the first place to find room.
IdleReplicas []IdleReplica `json:"idle_replicas,omitempty"`
}
// NodeTotals counts nodes by the states that need different responses.
type NodeTotals struct {
Total int `json:"total"`
Ready int `json:"ready"`
Cordoned int `json:"cordoned"`
Draining int `json:"draining"`
Unreachable int `json:"unreachable"`
}
// ClassUse is how much of one kind of card is in use.
type ClassUse struct {
Class string `json:"class"`
Total int `json:"total"`
Used int `json:"used"`
// Unhealthy GPUs are neither used nor available, and counting them as free
// is how a cluster looks like it has room it does not have.
Unhealthy int `json:"unhealthy"`
}
// Free is what can still be allocated.
func (c ClassUse) Free() int { return c.Total - c.Used - c.Unhealthy }
// FabricUse is capacity within one fabric.
type FabricUse struct {
Fabric string `json:"fabric"`
Nodes int `json:"nodes"`
// ModelsPresent is how many nodes here hold weights at all, and Models how
// many distinct models they hold between them.
//
// Per fabric because that is the unit at which the answer is actionable.
// Fabrics are not connected to one another, so weights in one are not
// weights in another: a model seeded into a single fabric leaves the rest
// of the cluster unable to serve it without first moving tens of gigabytes
// over the management network, and nothing said so.
NodesWithWeights int `json:"nodes_with_weights"`
Models int `json:"models_present"`
// LargestFreeBlock is the most nodes on this fabric that are entirely free,
// which is the number that decides whether a multi-node replica can land.
LargestFreeBlock int `json:"largest_free_block"`
}
// ReplicaTotal counts replicas by state.
type ReplicaTotal struct {
Ready int `json:"ready"`
Starting int `json:"starting"`
Draining int `json:"draining"`
Failed int `json:"failed"`
}
// ModelTotals counts models by whether they have what they asked for.
type ModelTotals struct {
Total int `json:"total"`
Satisfied int `json:"satisfied"`
Short int `json:"short"`
}
// IdleReplica is a replica holding hardware nothing is asking for.
type IdleReplica struct {
ID string `json:"id"`
Model string `json:"model"`
IdleSec int64 `json:"idle_seconds"`
GPUs int `json:"gpus"`
}
// idleThreshold is how long a replica must go unasked-for to be worth naming.
//
// Minutes rather than seconds, because a replica that flaps in and out of this
// list is noise, and because unloading one throws away its KV cache — the thing
// prefix routing spent its effort filling.
const idleThreshold = 10 * time.Minute
// Summary builds the fleet summary.
func (a *API) Summary() Summary {
var s Summary
nodes := a.Nodes()
byClass := map[string]*ClassUse{}
s.Nodes.Total = len(nodes)
for _, n := range nodes {
switch n.State {
case "ready":
s.Nodes.Ready++
case "cordoned":
s.Nodes.Cordoned++
case "draining":
s.Nodes.Draining++
default:
s.Nodes.Unreachable++
}
for _, c := range n.Classes {
if byClass[c] == nil {
byClass[c] = &ClassUse{Class: c}
}
}
// A node reports one class in practice; attribute its cards to it.
if len(n.Classes) == 1 {
u := byClass[n.Classes[0]]
u.Total += n.GPUTotal
u.Unhealthy += len(n.Unhealthy)
u.Used += n.GPUTotal - n.GPUFree - len(n.Unhealthy)
}
}
for _, u := range byClass {
s.GPUs = append(s.GPUs, *u)
}
sort.Slice(s.GPUs, func(i, j int) bool { return s.GPUs[i].Class < s.GPUs[j].Class })
byFabric := map[string]*FabricUse{}
for _, n := range nodes {
for _, f := range n.Fabrics {
if byFabric[f] == nil {
byFabric[f] = &FabricUse{Fabric: f}
}
byFabric[f].Nodes++
if len(n.Models) > 0 {
byFabric[f].NodesWithWeights++
}
// Entirely free, which is what a multi-node replica needs: a node
// with one card in use cannot contribute a full-width shard.
if n.State == "ready" && n.GPUFree == n.GPUTotal && n.GPUTotal > 0 {
byFabric[f].LargestFreeBlock++
}
}
}
// Distinct models per fabric, counted across its nodes: one copy on a
// shared filer and twenty local copies are the same answer to "can this
// fabric serve it".
perFabricModels := map[string]map[string]struct{}{}
for _, n := range nodes {
for _, f := range n.Fabrics {
if perFabricModels[f] == nil {
perFabricModels[f] = map[string]struct{}{}
}
for _, m := range n.Models {
perFabricModels[f][m] = struct{}{}
}
}
}
for name, f := range byFabric {
f.Models = len(perFabricModels[name])
s.Fabrics = append(s.Fabrics, *f)
}
sort.Slice(s.Fabrics, func(i, j int) bool { return s.Fabrics[i].Fabric < s.Fabrics[j].Fabric })
for _, r := range a.Replicas("") {
switch r.State {
case "ready":
s.Replicas.Ready++
case "draining":
s.Replicas.Draining++
case "failed":
s.Replicas.Failed++
default:
s.Replicas.Starting++
}
if r.State == "ready" && r.IdleSec >= int64(idleThreshold.Seconds()) {
gpus := 0
for _, sh := range r.Shards {
gpus += len(sh.GPUs)
}
s.IdleReplicas = append(s.IdleReplicas, IdleReplica{
ID: r.ID, Model: r.Model, IdleSec: r.IdleSec, GPUs: gpus,
})
}
}
sort.Slice(s.IdleReplicas, func(i, j int) bool {
return s.IdleReplicas[i].IdleSec > s.IdleReplicas[j].IdleSec
})
for _, m := range a.Models() {
s.Models.Total++
if m.Parked {
s.Parked = append(s.Parked, ParkedModel{Model: m.Name, Failures: m.Failures, Reason: m.Reason})
}
// Against what it should have now, not its minimum: an on-demand
// model's minimum is 0, so one that was asked for and fitted nowhere
// used to count as satisfied.
if m.Ready >= m.Desired && !m.Parked {
s.Models.Satisfied++
continue
}
s.Models.Short++
// Short of replicas and unable to place more is the actionable case;
// short and still starting is just a cluster doing its job.
if m.Unplaceable || (m.Total <= m.Ready && !m.Parked) {
s.Unplaceable = append(s.Unplaceable, m.Name)
}
}
sort.Strings(s.Unplaceable)
return s
}
// ParkedModel is a model no longer retried, and why.
type ParkedModel struct {
Model string `json:"model"`
Failures int `json:"failures"`
Reason string `json:"reason"`
}