The catalog had one source a model, so an embedding model served by vLLM from its repository on a card and by llama.cpp from a GGUF on CPUs had its entry swapped by hand halfway through the metallurgy import. A profile's source is what its engine loads and placement looks for; the model's is the default, and what updates and canaries move. ctl model add checks every source, the GGUF check reads each llama.cpp profile's, and ctl weights collects none a profile loads. An empty source leaves a profile's fingerprint as it was, so running replicas do not read as stale. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
117 lines
3.6 KiB
Go
117 lines
3.6 KiB
Go
package adminapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"sort"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
|
|
)
|
|
|
|
// OrphanView is cached weights on one node that no catalog model accounts for.
|
|
type OrphanView struct {
|
|
Node string `json:"node"`
|
|
Paths []string `json:"paths"`
|
|
// Bytes is what deleting them would free, as far as the node reported. A
|
|
// file whose size was not reported counts as zero rather than being left
|
|
// out, so the list and the number always describe the same set.
|
|
Bytes int64 `json:"bytes"`
|
|
}
|
|
|
|
// Orphans finds cached weights nothing in the catalog points at.
|
|
//
|
|
// Computed here from what nodes already report, so listing costs no round trip
|
|
// and a node that is offline simply contributes nothing — its files are not
|
|
// deletable while it is gone anyway.
|
|
//
|
|
// Only files llmgw fetched are ever considered. Everything else in a model
|
|
// directory was put there by the operator and is none of this system's
|
|
// business, however unreferenced it looks.
|
|
func (a *API) Orphans() []OrphanView {
|
|
var wanted []string
|
|
for _, m := range a.ctl.Catalog().Models() {
|
|
wanted = append(wanted, m.WeightSources()...)
|
|
// A model's adapters are its weights too; collecting them would
|
|
// leave replicas that cannot restart.
|
|
for _, ad := range m.Adapters {
|
|
wanted = append(wanted, ad.Source)
|
|
}
|
|
}
|
|
|
|
out := make([]OrphanView, 0)
|
|
for _, n := range a.ctl.Inventory().Nodes() {
|
|
size := map[string]int64{}
|
|
have := make([]string, 0, len(n.Weights))
|
|
for _, w := range n.Weights {
|
|
have = append(have, w.Path)
|
|
size[w.Path] = w.SizeBytes
|
|
}
|
|
|
|
paths := weights.Orphans(have, wanted)
|
|
if len(paths) == 0 {
|
|
continue
|
|
}
|
|
v := OrphanView{Node: n.ID, Paths: paths}
|
|
for _, p := range paths {
|
|
v.Bytes += size[p]
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
|
|
return out
|
|
}
|
|
|
|
func (a *API) listOrphans(w http.ResponseWriter, _ *http.Request) {
|
|
writeJSON(w, http.StatusOK, a.Orphans())
|
|
}
|
|
|
|
// CollectRequest limits a sweep to one node. Empty means every node, which
|
|
// is the common case after removing a model from the catalog.
|
|
type CollectRequest struct {
|
|
Node string `json:"node,omitempty"`
|
|
}
|
|
|
|
// CollectResult is what was deleted on one node, or why nothing was.
|
|
type CollectResult struct {
|
|
Node string `json:"node"`
|
|
Files int `json:"files"`
|
|
Bytes int64 `json:"bytes"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// collectWeights deletes them.
|
|
//
|
|
// A proposal, not an instruction: each node checks every path again before
|
|
// unlinking it, and refuses anything it cannot prove it fetched. The two
|
|
// checks are deliberate duplication — the control plane knows the catalog and
|
|
// the node knows its disk, and only the node can be sure.
|
|
func (a *API) collectWeights(w http.ResponseWriter, r *http.Request) {
|
|
rm := a.ctl.WeightRemover()
|
|
if rm == nil {
|
|
writeError(w, http.StatusNotImplemented,
|
|
"this control plane cannot ask nodes to delete files")
|
|
return
|
|
}
|
|
|
|
var body CollectRequest
|
|
// An empty body is the "all nodes" case and must not be an error.
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
out := make([]CollectResult, 0)
|
|
for _, o := range a.Orphans() {
|
|
if body.Node != "" && o.Node != body.Node {
|
|
continue
|
|
}
|
|
res := CollectResult{Node: o.Node, Files: len(o.Paths), Bytes: o.Bytes}
|
|
// A node that has gone since the list was built is reported rather than
|
|
// failing the whole sweep: the other nodes' space is still worth
|
|
// freeing, and this one will be offered again next time.
|
|
if err := rm.RemoveWeights(r.Context(), o.Node, o.Paths); err != nil {
|
|
res.Error = err.Error()
|
|
res.Files, res.Bytes = 0, 0
|
|
}
|
|
out = append(out, res)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|