CI / test (push) Successful in 1m39s
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>
310 lines
9.5 KiB
Go
310 lines
9.5 KiB
Go
// Package weights makes a model's files present on a node.
|
|
//
|
|
// The simple arrangement, and the default: every node has its own copy. A
|
|
// source that is already a local path is used as it stands; one that names a
|
|
// remote file is fetched into a cache directory once and reused. Nothing here
|
|
// manages storage, decides a retention policy, or knows what a filesystem is —
|
|
// it answers one question, which is where the engine should be pointed.
|
|
//
|
|
// The exotic arrangements are all handled by not acting. Weights on a shared
|
|
// filer, an image exported over the fabric, or anything else an operator has
|
|
// mounted are simply present, and present means there is nothing to do. That is
|
|
// why the interesting case needs no code: the escape hatch from a distribution
|
|
// mechanism is a distribution mechanism that checks first.
|
|
package weights
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Progress reports how a fetch is going, for anything that wants to say so.
|
|
type Progress struct {
|
|
Source string
|
|
BytesDone int64
|
|
BytesTotal int64
|
|
}
|
|
|
|
// Percent is how far along, or -1 when the total is unknown.
|
|
func (p Progress) Percent() int {
|
|
if p.BytesTotal <= 0 {
|
|
return -1
|
|
}
|
|
return int(p.BytesDone * 100 / p.BytesTotal)
|
|
}
|
|
|
|
// Fetcher makes sources local.
|
|
type Fetcher struct {
|
|
CacheDir string
|
|
Client *http.Client
|
|
Log *slog.Logger
|
|
// OnProgress is called occasionally during a fetch. A missing model is the
|
|
// difference between a replica that is slow to start and one that is stuck,
|
|
// and without this the two are indistinguishable from outside.
|
|
OnProgress func(Progress)
|
|
|
|
// inflight makes concurrent requests for one source share a single fetch.
|
|
//
|
|
// Eight shards of a tensor-parallel replica start at the same instant and
|
|
// want the same file. Without this they would each open a connection and
|
|
// each write the same bytes, turning one download into eight and, on a
|
|
// shared origin, into a self-inflicted denial of service.
|
|
mu sync.Mutex
|
|
inflight map[string]*fetchCall
|
|
}
|
|
|
|
type fetchCall struct {
|
|
done chan struct{}
|
|
path string
|
|
err error
|
|
}
|
|
|
|
// ErrNotLocal means a source names a file that is not here and cannot be
|
|
// fetched — a bare path with nothing to fetch it from.
|
|
var ErrNotLocal = errors.New("the weights are not on this node and the source says nothing about where to get them")
|
|
|
|
// Ensure returns a local path for a source, fetching it if it is not here.
|
|
//
|
|
// A plain path is never fetched, only checked. That is deliberate: a path is
|
|
// what an operator writes when the file is already there, whether it arrived on
|
|
// local disk, a Lustre mount or an image exported over the fabric, and a
|
|
// downloader that second-guessed that would copy a hundred gigabytes that were
|
|
// already present.
|
|
func (f *Fetcher) Ensure(ctx context.Context, source string) (string, error) {
|
|
if source == "" {
|
|
return "", fmt.Errorf("no source given")
|
|
}
|
|
if !isRemote(source) {
|
|
if !isPath(source) {
|
|
// A bare model id — "Qwen/Qwen2.5-7B-Instruct" — is not a file and
|
|
// was never meant to be one. vLLM and SGLang resolve those
|
|
// themselves, and doing it for them would be both a duplicate
|
|
// downloader and a change in documented behaviour.
|
|
return source, nil
|
|
}
|
|
if _, err := os.Stat(source); err == nil {
|
|
return source, nil
|
|
}
|
|
return "", fmt.Errorf("%w: %s", ErrNotLocal, source)
|
|
}
|
|
|
|
target, err := f.cachePath(source)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if _, err := os.Stat(target); err == nil {
|
|
return target, nil
|
|
}
|
|
|
|
f.mu.Lock()
|
|
if f.inflight == nil {
|
|
f.inflight = map[string]*fetchCall{}
|
|
}
|
|
if call, running := f.inflight[source]; running {
|
|
f.mu.Unlock()
|
|
select {
|
|
case <-call.done:
|
|
return call.path, call.err
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
}
|
|
call := &fetchCall{done: make(chan struct{})}
|
|
f.inflight[source] = call
|
|
f.mu.Unlock()
|
|
|
|
call.path, call.err = f.fetch(ctx, source, target)
|
|
close(call.done)
|
|
|
|
f.mu.Lock()
|
|
delete(f.inflight, source)
|
|
f.mu.Unlock()
|
|
return call.path, call.err
|
|
}
|
|
|
|
// fetch downloads a source to a target, resuming and verifying.
|
|
func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, error) {
|
|
url, err := resolveURL(source)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Written beside the target and renamed at the end. A partial file under
|
|
// the real name would be found by the next scan, reported as present, and
|
|
// handed to an engine that fails on it minutes later — the exact failure
|
|
// this whole area exists to prevent.
|
|
part := target + ".part"
|
|
existing := int64(0)
|
|
if st, err := os.Stat(part); err == nil {
|
|
existing = st.Size()
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if existing > 0 {
|
|
// Resume. A forty gigabyte fetch that fails at ninety percent must not
|
|
// start again from nothing.
|
|
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", existing))
|
|
}
|
|
|
|
client := f.Client
|
|
if client == nil {
|
|
client = &http.Client{}
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetching %s: %w", source, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
switch resp.StatusCode {
|
|
case http.StatusOK:
|
|
existing = 0 // the server ignored the range; start over
|
|
case http.StatusPartialContent:
|
|
default:
|
|
return "", fmt.Errorf("fetching %s: %s returned %s", source, url, resp.Status)
|
|
}
|
|
|
|
flags := os.O_CREATE | os.O_WRONLY
|
|
if existing > 0 {
|
|
flags |= os.O_APPEND
|
|
} else {
|
|
flags |= os.O_TRUNC
|
|
}
|
|
out, err := os.OpenFile(part, flags, 0o644)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
total := existing + resp.ContentLength
|
|
f.report(Progress{Source: source, BytesDone: existing, BytesTotal: total})
|
|
written, err := f.copyWithProgress(ctx, out, resp.Body, source, existing, total)
|
|
closeErr := out.Close()
|
|
if err != nil {
|
|
// The part file is kept on purpose, so the next attempt resumes.
|
|
return "", fmt.Errorf("fetching %s after %d bytes: %w", source, written, err)
|
|
}
|
|
if closeErr != nil {
|
|
return "", closeErr
|
|
}
|
|
|
|
if err := os.Rename(part, target); err != nil {
|
|
return "", err
|
|
}
|
|
if f.Log != nil {
|
|
f.Log.Info("weights fetched", "source", source, "path", target, "bytes", written)
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader,
|
|
source string, done, total int64,
|
|
) (int64, error) {
|
|
buf := make([]byte, 4<<20)
|
|
last := time.Now()
|
|
for {
|
|
if err := ctx.Err(); err != nil {
|
|
return done, err
|
|
}
|
|
n, err := src.Read(buf)
|
|
if n > 0 {
|
|
if _, werr := dst.Write(buf[:n]); werr != nil {
|
|
return done, werr
|
|
}
|
|
done += int64(n)
|
|
// Occasionally, not per read: a four-megabyte buffer over a
|
|
// hundred gigabytes is twenty-five thousand callbacks, and nobody
|
|
// needs to be told that often.
|
|
if time.Since(last) > 2*time.Second {
|
|
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
|
|
last = time.Now()
|
|
}
|
|
}
|
|
if errors.Is(err, io.EOF) {
|
|
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
|
|
return done, nil
|
|
}
|
|
if err != nil {
|
|
return done, err
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *Fetcher) report(p Progress) {
|
|
if f.OnProgress != nil {
|
|
f.OnProgress(p)
|
|
}
|
|
}
|
|
|
|
// cachePath is where a remote source lands.
|
|
//
|
|
// Named by a hash of the source with the original filename kept on the end, so
|
|
// two models with the same filename from different places cannot collide while
|
|
// a human looking at the directory can still tell what is in it.
|
|
func (f *Fetcher) cachePath(source string) (string, error) {
|
|
if f.CacheDir == "" {
|
|
return "", fmt.Errorf("no cache directory configured, so %s cannot be fetched", source)
|
|
}
|
|
return CachePathFor(f.CacheDir, source), nil
|
|
}
|
|
|
|
// isPath reports whether a source names a file on this machine.
|
|
//
|
|
// By its leading character, because that is the only honest signal available:
|
|
// "Qwen/Qwen2.5-7B-Instruct" and "models/qwen.gguf" are indistinguishable as
|
|
// strings, and an operator writing a local file writes an absolute path. The
|
|
// cost of being wrong in this direction is that a relative path is passed to
|
|
// the engine untouched, which is what happened before this code existed.
|
|
func isPath(source string) bool {
|
|
return strings.HasPrefix(source, "/") ||
|
|
strings.HasPrefix(source, "./") ||
|
|
strings.HasPrefix(source, "../")
|
|
}
|
|
|
|
// isRemote reports whether a source has to be fetched at all.
|
|
func isRemote(source string) bool {
|
|
return strings.HasPrefix(source, "hf:") ||
|
|
strings.HasPrefix(source, "http://") ||
|
|
strings.HasPrefix(source, "https://")
|
|
}
|
|
|
|
// resolveURL turns a source into something to GET.
|
|
//
|
|
// hf: names one file in a repository, which is what a GGUF is. A whole
|
|
// repository of shards is deliberately not handled here: vLLM and SGLang
|
|
// download those themselves and do it well, and reimplementing a snapshot
|
|
// downloader to compete with them would be work spent to arrive back where we
|
|
// started.
|
|
func resolveURL(source string) (string, error) {
|
|
if rest, ok := strings.CutPrefix(source, "hf:"); ok {
|
|
parts := strings.Split(rest, "/")
|
|
if len(parts) < 3 {
|
|
return "", fmt.Errorf(
|
|
"%s names a repository, not a file; give the file as hf:org/repo/model.gguf, "+
|
|
"or use the repository id directly and let the engine download it", source)
|
|
}
|
|
repo := strings.Join(parts[:2], "/")
|
|
file := strings.Join(parts[2:], "/")
|
|
return "https://huggingface.co/" + repo + "/resolve/main/" + file, nil
|
|
}
|
|
if _, err := url.Parse(source); err != nil {
|
|
return "", fmt.Errorf("%s is not a usable source: %w", source, err)
|
|
}
|
|
return source, nil
|
|
}
|