Files
Ilker ManapandClaude Opus 5 774e134ed1
CI / test (push) Successful in 1m39s
Refuse a node with no room, and take back only what llmgw put there
Two halves of the same problem: nothing stopped a model being placed on a node
that could not hold it, and nothing ever removed the weights of a model that
had been deleted from the catalog.

Placement now has a disk check, shaped exactly like the memory one it sits
beside, with one addition: it is skipped on a node that already holds the
weights. A node holding the file needs no room for it, and without that the
node best placed to run a model — because it already has it — is the one most
likely to be rejected, since holding the file is what used the space.

Reclaiming is the dangerous half, so the whole design is about what it will
not do. llmgw deletes only files it fetched itself, and that is a calculation
rather than a heuristic: the cache name is a hash of the source, so the set of
wanted files can be computed from the catalog exactly. Anything without that
name was put there by an operator — scp, a shared mount, a copy by hand — and
is none of this system's business however unreferenced it looks. A model that
took a week to copy must not be removable by a command someone ran to tidy up.

The node checks every path again before unlinking it, and that duplication is
the point. The control plane knows the catalog; the node knows its disk. A
control plane that is wrong, newer than the agent, or tampered with must not be
able to talk a node into deleting something, so the agent refuses anything
outside its own cache directory or without the cache name — including a path
that tries to traverse out of it.

Listing is the default and deleting takes --collect. The summary says "asked to
remove", because each node decides, and one that refuses a path it cannot prove
it fetched is this working rather than failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 09:51:05 +02:00

117 lines
4.1 KiB
Go

package weights
import (
"path/filepath"
"slices"
"testing"
)
func TestOnlyFilesLLMGWFetchedAreEverOrphans(t *testing.T) {
// The invariant the whole feature rests on. Everything without the cache
// name was put there by the operator — scp, a shared mount, a copy by hand
// — and deleting one could cost a week of someone's bandwidth. It does not
// matter how unreferenced it looks.
const dir = "/var/lib/llmgw-models"
have := []string{
filepath.Join(dir, "qwen2.5-coder-14b-instruct-q8_0-00001-of-00002.gguf"),
filepath.Join(dir, "some-model.safetensors"),
filepath.Join(dir, "notes.txt"),
CachePathFor(dir, "https://models.internal/gone.gguf"),
}
got := Orphans(have, nil)
if len(got) != 1 {
t.Fatalf("expected only the fetched file, got %v", got)
}
if !Owned(got[0]) {
t.Errorf("returned a file llmgw did not fetch: %s", got[0])
}
}
func TestAFetchedFileStillInTheCatalogIsKept(t *testing.T) {
const dir = "/var/lib/llmgw-models"
const wanted = "hf:Qwen/Qwen2.5-Coder-3B-Instruct-GGUF/q4.gguf"
const gone = "https://models.internal/old.gguf"
have := []string{CachePathFor(dir, wanted), CachePathFor(dir, gone)}
got := Orphans(have, []string{wanted})
if slices.Contains(got, CachePathFor(dir, wanted)) {
t.Error("a file the catalog still points at was called an orphan")
}
if !slices.Contains(got, CachePathFor(dir, gone)) {
t.Errorf("a file nothing points at was not found: %v", got)
}
}
func TestTheCacheNameMatchesWhatAFetchActuallyWrites(t *testing.T) {
// The control plane proposes deletions by recomputing this name from the
// catalog. If it ever drifted from what a fetch writes, the proposal would
// name a file that is in use — so the two must be one function, and this
// is what says so.
f := &Fetcher{CacheDir: "/cache"}
const source = "https://models.internal/x.gguf"
got, err := f.cachePath(source)
if err != nil {
t.Fatal(err)
}
if want := CachePathFor("/cache", source); got != want {
t.Errorf("a fetch writes %q but the catalog would look for %q", got, want)
}
}
func TestOneOfTwoLookalikeFilesIsNotMistakenForTheOther(t *testing.T) {
// That the two cache to different names is covered where the naming lives.
// What matters here is the consequence: deleting the unreferenced one must
// not take the referenced one with it, and both end in "model.gguf".
a := CachePathFor("/cache", "https://one.example/model.gguf")
b := CachePathFor("/cache", "https://two.example/model.gguf")
got := Orphans([]string{a, b}, []string{"https://one.example/model.gguf"})
if !slices.Equal(got, []string{b}) {
t.Errorf("orphans = %v, want only %s", got, b)
}
}
func TestASourceThatIsNeverFetchedProtectsNothingAndDeletesNothing(t *testing.T) {
// A plain path, or a model id an engine resolves itself, puts nothing in
// the cache. Computing a cache name for it would reserve a file that does
// not exist, and it must not stop a real orphan being found either.
const dir = "/models"
orphan := CachePathFor(dir, "https://models.internal/gone.gguf")
got := Orphans([]string{orphan, filepath.Join(dir, "local.gguf")},
[]string{"/models/local.gguf", "Qwen/Qwen2.5-7B-Instruct"})
if !slices.Equal(got, []string{orphan}) {
t.Errorf("orphans = %v, want only %s", got, orphan)
}
}
func TestAnEmptyCatalogDoesNotMakeOperatorFilesDisposable(t *testing.T) {
// The dangerous case: every model removed from the catalog at once. Files
// llmgw fetched become orphans, which is right. Files it did not must stay
// untouched, which is the whole point.
const dir = "/models"
have := []string{
filepath.Join(dir, "hand-placed.gguf"),
CachePathFor(dir, "https://models.internal/a.gguf"),
}
got := Orphans(have, nil)
if len(got) != 1 || got[0] != CachePathFor(dir, "https://models.internal/a.gguf") {
t.Errorf("orphans = %v", got)
}
}
func TestPartialsAreNotSweptUpWithTheRest(t *testing.T) {
// A fetch may be running right now, and only the node itself can tell a
// live download from one a killed agent left behind.
if !PartialName("/models/abc-model.gguf.part") {
t.Error("a .part file was not recognised")
}
if PartialName("/models/abc-model.gguf") {
t.Error("a finished file was called partial")
}
}