// 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) } // 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 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 } // 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: 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() }