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>
234 lines
7.5 KiB
Go
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"`
|
|
}
|