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>
222 lines
6.3 KiB
Go
222 lines
6.3 KiB
Go
// Package control wires the control plane together: inventory, catalog,
|
|
// placement and reconciliation, and publishes the result to the router.
|
|
package control
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
|
|
)
|
|
|
|
// EndpointSink receives the endpoint list. The router implements it.
|
|
//
|
|
// It is an interface so the control plane does not depend on the router: when
|
|
// the two split into separate processes, this becomes a watch stream and
|
|
// nothing above it changes (plan §3.1).
|
|
type EndpointSink interface {
|
|
SetEndpoints(ctx context.Context, infos []endpoint.Info)
|
|
}
|
|
|
|
// UsageSource reports when each endpoint was last sent a request.
|
|
//
|
|
// The return path of EndpointSink, and an interface for the same reason: the
|
|
// control plane decides what should run, the router is the only thing that
|
|
// knows whether anyone wants it, and neither should have to be in the same
|
|
// process as the other to say so.
|
|
//
|
|
// Deliberately a snapshot rather than an event stream. On a fleet with hundreds
|
|
// of replicas, per-request notification is a firehose carrying one bit of
|
|
// information — the newest timestamp wins — and a map read once a tick carries
|
|
// exactly the same answer for a fixed cost.
|
|
type UsageSource interface {
|
|
LastUsed() map[string]time.Time
|
|
}
|
|
|
|
// Config tunes the control plane.
|
|
type Config struct {
|
|
Reconciler reconciler.Config
|
|
// PublishInterval is how often the endpoint list is republished even when
|
|
// nothing appears to have changed. Events drive the common case; this is
|
|
// the backstop.
|
|
PublishInterval time.Duration
|
|
}
|
|
|
|
// DefaultConfig returns sensible defaults.
|
|
func DefaultConfig() Config {
|
|
return Config{
|
|
Reconciler: reconciler.DefaultConfig(),
|
|
PublishInterval: time.Second,
|
|
}
|
|
}
|
|
|
|
// Controller owns the control plane's state.
|
|
type Controller struct {
|
|
cfg Config
|
|
log *slog.Logger
|
|
inv *inventory.Store
|
|
cat *catalog.Catalog
|
|
rec *reconciler.Reconciler
|
|
sink EndpointSink
|
|
usage UsageSource
|
|
|
|
mu sync.Mutex
|
|
last string // fingerprint of the last published list
|
|
|
|
stop chan struct{}
|
|
done chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
// New builds a controller.
|
|
func New(cfg Config, cat *catalog.Catalog, disp reconciler.Dispatcher, sink EndpointSink, log *slog.Logger) *Controller {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
if cfg.PublishInterval <= 0 {
|
|
cfg.PublishInterval = time.Second
|
|
}
|
|
inv := inventory.NewStore()
|
|
return &Controller{
|
|
cfg: cfg,
|
|
log: log,
|
|
inv: inv,
|
|
cat: cat,
|
|
rec: reconciler.New(cfg.Reconciler, cat, inv, disp, log),
|
|
sink: sink,
|
|
stop: make(chan struct{}),
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Inventory exposes the node inventory, which the agent service feeds.
|
|
func (c *Controller) Inventory() *inventory.Store { return c.inv }
|
|
|
|
// SetUsageSource supplies where request activity is read from.
|
|
//
|
|
// A setter rather than a constructor argument because a control plane without
|
|
// one is fully functional — it simply cannot say whether a replica is idle,
|
|
// which is how it behaved until now.
|
|
func (c *Controller) SetUsageSource(u UsageSource) { c.usage = u }
|
|
|
|
// collectUsage stamps replicas with when they were last asked for anything.
|
|
//
|
|
// Once a tick, over the replicas rather than the requests. On a fleet of
|
|
// hundreds this is a map lookup each; there is no version of it that grows with
|
|
// traffic.
|
|
func (c *Controller) collectUsage() {
|
|
if c.usage == nil {
|
|
return
|
|
}
|
|
c.rec.RecordUsage(c.usage.LastUsed())
|
|
}
|
|
|
|
// Reconciler exposes the reconciliation loop, for status reports and ctl.
|
|
func (c *Controller) Reconciler() *reconciler.Reconciler { return c.rec }
|
|
|
|
// Catalog exposes the model catalog.
|
|
func (c *Controller) Catalog() *catalog.Catalog { return c.cat }
|
|
|
|
// Start runs the reconciler and begins publishing endpoints.
|
|
func (c *Controller) Start(ctx context.Context) {
|
|
c.rec.Start(ctx)
|
|
|
|
go func() {
|
|
defer close(c.done)
|
|
t := time.NewTicker(c.cfg.PublishInterval)
|
|
defer t.Stop()
|
|
|
|
c.Publish(ctx)
|
|
for {
|
|
select {
|
|
case <-c.stop:
|
|
return
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
// Before publishing, so a replica's idleness is as fresh as
|
|
// the endpoint list it travels beside.
|
|
c.collectUsage()
|
|
c.Publish(ctx)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Close stops the control plane.
|
|
func (c *Controller) Close() {
|
|
c.once.Do(func() {
|
|
close(c.stop)
|
|
<-c.done
|
|
c.rec.Close()
|
|
})
|
|
}
|
|
|
|
// Publish pushes the current Ready replicas to the sink.
|
|
//
|
|
// It is exported so tests can drive it deterministically instead of racing a
|
|
// ticker, and it skips the push when nothing changed: replacing the list is
|
|
// cheap but resynchronizes the metric pollers, and doing that every second for
|
|
// no reason adds churn to every engine's /metrics.
|
|
func (c *Controller) Publish(ctx context.Context) {
|
|
infos := c.endpoints()
|
|
|
|
fp := fingerprint(infos)
|
|
c.mu.Lock()
|
|
unchanged := fp == c.last
|
|
c.last = fp
|
|
c.mu.Unlock()
|
|
if unchanged {
|
|
return
|
|
}
|
|
|
|
c.log.Info("publishing endpoints", "count", len(infos))
|
|
c.sink.SetEndpoints(ctx, infos)
|
|
}
|
|
|
|
// endpoints converts Ready replicas into the router's view.
|
|
func (c *Controller) endpoints() []endpoint.Info {
|
|
ready := c.rec.ReadyReplicas()
|
|
out := make([]endpoint.Info, 0, len(ready))
|
|
|
|
for _, rep := range ready {
|
|
m, ok := c.cat.Resolve(rep.Model)
|
|
if !ok {
|
|
// The model left the catalog while a replica was still running.
|
|
// The reconciler will tear it down; until then, do not route to it.
|
|
continue
|
|
}
|
|
// Clients may address a model by any of its names, and the router
|
|
// matches endpoints by the resolved name plus aliases.
|
|
names := append([]string{m.Name}, m.Aliases...)
|
|
|
|
prof, _ := m.ProfileByName(rep.Profile)
|
|
out = append(out, endpoint.Info{
|
|
ID: rep.ID,
|
|
URL: strings.TrimRight(rep.Endpoint, "/"),
|
|
Models: names,
|
|
Engine: prof.Engine,
|
|
Pool: rep.Pool,
|
|
Weight: rep.Weight,
|
|
Priority: prof.Priority,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fingerprint summarizes a list so an unchanged one can be skipped.
|
|
func fingerprint(infos []endpoint.Info) string {
|
|
var b strings.Builder
|
|
for _, i := range infos {
|
|
fmt.Fprintf(&b, "%s|%s|%s|%s|%v|%g|%d;", i.ID, i.URL, i.Engine, i.Pool, i.Models, i.Weight, i.Priority)
|
|
}
|
|
return b.String()
|
|
}
|