CI / test (push) Successful in 1m28s
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>
345 lines
9.5 KiB
Go
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
|
|
}
|
|
}
|