Files
llmgw/internal/control/modelserve/updates.go
T
Ilker ManapandClaude Opus 5.5 d41104a452 Merge C21 quality suites and gating
Conflicts: docs.go (manual, OpenAPI and suites all kept), modelserve Job
(trace and eval both kept). The eval routes are added to the OpenAPI route
table, update and promote documented with their optional body, and the
document regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:08:33 +02:00

794 lines
26 KiB
Go

package modelserve
import (
"context"
"errors"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"sync/atomic"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"github.com/prometheus/client_golang/prometheus"
)
// Updates: a repository publishes new weights under the same name — a fixed
// chat template, a better quantisation — and nothing told anyone. A model
// served from the registry now remembers where it came from; this asks the
// registry, every so often, whether the files it runs have changed there,
// and moves a model to them on request: fetched into the mirror beside the
// old ones, checked, swapped in, its replicas replaced one at a time, and
// the old files kept so it can go back.
// Models is the catalog as an update changes it.
type Models interface {
Declared(name string) bool
SetSource(ctx context.Context, name, source string, origin *catalog.Origin, by string) (catalog.Model, error)
SetOrigin(ctx context.Context, name string, origin *catalog.Origin) error
// For a canary: the candidate added beside the model and taken out
// again, and the share of requests it is given.
Add(ctx context.Context, m catalog.Model, by string) error
Remove(ctx context.Context, name string) error
SetCanary(ctx context.Context, name string, c *catalog.Canary, by string) (catalog.Model, error)
}
// UpdateInfo is what the registry has for a model against what it runs.
type UpdateInfo struct {
Model string `json:"model"`
Repo string `json:"repo,omitempty"`
// Current is the commit the model runs, Latest the repository's now.
Current string `json:"current,omitempty"`
Latest string `json:"latest,omitempty"`
// Changed are the model's files whose weights differ at Latest, and
// Missing those Latest no longer has at all.
Changed []string `json:"changed,omitempty"`
Missing []string `json:"missing,omitempty"`
// Bytes is what an update downloads.
Bytes int64 `json:"bytes,omitempty"`
// Available is true when the model can be moved to Latest.
Available bool `json:"available"`
// Previous is the commit an update replaced, which it can go back to.
Previous string `json:"previous,omitempty"`
CheckedAt time.Time `json:"checked_at"`
Err string `json:"error,omitempty"`
}
// place is where a model's weights sit on the mirror.
type place struct {
model, version string
files []mirror.Status
}
// placeOf finds a source on the mirror: a file, or a directory of shards.
func placeOf(source string, list mirror.ModelsResponse) (place, bool) {
base := strings.TrimSuffix(list.Base, "/") + "/"
if list.Base == "" || !strings.HasPrefix(source, base) {
return place{}, false
}
parts := strings.SplitN(strings.TrimPrefix(source, base), "/", 3)
if len(parts) < 2 {
return place{}, false
}
p := place{model: parts[0], version: parts[1]}
file := ""
if len(parts) == 3 {
file = parts[2]
}
for _, st := range list.Models {
if st.Model == p.model && st.Version == p.version && (file == "" || st.File == file) {
p.files = append(p.files, st)
}
}
return p, len(p.files) > 0
}
// parseResolve reads a registry download URL: base/owner/name/resolve/rev/path.
func parseResolve(u string) (repo, rev, path string, ok bool) {
pu, err := url.Parse(u)
if err != nil {
return "", "", "", false
}
i := strings.Index(pu.Path, "/resolve/")
if i < 0 {
return "", "", "", false
}
repo = strings.Trim(pu.Path[:i], "/")
if strings.Count(repo, "/") != 1 {
return "", "", "", false
}
rev, path, ok = strings.Cut(pu.Path[i+len("/resolve/"):], "/")
return repo, rev, path, ok && rev != "" && path != ""
}
// inferOrigin works a model's origin out from what the mirror recorded of
// where it fetched each file, for a model served before origins were kept.
func inferOrigin(p place) (*catalog.Origin, bool) {
var o catalog.Origin
for _, st := range p.files {
repo, rev, path, ok := parseResolve(st.Source)
if !ok || (o.Repo != "" && (repo != o.Repo || rev != o.Revision)) {
return nil, false
}
o.Repo, o.Revision = repo, rev
o.Files = append(o.Files, catalog.OriginFile{Path: path, SHA256: st.SHA256, Bytes: st.BytesDone})
}
return &o, o.Repo != ""
}
func originFiles(set hf.FileSet) []catalog.OriginFile {
out := make([]catalog.OriginFile, 0, len(set.Files))
for _, f := range set.Files {
out = append(out, catalog.OriginFile{Path: f.Path, SHA256: f.SHA256, Bytes: f.Bytes})
}
return out
}
// Finder is a registry that can be searched, and read at a past commit: what
// finding the origin of weights fetched before origins were kept needs.
type Finder interface {
Explore(ctx context.Context, q hf.Query) ([]hf.Model, error)
RepoAt(ctx context.Context, id, rev string) (hf.Repo, error)
}
// originOf is a model's origin: recorded; or worked out from where the
// mirror fetched its files; or found on the registry by the files' names,
// the commit the mirror's directory is named after, and their sizes. What is
// worked out is recorded, so it is worked out once.
func (s *Server) originOf(ctx context.Context, m catalog.Model, list mirror.ModelsResponse) *catalog.Origin {
if m.Origin != nil && m.Origin.Repo != "" {
return m.Origin
}
s.mu.Lock()
o, known := s.origins[m.Name]
s.mu.Unlock()
if known {
return o
}
p, ok := placeOf(m.Source, list)
if !ok {
return nil
}
o, ok = inferOrigin(p)
if !ok {
o = s.findOrigin(ctx, p)
}
s.remember(ctx, m.Name, o)
return o
}
// remember keeps an origin: in the catalog for a model added at runtime, and
// here for one from configuration, whose definition is not this one's to
// change. A nil origin is remembered too, so a model nobody can trace is not
// searched for at every check.
func (s *Server) remember(ctx context.Context, name string, o *catalog.Origin) {
s.mu.Lock()
s.origins[name] = o
s.mu.Unlock()
if o == nil || s.deps.Models == nil || s.deps.Models.Declared(name) {
return
}
if err := s.deps.Models.SetOrigin(ctx, name, o); err != nil {
s.deps.Log.Debug("could not record a model's origin", "model", name, "err", err)
}
}
var hexRev = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
// findOrigin looks for the repository a mirror's files came from: one that
// holds a file of the same name, and has the commit the mirror's directory
// is named after with every file at the same size. Searched by the mirror's
// name for the model and the file's own; among the repositories that match,
// the most downloaded, which is the original rather than a copy of it.
func (s *Server) findOrigin(ctx context.Context, p place) *catalog.Origin {
f, ok := s.deps.Registry.(Finder)
if !ok || !hexRev.MatchString(p.version) {
return nil
}
file := p.files[0].File
stem := strings.TrimSuffix(file, ".gguf")
if i := strings.LastIndexAny(stem, "-."); i > 0 {
stem = stem[:i] // the quantisation
}
tried := map[string]bool{}
for _, q := range []string{p.model, stem} {
found, err := f.Explore(ctx, hf.Query{Search: q, GGUF: true, Sort: hf.SortDownloads, Limit: 100, WithFiles: true})
if err != nil {
s.deps.Log.Debug("searching the registry for a model's origin", "model", p.model, "err", err)
continue
}
for _, c := range found {
if tried[c.ID] || !slices.ContainsFunc(c.Siblings, func(sb struct {
RFilename string `json:"rfilename"`
}) bool {
return mirror.LastPathElement(sb.RFilename) == file
}) {
continue
}
tried[c.ID] = true
if o := s.verifyOrigin(ctx, f, c.ID, p); o != nil {
s.deps.Log.Info("found where a model's weights came from", "model", p.model, "repo", c.ID,
"revision", catalog.ShortRevision(o.Revision))
return o
}
}
}
return nil
}
// verifyOrigin reads a repository at the mirror's commit and says whether
// every file the mirror holds is there at the same size.
func (s *Server) verifyOrigin(ctx context.Context, f Finder, id string, p place) *catalog.Origin {
repo, err := f.RepoAt(ctx, id, p.version)
if err != nil || !strings.HasPrefix(repo.Revision, p.version) {
return nil
}
o := &catalog.Origin{Repo: repo.ID, Revision: repo.Revision}
for _, st := range p.files {
i := slices.IndexFunc(repo.Files, func(g hf.File) bool {
return mirror.LastPathElement(g.Path) == st.File && g.Bytes == st.BytesDone
})
if i < 0 {
return nil
}
g := repo.Files[i]
o.Files = append(o.Files, catalog.OriginFile{Path: g.Path, SHA256: g.SHA256, Bytes: g.Bytes})
}
return o
}
// SetOriginOf records, by hand, the repository a model's weights came from;
// the commit is the one the mirror's directory is named after, and the files
// must be there at the sizes the mirror holds.
func (s *Server) SetOriginOf(ctx context.Context, name, repoID string) (*catalog.Origin, error) {
m, err := s.current(name)
if err != nil {
return nil, err
}
list, err := s.deps.Mirror.List(ctx)
if err != nil {
return nil, fmt.Errorf("the mirror: %w", err)
}
p, ok := placeOf(m.Source, list)
if !ok {
return nil, fmt.Errorf("%s does not run from the mirror", name)
}
f, ok := s.deps.Registry.(Finder)
if !ok {
return nil, errors.New("this registry cannot be read at a past commit")
}
o := s.verifyOrigin(ctx, f, repoID, p)
if o == nil {
return nil, fmt.Errorf("%s has no commit %s holding %s at the size the mirror has", repoID, p.version,
p.files[0].File)
}
s.remember(ctx, name, o)
s.checkOne(ctx, m, o)
return o, nil
}
// compare says what the registry's repository has against an origin.
func compare(m catalog.Model, o *catalog.Origin, repo hf.Repo) UpdateInfo {
u := UpdateInfo{Model: m.Name, Repo: o.Repo, Current: o.Revision, Latest: repo.Revision, CheckedAt: time.Now()}
if o.Previous != nil {
u.Previous = o.Previous.Revision
}
if sameRevision(repo.Revision, o.Revision) {
return u
}
for _, f := range o.Files {
i := slices.IndexFunc(repo.Files, func(g hf.File) bool { return g.Path == f.Path })
if i < 0 {
u.Missing = append(u.Missing, f.Path)
continue
}
g := repo.Files[i]
if (f.SHA256 != "" && g.SHA256 != "" && !strings.EqualFold(f.SHA256, g.SHA256)) ||
(f.SHA256 == "" && f.Bytes != 0 && f.Bytes != g.Bytes) {
u.Changed = append(u.Changed, f.Path)
u.Bytes += g.Bytes
}
}
u.Available = len(u.Changed) > 0 && len(u.Missing) == 0
return u
}
// sameRevision compares commits either of which may be shortened.
func sameRevision(a, b string) bool {
if a == "" || b == "" {
return a == b
}
return strings.HasPrefix(a, b) || strings.HasPrefix(b, a)
}
// CheckUpdates asks the registry about every model with an origin.
func (s *Server) CheckUpdates(ctx context.Context) {
if s.deps.Current == nil {
return
}
list, err := s.deps.Mirror.List(ctx)
if err != nil {
s.deps.Log.Warn("checking for model updates: the mirror", "err", err)
return
}
seen := map[string]bool{}
for _, m := range s.deps.Current() {
o := s.originOf(ctx, m, list)
if o == nil {
continue
}
seen[m.Name] = true
s.checkOne(ctx, m, o)
}
s.mu.Lock()
for name := range s.updates {
if !seen[name] {
delete(s.updates, name)
}
}
s.mu.Unlock()
}
func (s *Server) checkOne(ctx context.Context, m catalog.Model, o *catalog.Origin) UpdateInfo {
repo, err := s.deps.Registry.Repo(ctx, o.Repo)
u := UpdateInfo{Model: m.Name, Repo: o.Repo, Current: o.Revision, CheckedAt: time.Now()}
if err != nil {
u.Err = err.Error()
} else {
u = compare(m, o, repo)
if u.Available {
s.deps.Log.Info("a model's repository has new weights", "model", m.Name, "repo", o.Repo,
"current", catalog.ShortRevision(o.Revision), "latest", catalog.ShortRevision(repo.Revision))
}
}
s.mu.Lock()
prev, had := s.updates[m.Name]
s.updates[m.Name] = u
s.mu.Unlock()
// Told once per new revision, not at every check that finds it still there.
if u.Available && (!had || !prev.Available || prev.Latest != u.Latest) {
s.deps.Notify.Notify(notify.UpdateAvailable, m.Name+"@"+u.Latest,
fmt.Sprintf("%s has new weights at %s (%s); `llmgw ctl model update %s` moves to them",
m.Name, o.Repo, catalog.ShortRevision(u.Latest), m.Name),
map[string]any{"model": m.Name, "repo": o.Repo, "current": o.Revision, "latest": u.Latest})
}
return u
}
// CheckModel asks the registry about one model now.
func (s *Server) CheckModel(ctx context.Context, name string) (UpdateInfo, error) {
m, err := s.current(name)
if err != nil {
return UpdateInfo{}, err
}
list, err := s.deps.Mirror.List(ctx)
if err != nil {
return UpdateInfo{}, fmt.Errorf("the mirror: %w", err)
}
// Asked by hand: one that could not be traced is looked for again.
s.mu.Lock()
if o, ok := s.origins[name]; ok && o == nil {
delete(s.origins, name)
}
s.mu.Unlock()
o := s.originOf(ctx, m, list)
if o == nil {
return UpdateInfo{}, fmt.Errorf("where %s's weights came from is not known: give its repository with "+
"`llmgw ctl model origin %s OWNER/REPO`", name, name)
}
return s.checkOne(ctx, m, o), nil
}
// Updates is what the last checks found, by model.
func (s *Server) Updates() map[string]UpdateInfo {
s.mu.Lock()
defer s.mu.Unlock()
out := make(map[string]UpdateInfo, len(s.updates))
for k, v := range s.updates {
out[k] = v
}
return out
}
// WatchUpdates checks every so often until ctx ends; zero never checks.
func (s *Server) WatchUpdates(ctx context.Context, every time.Duration) {
if every <= 0 {
return
}
// A little after starting, when the catalog is in force and the mirror
// answering, rather than in the middle of starting.
wait := 2 * time.Minute
for {
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
s.CheckUpdates(ctx)
wait = every
}
}
func (s *Server) current(name string) (catalog.Model, error) {
if s.deps.Current == nil {
return catalog.Model{}, errors.New("this control plane cannot read its catalog here")
}
for _, m := range s.deps.Current() {
if m.Name == name {
return m, nil
}
}
return catalog.Model{}, fmt.Errorf("no model called %s", name)
}
// StartUpdate moves a model to its repository's latest weights, as a job.
// Held to a quality suite, the new weights run first as a canary given none
// of its requests, are asked the suite, and replace the old only at its
// score.
func (s *Server) StartUpdate(name, by string, ch Change) (Job, error) {
if s.deps.Models == nil || s.deps.Restart == nil {
return Job{}, errors.New("updating needs the store, which this control plane lacks")
}
m, err := s.current(name)
if err != nil {
return Job{}, err
}
if s.deps.Models.Declared(name) {
return Job{}, fmt.Errorf("%s is declared in server.yaml: serve the new weights there, by its source, "+
"or they would revert on the next restart", name)
}
g, skipped := gateFor(m, ch)
if g != nil && m.Canary != nil {
return Job{}, fmt.Errorf("%s has a canary, %s: an update held to a quality suite tries the new weights as "+
"one. Promote it (`llmgw ctl model promote %s --eval ...`) or take it out first", name, m.Canary.Model, name)
}
if err := s.checkGate(canaryName(name), g); err != nil {
return Job{}, err
}
s.mu.Lock()
for _, j := range s.jobs {
if j.Kind == "update" && j.Model == name && j.State == Running {
s.mu.Unlock()
return Job{}, fmt.Errorf("%s is already being updated: job %s", name, j.ID)
}
if g != nil && (j.Kind == "canary" || j.Kind == "promote") && j.Model == name && j.State == Running {
s.mu.Unlock()
return Job{}, fmt.Errorf("%s has a job running: %s", name, j.ID)
}
}
s.seq++
j := &Job{ID: fmt.Sprintf("update-%d-%d", time.Now().Unix(), s.seq), Kind: "update", Model: name,
Request: Request{Name: name, By: by}, State: Running, Started: time.Now()}
s.keepJob(j)
s.mu.Unlock()
if g != nil {
go s.runGatedUpdate(j, g)
} else {
go s.runUpdate(j, skipped)
}
return s.snapshot(j), nil
}
// runGatedUpdate is an update held to a quality suite: the new weights as a
// canary with no share, asked the suite, then promoted or taken out.
func (s *Server) runGatedUpdate(j *Job, g *eval.Request) {
ctx := context.Background()
name := j.Model
p, ok := s.prepare(ctx, j, name)
if !ok {
return
}
cand, ok := s.addCandidate(ctx, j, p, catalog.Canary{Percent: 0})
if !ok {
return
}
if !s.gate(ctx, j, cand, g, false, func() string {
if _, err := s.AbortCanary(ctx, name, "llmgw"); err != nil {
return fmt.Sprintf("the new weights could not be taken out (%v): `llmgw ctl model canary %s --abort`", err, name)
}
return fmt.Sprintf("the new weights were taken out again; %s still runs %s", name, catalog.ShortRevision(p.o.Revision))
}) {
return
}
if s.promoteTo(ctx, j) {
s.finish(j)
}
}
// prepared is a model's new weights, fetched and checked: what an update and
// a canary both start from.
type prepared struct {
m catalog.Model
o *catalog.Origin
repo hf.Repo
set hf.FileSet
source string
}
// prepare reads where a model came from, fetches its repository's newer
// files into the mirror beside the ones in use, and checks the nodes can
// load them. It fails the job and says false when any of that cannot be.
func (s *Server) prepare(ctx context.Context, j *Job, name string) (prepared, bool) {
// 1. The registry: which files, at which commit.
s.step(j, "registry", Running, "reading where "+name+" came from")
m, err := s.current(name)
if err != nil {
s.fail(j, "registry", err)
return prepared{}, false
}
list, err := s.deps.Mirror.List(ctx)
if err != nil {
s.fail(j, "registry", fmt.Errorf("the mirror: %w", err))
return prepared{}, false
}
o := s.originOf(ctx, m, list)
p, onMirror := placeOf(m.Source, list)
if o == nil || !onMirror {
s.fail(j, "registry", fmt.Errorf("%s did not come from the registry through the mirror", name))
return prepared{}, false
}
repo, err := s.deps.Registry.Repo(ctx, o.Repo)
if err != nil {
s.fail(j, "registry", err)
return prepared{}, false
}
s.mu.Lock()
j.Request.Repo = repo.ID
s.mu.Unlock()
u := compare(m, o, repo)
switch {
case sameRevision(repo.Revision, o.Revision):
s.fail(j, "registry", fmt.Errorf("%s already runs %s's latest commit, %s", name, repo.ID, catalog.ShortRevision(repo.Revision)))
return prepared{}, false
case len(u.Missing) > 0:
s.fail(j, "registry", fmt.Errorf("%s at %s no longer has %s; choose one of its files and serve it under a new name",
repo.ID, repo.ShortRevision(), strings.Join(u.Missing, ", ")))
return prepared{}, false
case len(u.Changed) == 0:
s.fail(j, "registry", fmt.Errorf("%s has a newer commit, %s, but the files %s runs are the same in it",
repo.ID, repo.ShortRevision(), name))
return prepared{}, false
case repo.Gated.Is():
s.fail(j, "registry", fmt.Errorf("%s is gated: it needs an account's approval the mirror does not have", repo.ID))
return prepared{}, false
}
set := hf.FileSet{}
for _, f := range o.Files {
g := repo.Files[slices.IndexFunc(repo.Files, func(g hf.File) bool { return g.Path == f.Path })]
set.Files = append(set.Files, g)
set.Bytes += g.Bytes
}
set.Name, set.Shards = set.Files[0].Path, len(set.Files)
s.step(j, "registry", Done, fmt.Sprintf("%s: %s → %s, %d of %d file(s) changed, %.1f GB",
repo.ID, catalog.ShortRevision(o.Revision), repo.ShortRevision(), len(u.Changed), len(o.Files), gb(set.Bytes)))
// 2. The mirror, beside the files in use.
source, err := s.mirror(ctx, j, repo, set, p.model)
if err != nil {
s.fail(j, "mirror", err)
return prepared{}, false
}
// 3. Whether the nodes load it, and whether it still fits what is
// reserved for it.
s.step(j, "check", Running, "reading its header from the mirror")
detail := "no header to read"
if u := headerURL(source, set); u != "" {
v := gguf.Check(ctx, u, s.deps.LlamaVersion)
if !v.Servable {
s.fail(j, "check", fmt.Errorf("llama.cpp %s on the nodes cannot run the new weights: %s", s.deps.LlamaVersion, v.Reason))
return prepared{}, false
}
detail = orUnknown(v.Header.Architecture)
if len(m.Profiles) > 0 {
ctxPool := 16384
for i, a := range m.Profiles[0].Args {
if a == "--ctx-size" && i+1 < len(m.Profiles[0].Args) {
fmt.Sscan(m.Profiles[0].Args[i+1], &ctxPool)
}
}
need := int((v.Header.MemoryBytes(set.Bytes, ctxPool) + 1<<30 - 1) >> 30)
if have := m.Profiles[0].MinMemoryGB; need > have && have > 0 {
detail += fmt.Sprintf("; needs about %d GB and %d is reserved: raise it if replicas fail", need, have)
}
}
}
s.step(j, "check", Done, detail)
return prepared{m: m, o: o, repo: repo, set: set, source: source}, true
}
func (s *Server) runUpdate(j *Job, skipped bool) {
ctx := context.Background()
name := j.Model
p, ok := s.prepare(ctx, j, name)
if !ok {
return
}
s.gate(ctx, j, name, nil, skipped, nil)
m, o, repo, set, source := p.m, p.o, p.repo, p.set, p.source
// 4. The catalog: the new source, and the old one kept to go back to.
s.step(j, "catalog", Running, "moving "+name+" to "+repo.ShortRevision())
next := &catalog.Origin{Repo: repo.ID, Revision: repo.Revision, Files: originFiles(set),
Previous: &catalog.PreviousOrigin{Source: m.Source, Revision: o.Revision, Files: o.Files}}
if _, err := s.deps.Models.SetSource(ctx, name, source, next, j.Request.By); err != nil {
s.fail(j, "catalog", err)
return
}
s.step(j, "catalog", Done, "the catalog names "+source)
// 5. The replicas, replaced one at a time.
if err := s.replace(ctx, j, name); err != nil {
s.fail(j, "replicas", fmt.Errorf("%w. `llmgw ctl model rollback %s` goes back to %s", err, name,
catalog.ShortRevision(o.Revision)))
return
}
s.mu.Lock()
s.updates[name] = UpdateInfo{Model: name, Repo: repo.ID, Current: repo.Revision, Latest: repo.Revision,
Previous: o.Revision, CheckedAt: time.Now()}
s.mu.Unlock()
s.finish(j)
}
// replace restarts a model's replicas onto the catalog in force and waits
// for the new ones, then asks one to answer.
func (s *Server) replace(ctx context.Context, j *Job, name string) error {
var old []string
running := 0
for _, r := range s.deps.Fleet.Replicas() {
if r.Model != name {
continue
}
running++
if r.State == reconciler.Ready {
old = append(old, r.ID)
}
}
if running == 0 {
s.step(j, "replicas", Skipped, "none running; the next to start loads the new weights")
return nil
}
if len(old) == 0 {
s.step(j, "replicas", Skipped, "none ready; those starting load the new weights when they next start")
return nil
}
if err := s.deps.Restart(old...); err != nil {
return err
}
s.step(j, "replicas", Running, fmt.Sprintf("replacing %d, one at a time", len(old)))
deadline := time.Now().Add(s.deps.ReadyTimeout)
for {
left, ready := 0, []reconciler.Replica{}
for _, r := range s.deps.Fleet.Replicas() {
if r.Model != name {
continue
}
if slices.Contains(old, r.ID) {
left++
} else if r.State == reconciler.Ready {
ready = append(ready, r)
}
}
for _, h := range s.deps.Fleet.ModelHealth() {
if h.Model == name && h.Parked {
return fmt.Errorf("the new weights failed to start %d times: %s", h.Failures, h.Reason)
}
}
if left == 0 && len(ready) > 0 {
for _, r := range ready {
s.linkReplica(j, r)
}
s.step(j, "replicas", Done, fmt.Sprintf("%d replaced", len(old)))
s.step(j, "smoke test", Running, "asking "+ready[0].ID+" to say hello")
reply, tps, err := s.smoke(ctx, ready[0], name)
if err != nil {
s.step(j, "smoke test", Failed, err.Error())
return err
}
s.mu.Lock()
j.Reply, j.TokensPerSecond = reply, tps
s.mu.Unlock()
s.step(j, "smoke test", Done, fmt.Sprintf("answered at %.1f tokens/s: %q", tps, reply))
return nil
}
s.step(j, "replicas", Running, fmt.Sprintf("%d of %d replaced, %d new ready", len(old)-left, len(old), len(ready)))
if time.Now().After(deadline) {
return fmt.Errorf("the replicas were not replaced after %s", s.deps.ReadyTimeout)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(s.deps.Poll):
}
}
}
// Rollback moves a model back to what its last update replaced, if those
// files are still on the mirror, and restarts its replicas onto them. The
// update is kept as the one to go forward to again.
func (s *Server) Rollback(ctx context.Context, name, by string) (string, error) {
if s.deps.Models == nil || s.deps.Restart == nil {
return "", errors.New("going back needs the store, which this control plane lacks")
}
m, err := s.current(name)
if err != nil {
return "", err
}
if m.Origin == nil || m.Origin.Previous == nil {
return "", fmt.Errorf("%s has not been updated, so there is nothing to go back to", name)
}
prev := m.Origin.Previous
list, err := s.deps.Mirror.List(ctx)
if err != nil {
return "", fmt.Errorf("the mirror: %w", err)
}
if _, ok := placeOf(prev.Source, list); !ok {
return "", fmt.Errorf("the files of %s are no longer on the mirror", catalog.ShortRevision(prev.Revision))
}
back := &catalog.Origin{Repo: m.Origin.Repo, Revision: prev.Revision, Files: prev.Files,
Previous: &catalog.PreviousOrigin{Source: m.Source, Revision: m.Origin.Revision, Files: m.Origin.Files}}
if _, err := s.deps.Models.SetSource(ctx, name, prev.Source, back, by); err != nil {
return "", err
}
var ids []string
for _, r := range s.deps.Fleet.Replicas() {
if r.Model == name && r.State == reconciler.Ready {
ids = append(ids, r.ID)
}
}
msg := fmt.Sprintf("%s runs %s again", name, catalog.ShortRevision(prev.Revision))
if len(ids) > 0 {
if err := s.deps.Restart(ids...); err != nil {
return "", err
}
msg += fmt.Sprintf("; %d replica(s) are being replaced", len(ids))
}
s.mu.Lock()
if u, ok := s.updates[name]; ok {
u.Current, u.Previous = prev.Revision, m.Origin.Revision
u.Available = u.Latest != "" && u.Latest != prev.Revision
s.updates[name] = u
}
s.mu.Unlock()
return msg, nil
}
// UpdateCollector publishes llmgw_model_update_available, 1 for each model
// whose repository has new weights. Registered when the metrics are, before
// there is a server to read: Set gives it one.
type UpdateCollector struct{ s atomic.Pointer[Server] }
var updateDesc = prometheus.NewDesc("llmgw_model_update_available",
"1 when the registry repository a model was served from has published different weights for its files.",
[]string{"model", "repo"}, nil)
// Set gives the collector the server whose checks it reports.
func (c *UpdateCollector) Set(s *Server) { c.s.Store(s) }
// Describe implements prometheus.Collector.
func (c *UpdateCollector) Describe(ch chan<- *prometheus.Desc) { ch <- updateDesc }
// Collect implements prometheus.Collector.
func (c *UpdateCollector) Collect(ch chan<- prometheus.Metric) {
s := c.s.Load()
if s == nil {
return
}
for _, u := range s.Updates() {
v := 0.0
if u.Available {
v = 1
}
ch <- prometheus.MustNewConstMetric(updateDesc, prometheus.GaugeValue, v, u.Model, u.Repo)
}
}