Files
Ilker ManapandClaude Opus 5.5 cba4897987
CI / test (push) Failing after 6m44s
Deleting unused model files says what went, and sets go whole
A sweep counted every file the nodes were asked to delete as freed as soon
as it asked, and a node refused a set's directory as not empty, so tens of
gigabytes were reported freed and nothing was. The agent removes a set's
directory whole (a link only as a link), and the control plane waits for
each node to list its files again, saying what it no longer lists and what
it kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:29:16 +01:00

179 lines
5.8 KiB
Go

package cli
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// newWeightsCmd reports and reclaims model files nothing needs.
//
// The dangerous half of storage management, so its shape is defensive: listing
// is the default and deleting takes a flag, only files llmgw fetched are ever
// candidates, and the node checks every path again before unlinking it.
//
// What it will never touch is the more important half of the contract. Weights
// an operator put on a node — by scp, by a shared mount, by hand — are none of
// this system's business however unreferenced they look, and a model that took
// a week to copy must not be removable by a command someone ran to tidy up.
func newWeightsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
cmd := &cobra.Command{
Use: "weights",
Short: "Show cached model files no model in the catalog needs",
Long: "Lists weights llmgw downloaded for models that are no longer in the\n" +
"catalog, per node, with what deleting them would free.\n\n" +
"Only files llmgw fetched are considered. Anything an operator put on a\n" +
"node is left alone, whatever the catalog says — llmgw observes storage\n" +
"rather than owning it, and only takes back what it put there itself.\n\n" +
"Nothing is deleted without --collect.",
Args: cobra.NoArgs,
}
var collect bool
var node string
cmd.Flags().BoolVar(&collect, "collect", false, "delete them, rather than only listing")
cmd.Flags().StringVar(&node, "node", "", "limit to one node")
cmd.RunE = func(cmd *cobra.Command, _ []string) error {
if collect {
return collectOrphans(cmd, client(), node, *asJSON)
}
return listOrphanWeights(cmd, client(), node, *asJSON)
}
return cmd
}
func listOrphanWeights(cmd *cobra.Command, c *ctlClient, node string, asJSON bool) error {
var found []adminapi.OrphanView
if err := c.get(cmd.Context(), "/api/v1/weights/orphans", &found); err != nil {
return err
}
if node != "" {
found = onlyNode(found, node)
}
if asJSON {
return emitJSON(found)
}
if len(found) == 0 {
fmt.Fprintln(cmd.OutOrStdout(),
"Nothing to reclaim: every file llmgw fetched is still wanted by a model.")
return nil
}
w := tab()
fmt.Fprintln(w, "NODE\tFILES\tWOULD FREE")
var total int64
for _, o := range found {
fmt.Fprintf(w, "%s\t%d\t%s\n", o.Node, len(o.Paths), fileSize(o.Bytes))
total += o.Bytes
}
_ = w.Flush()
for _, o := range found {
for _, p := range o.Paths {
fmt.Fprintf(cmd.OutOrStdout(), "\n %s: %s", o.Node, p)
}
}
fmt.Fprintf(cmd.OutOrStdout(), "\n\n%s in total. Delete with --collect.\n", fileSize(total))
return nil
}
func collectOrphans(cmd *cobra.Command, c *ctlClient, node string, asJSON bool) error {
body, err := json.Marshal(map[string]string{"node": node})
if err != nil {
return err
}
var results []adminapi.CollectResult
// The control plane waits for the nodes to list their files without the
// deleted ones: half a minute at most.
if err := c.patient(time.Minute).post(cmd.Context(), "/api/v1/weights/collect", body, &results); err != nil {
return err
}
if asJSON {
return emitJSON(results)
}
if len(results) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "Nothing to reclaim.")
return nil
}
var freed, left int64
for _, r := range results {
if r.Error != "" {
// Reported rather than fatal: the other nodes' space is still worth
// freeing, and this one will be offered again next time.
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", r.Node, r.Error)
continue
}
line := fmt.Sprintf("%s: removed %d files, %s", r.Node, r.Files, fileSize(r.Bytes))
if r.Left > 0 {
line += fmt.Sprintf("; %d still there, %s", r.Left, fileSize(r.LeftBytes))
}
fmt.Fprintln(cmd.OutOrStdout(), line)
freed, left = freed+r.Bytes, left+r.LeftBytes
}
fmt.Fprintf(cmd.OutOrStdout(), "\n%s freed.\n", fileSize(freed))
if left > 0 {
// Each node decides: one that keeps a path it cannot prove it
// fetched is the feature working. Its agent's log names each.
fmt.Fprintf(cmd.OutOrStdout(), "%s the nodes kept; each node's agent log says why "+
"(journalctl -u llmgw-agent).\n", fileSize(left))
}
return nil
}
func onlyNode(in []adminapi.OrphanView, node string) []adminapi.OrphanView {
out := make([]adminapi.OrphanView, 0, len(in))
for _, o := range in {
if o.Node == node {
out = append(out, o)
}
}
return out
}
// fileSize renders a size the way someone reads one when deciding whether a
// file is worth deleting.
//
// Separate from the per-token cache size renderer in modelplan, which never
// leaves KiB and MiB. Model files are tens of gigabytes, and "8601.6 MiB" is
// not a number anyone weighs a decision against.
func fileSize(b int64) string {
switch {
case b >= 1<<30:
return fmt.Sprintf("%.1f GB", float64(b)/(1<<30))
case b >= 1<<20:
return fmt.Sprintf("%.0f MB", float64(b)/(1<<20))
case b > 0:
return fmt.Sprintf("%d B", b)
default:
// The node reported no size. Saying "0 B" would suggest deleting it
// gains nothing, which is a different claim from not knowing.
return "size unknown"
}
}
// fetchColumn renders a download's progress.
//
// A percentage when the size is known and a byte count when it is not, because
// a server that does not send Content-Length is common enough — and an
// invented percentage that stops moving is worse than a number that rises.
func fetchColumn(f *adminapi.FetchView) string {
if f.Percent != nil {
return fmt.Sprintf("%s %d%% of %s", shortSource(f.Source), *f.Percent, fileSize(f.BytesTotal))
}
return fmt.Sprintf("%s %s so far", shortSource(f.Source), fileSize(f.BytesDone))
}
// shortSource keeps the end of a source, which is the part that identifies it.
func shortSource(s string) string {
if i := strings.LastIndex(s, "/"); i >= 0 && i < len(s)-1 {
return s[i+1:]
}
return s
}