Files
llmgw/internal/router/endpoint/endpoint.go
T
Ilker ManapandClaude Opus 5 9d8cd5e284
CI / test (push) Successful in 1m28s
Track which replicas are actually being used, and summarise the fleet
Two things a cluster with many nodes and many GPUs needs that were missing,
and they are the same thing seen from two distances.

Idle tracking. The control plane knew what was placed and whether it was
healthy, never whether anyone wanted it — so on a fleet with more models
than cards there was no way to answer the first question anyone asks, which
is what can I stop. The router is the only thing that knows, so it now
reports it: a timestamp written in Acquire, where a request already meets an
endpoint under a lock already held, and collected once a tick as a map. That
shape is the point. Per-request notification would be a firehose carrying
one bit — the newest timestamp wins — and this costs the same on four
replicas as on four hundred.

It travels through an interface in the direction opposite EndpointSink, for
the same reason that one exists: the control plane decides what should run,
the router knows whether anyone wants it, and neither should need to be in
the other's process to say so.

Unknown is kept distinct from idle throughout. A router that has just
restarted knows nothing, and a zero timestamp would be the oldest idle
replica in every comparison — so one restart would nominate an entire fleet
for eviction. Absent from the map, "-" in the table, negative in the JSON,
and IdleFor returns a second value rather than a duration anything could
act on by mistake.

ctl status. A per-node listing is right for four nodes and wrong for two
hundred: how full am I, what is broken, what is holding cards nobody is
using are aggregates, and reading them off a long table is a chore, which
means it gets skipped. Capacity per GPU class with usage bars, free whole
nodes per fabric — the number that decides whether a multi-node replica can
land, since free cards on the wrong fabric are not capacity for a model that
spans nodes — replica and model counts, models that cannot be placed, and
what has gone unasked-for. Derived from the same state the tables show, so
it cannot disagree with them.

Unhealthy GPUs count as neither used nor free, because counting them free is
how a cluster looks like it has room it does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 00:52:34 +02:00

345 lines
9.5 KiB
Go

// Package endpoint tracks the upstream replicas the router can send to, along
// with their live state.
//
// In phase 1 the endpoint list comes from configuration. In phase 3 the control
// plane's EndpointSource (plan §6.4) will feed the same Registry; the scheduler
// and proxy must not notice the difference, which is why the API is shaped
// around wholesale list replacement from the start.
package endpoint
import (
"maps"
"slices"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/router/breaker"
)
// Info is a replica's static description.
type Info struct {
// ID uniquely identifies the replica and labels its metrics.
ID string
// URL is the engine's root address (e.g. http://gpu-a-01:8000), no trailing slash.
URL string
// Models lists the model names this replica serves.
Models []string
// Engine names the inference engine serving here, which decides what its
// metrics are called. Empty means vLLM's names.
Engine string
// Pool names the (fabric, gpu_class) pool. Used for observability and pool
// preference only; phase 1 may well have a single pool.
Pool string
// Weight is the capacity weight measured by ctl bench (§6.2.1). 0 means 1.
Weight float64
// Priority expresses pool preference; higher pools are tried first.
Priority int
}
// Serves reports whether the replica serves the given model.
func (i Info) Serves(model string) bool {
return slices.Contains(i.Models, model)
}
// Stats is the router-side view of the engine metric contract (plan §7.6).
type Stats struct {
// The three mandatory fields.
Running int
Waiting int
KVCacheUsage float64
// Optional.
BlockSize int
NumGPUBlocks int
// Raw counters, kept so derived rates can be computed.
PromptTokens float64
GenerationTokens float64
SuccessTotal float64
}
// Snapshot is a complete view of one endpoint at scheduling time.
// It is a value type so the scheduler can work without holding a lock.
type Snapshot struct {
Info
Stats Stats
// DrainRate is the observed completion rate in requests/sec, exponentially
// smoothed. This is AIBrix's load-balance signal: Running alone cannot tell
// that a replica is slow.
DrainRate float64
// Inflight counts requests the router has dispatched here that have not
// finished. It compensates for scrape lag (§7.4); without it, herding is
// unavoidable within a 2s scrape interval.
Inflight int
// RunningCapacity is the highest concurrent request count ever observed on
// this endpoint — in practice the engine's batch capacity, learned rather
// than configured. Without it, requests the router has dispatched but the
// engine has not yet reported are indistinguishable from queue depth, and
// during a scrape interval that makes a perfectly healthy engine look
// overloaded.
RunningCapacity int
// Stale means no successful scrape arrived within StaleAfter. Stale
// endpoints are not dropped, only scored down.
Stale bool
// Age is the time since the last successful scrape.
Age time.Duration
// Breaker is the circuit breaker's current state, read without side effects.
Breaker breaker.State
}
// Config holds Registry settings.
type Config struct {
// StaleAfter is how long an endpoint may go without a successful scrape
// before being considered stale.
StaleAfter time.Duration
// Breaker holds per-endpoint circuit breaker settings.
Breaker breaker.Config
}
// DefaultConfig returns sensible defaults.
func DefaultConfig() Config {
return Config{
StaleAfter: 10 * time.Second,
Breaker: breaker.DefaultConfig(),
}
}
// live is one endpoint's mutable state.
type live struct {
info Info
stats Stats
scrapedAt time.Time
lastSucc float64
drainRate float64
maxRunning int
inflight int
brk *breaker.Breaker
// lastUsed is when a request was last dispatched here.
//
// The fact nothing recorded. Whether a replica is earning the cards it
// holds is the first question on a fleet with more models than GPUs, and
// the control plane had no way to ask it: it knew what was placed and
// whether it was healthy, never whether anyone wanted it.
//
// Recorded here because Acquire is already the one place a request meets an
// endpoint, and already holds the lock. One timestamp per dispatch, and
// collection is one pass over the replicas rather than anything per
// request — which is what makes it affordable on a fleet of hundreds.
lastUsed time.Time
}
// Registry holds the live state of all endpoints. It is safe for concurrent use.
type Registry struct {
cfg Config
now func() time.Time
mu sync.RWMutex
m map[string]*live
}
// NewRegistry creates an empty registry.
func NewRegistry(cfg Config) *Registry {
if cfg.StaleAfter <= 0 {
cfg.StaleAfter = 10 * time.Second
}
return &Registry{cfg: cfg, now: time.Now, m: make(map[string]*live)}
}
// SetClock replaces the time source, for tests.
func (r *Registry) SetClock(now func() time.Time) {
r.mu.Lock()
r.now = now
r.mu.Unlock()
}
// Replace reconciles the endpoint list with the given set. Endpoints that
// remain keep their live state, new ones get a breaker, removed ones are dropped.
func (r *Registry) Replace(infos []Info) {
r.mu.Lock()
defer r.mu.Unlock()
seen := make(map[string]struct{}, len(infos))
for _, in := range infos {
seen[in.ID] = struct{}{}
if l, ok := r.m[in.ID]; ok {
l.info = in
continue
}
r.m[in.ID] = &live{info: in, brk: breaker.New(r.cfg.Breaker)}
}
for id := range r.m {
if _, ok := seen[id]; !ok {
delete(r.m, id)
}
}
}
// IDs returns the registered endpoint IDs in sorted order.
func (r *Registry) IDs() []string {
r.mu.RLock()
defer r.mu.RUnlock()
return slices.Sorted(maps.Keys(r.m))
}
// Infos returns the registered endpoint descriptions, sorted by ID.
func (r *Registry) Infos() []Info {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]Info, 0, len(r.m))
for _, l := range r.m {
out = append(out, l.info)
}
slices.SortFunc(out, func(a, b Info) int { return strcmp(a.ID, b.ID) })
return out
}
// Observe records a successful scrape and updates the drain rate.
func (r *Registry) Observe(id string, s Stats) {
r.mu.Lock()
defer r.mu.Unlock()
l, ok := r.m[id]
if !ok {
return
}
now := r.now()
if !l.scrapedAt.IsZero() {
dt := now.Sub(l.scrapedAt).Seconds()
// A counter going backwards means the engine restarted; skip the derivative.
if dt > 0 && s.SuccessTotal >= l.lastSucc {
rate := (s.SuccessTotal - l.lastSucc) / dt
const alpha = 0.3 // exponential smoothing factor
l.drainRate = alpha*rate + (1-alpha)*l.drainRate
} else if s.SuccessTotal < l.lastSucc {
l.drainRate = 0
}
}
l.stats = s
l.lastSucc = s.SuccessTotal
l.scrapedAt = now
l.maxRunning = max(l.maxRunning, s.Running)
}
// Snapshots returns a view of every endpoint serving the given model, or of all
// endpoints when model is empty. Breaker state is read, never consumed.
func (r *Registry) Snapshots(model string) []Snapshot {
r.mu.RLock()
defer r.mu.RUnlock()
now := r.now()
out := make([]Snapshot, 0, len(r.m))
for _, l := range r.m {
if model != "" && !l.info.Serves(model) {
continue
}
var age time.Duration
stale := true
if !l.scrapedAt.IsZero() {
age = now.Sub(l.scrapedAt)
stale = age > r.cfg.StaleAfter
}
out = append(out, Snapshot{
Info: l.info,
Stats: l.stats,
DrainRate: l.drainRate,
Inflight: l.inflight,
RunningCapacity: l.maxRunning,
Stale: stale,
Age: age,
Breaker: l.brk.State(),
})
}
slices.SortFunc(out, func(a, b Snapshot) int { return strcmp(a.ID, b.ID) })
return out
}
// Acquire asks permission to send a request to an endpoint.
//
// It returns (nil, false) if the circuit breaker refuses. On success the
// inflight counter is incremented and the returned function must be called to
// report the outcome: release(err == nil).
func (r *Registry) Acquire(id string) (release func(ok bool), granted bool) {
r.mu.Lock()
l, exists := r.m[id]
r.mu.Unlock()
if !exists {
return nil, false
}
if !l.brk.Allow() {
return nil, false
}
r.mu.Lock()
l.inflight++
l.lastUsed = r.now()
r.mu.Unlock()
var once sync.Once
return func(ok bool) {
once.Do(func() {
r.mu.Lock()
l.inflight--
r.mu.Unlock()
if ok {
l.brk.Success()
} else {
l.brk.Failure()
}
})
}, true
}
// LastUsed reports when each endpoint last had a request dispatched to it.
//
// Endpoints that have never been used are absent rather than zero-valued: "not
// since this router started" and "not for an hour" are different facts, and a
// zero time would quietly become the oldest idle replica in any comparison —
// so a router restart would nominate everything for eviction at once.
func (r *Registry) LastUsed() map[string]time.Time {
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[string]time.Time, len(r.m))
for id, l := range r.m {
if l.lastUsed.IsZero() {
continue
}
out[id] = l.lastUsed
}
return out
}
// ScrapeFailed reports a failed metrics scrape.
//
// A scrape failure is not a request failure, so it does not touch the breaker:
// the endpoint may be serving fine while only its metrics endpoint is down.
// It will go stale on its own and score lower (§7.6).
func (r *Registry) ScrapeFailed(string) {}
// Inflight returns an endpoint's current inflight count, for tests and metrics.
func (r *Registry) Inflight(id string) int {
r.mu.RLock()
defer r.mu.RUnlock()
if l, ok := r.m[id]; ok {
return l.inflight
}
return 0
}
func strcmp(a, b string) int {
switch {
case a < b:
return -1
case a > b:
return 1
default:
return 0
}
}