Files
llmgw/internal/router/admin.go
T
ilkerandClaude Opus 5 e1eb1d4149
CI / test (push) Failing after 50s
Phase 3: ctl, and make draining a rolling move
ctl talks to a management API on the admin listener, not the client-facing one.
The two have different audiences: one serves inference to whoever holds an API
key, the other lets a caller cordon nodes and read the cluster's shape.

- nodes surfaces unhealthy GPUs and why they are excluded. That is usually the
  answer when a node looks idle but nothing will schedule onto it, and it was
  invisible before.
- explain-placement prints every candidate considered and why it was rejected.
- replicas shows the shards a replica is made of, with per-shard errors.
- cordon, uncordon and drain are one endpoint with different targets, because
  the difference between cordon and drain is exactly what an operator needs to
  be sure of before running either.

Draining had to be fixed first to make ctl drain worth having. A drained replica
was stopped on the next pass, which cuts in-flight requests — the thing draining
exists to avoid. Now a node marked Draining has its replicas drained, the model
falls below its desired count, a replacement is placed elsewhere, and the
original is stopped only once the replacement is serving. A grace period bounds
it, because draining the only node that can host a model would otherwise never
finish and the node could never be taken out of service.

Verified against a two-node cluster of real binaries: draining moved a replica
to the surviving node and traffic kept returning 200 throughout.

ctl logs is deliberately absent. It needs a request/response pair the agent
protocol does not have, and the failure path already ships an engine log tail
with every status report — so the case that matters most is covered, and
reading a healthy shard's output on demand is not worth a protocol change yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 06:27:21 +02:00

129 lines
4.3 KiB
Go

package router
import (
"net/http"
"net/http/pprof"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/collectors"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
// AdminHandler returns the diagnostics surface: metrics, health and pprof.
//
// It is served on a separate listener so the client-facing port can be exposed
// without also exposing pprof (plan §9.1). The control plane's management API
// is mounted here too, by the caller.
func (r *Router) AdminHandler() (*http.ServeMux, error) {
reg := prometheus.NewRegistry()
reg.MustRegister(
collectors.NewGoCollector(),
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
)
for _, c := range r.m.collectors() {
if err := reg.Register(c); err != nil {
return nil, err
}
}
if err := reg.Register(newEndpointCollector(r)); err != nil {
return nil, err
}
mux := http.NewServeMux()
mux.Handle("GET /metrics", promhttp.HandlerFor(reg, promhttp.HandlerOpts{}))
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("GET /debug/pprof/profile", pprof.Profile)
mux.HandleFunc("GET /debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("GET /debug/pprof/trace", pprof.Trace)
return mux, nil
}
// endpointCollector reports live endpoint state at scrape time rather than
// mirroring it into gauges on every request; the registry already holds it.
type endpointCollector struct {
r *Router
inflight *prometheus.Desc
waiting *prometheus.Desc
running *prometheus.Desc
kv *prometheus.Desc
stale *prometheus.Desc
drain *prometheus.Desc
breaker *prometheus.Desc
queueDepth *prometheus.Desc
saturation *prometheus.Desc
}
func newEndpointCollector(r *Router) *endpointCollector {
lbl := []string{"replica", "pool"}
return &endpointCollector{
r: r,
inflight: prometheus.NewDesc("llmgw_endpoint_inflight", "Requests the router has dispatched and not yet finished.", lbl, nil),
waiting: prometheus.NewDesc("llmgw_endpoint_waiting", "Engine queue depth, as last scraped.", lbl, nil),
running: prometheus.NewDesc("llmgw_endpoint_running", "Requests running on the engine, as last scraped.", lbl, nil),
kv: prometheus.NewDesc("llmgw_endpoint_kv_cache_usage", "Engine KV cache occupancy, 0..1.", lbl, nil),
drain: prometheus.NewDesc("llmgw_endpoint_drain_rate", "Observed completions per second.", lbl, nil),
stale: prometheus.NewDesc("llmgw_endpoint_metrics_stale", "1 when the endpoint's metrics are stale.", lbl, nil),
breaker: prometheus.NewDesc("llmgw_endpoint_breaker_open", "1 when the endpoint's circuit breaker is open.", lbl, nil),
queueDepth: prometheus.NewDesc("llmgw_admission_queue_depth", "Requests waiting in each admission band.", []string{"band"}, nil),
saturation: prometheus.NewDesc("llmgw_pool_saturation", "Pool saturation per model; 1 is the healthy limit.", []string{"model"}, nil),
}
}
func (c *endpointCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.inflight
ch <- c.waiting
ch <- c.running
ch <- c.kv
ch <- c.drain
ch <- c.stale
ch <- c.breaker
ch <- c.queueDepth
ch <- c.saturation
}
func (c *endpointCollector) Collect(ch chan<- prometheus.Metric) {
for _, s := range c.r.reg.Snapshots("") {
pool := s.Pool
if pool == "" {
pool = "none"
}
g := func(d *prometheus.Desc, v float64) {
ch <- prometheus.MustNewConstMetric(d, prometheus.GaugeValue, v, s.ID, pool)
}
g(c.inflight, float64(s.Inflight))
g(c.waiting, float64(s.Stats.Waiting))
g(c.running, float64(s.Stats.Running))
g(c.kv, s.Stats.KVCacheUsage)
g(c.drain, s.DrainRate)
g(c.stale, boolToFloat(s.Stale))
g(c.breaker, boolToFloat(s.Breaker.String() == "open"))
}
if c.r.adm != nil {
for _, d := range c.r.adm.Depths() {
ch <- prometheus.MustNewConstMetric(c.queueDepth, prometheus.GaugeValue, float64(d.Depth), d.Band)
}
}
// Saturation is reported per model, which is the granularity admission
// decisions are made at.
for _, model := range c.r.servedModels() {
if sat, ok := c.r.saturation(model, 0); ok {
ch <- prometheus.MustNewConstMetric(c.saturation, prometheus.GaugeValue, sat, model)
}
}
}
func boolToFloat(b bool) float64 {
if b {
return 1
}
return 0
}