mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
Every writer used to stage into the same `.artifacts/.partial/<cacheKey>`. That was safe only because the artifact lock held: two writers that both believed they had it opened the same blob with O_APPEND and interleaved their bytes into one file, while the resume probe read the other writer's in-flight size. SHA verification caught the damage only after both had burned the entire download. #10986 restored the lock's precondition on CIFS but left the dependency in place. Suffix the staging tree with a writer identity drawn once per process run, so concurrent writers cannot corrupt each other whatever the lock does. The lock stops being a correctness dependency and becomes a pure efficiency optimisation: a lock failure now costs a duplicated download, not a corrupted one. Commit stays an atomic rename. The loser of a commit race reconciles onto the winner's tree instead of surfacing a bare ENOTEMPTY for work that actually succeeded, since the artifact is content-addressed and both trees hold the same verified bytes. Writer-unique staging means a crashed writer's tree is no longer overwritten by its successor, so two things are added to keep it from becoming a disk leak and a resume regression: - A sweep reclaims trees whose contents have been untouched for 24h, matching the window the startup reaper already uses for stray *.partial files. It reads the newest mtime anywhere inside the tree, because writing a blob never touches an ancestor, and refuses any name this package did not write. A live download writes continuously, and the downloader's stall watchdog aborts a silent one long before it could look abandoned. - Adoption lets a restarted process claim a dead predecessor's tree for the same artifact and resume from its bytes, which a tens-of-gigabytes repo depends on. The claim is an atomic rename, so racing adopters cannot both win. It runs only under the artifact lock - which is released exactly when the owning process dies - and only on a tree idle for 5 minutes as a second line of defence for when the lock does not exclude. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
126 lines
4.5 KiB
Go
126 lines
4.5 KiB
Go
package modelartifacts
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
type Layout struct {
|
|
Root string
|
|
CacheKey string
|
|
Lock string
|
|
// PartialRoot holds every in-flight materialization for every artifact.
|
|
// Individual writers never share a subtree of it.
|
|
PartialRoot string
|
|
// Partial is this writer's private staging tree. It is empty until
|
|
// WithWriter stamps a writer identity onto the layout, because a partial
|
|
// tree belongs to one process run and the layout alone cannot name it.
|
|
Partial string
|
|
Final string
|
|
Snapshot string
|
|
Manifest string
|
|
}
|
|
|
|
// WithWriter returns a copy of the layout whose partial tree belongs
|
|
// exclusively to writerID.
|
|
//
|
|
// Every writer used to stage into the same `.partial/<cacheKey>`, which made
|
|
// the artifact lock a correctness dependency: two writers that both believed
|
|
// they held it opened the same blob with O_APPEND, interleaved their bytes into
|
|
// one file, and read each other's in-flight size when probing for a resume
|
|
// point (#10981 put a real cluster in exactly that state, because flock(2) on
|
|
// CIFS reports contention as EACCES). Suffixing the writer identity makes that
|
|
// impossible by construction, so a lock failure now costs a duplicated download
|
|
// rather than a corrupted one.
|
|
func (l Layout) WithWriter(writerID string) (Layout, error) {
|
|
if !writerIDPattern.MatchString(writerID) {
|
|
return Layout{}, fmt.Errorf("invalid artifact writer id")
|
|
}
|
|
if !cacheKeyPattern.MatchString(l.CacheKey) || l.PartialRoot == "" {
|
|
return Layout{}, fmt.Errorf("writer layout requires a resolved cache key")
|
|
}
|
|
l.Partial = filepath.Join(l.PartialRoot, l.CacheKey+"."+writerID)
|
|
return l, nil
|
|
}
|
|
|
|
// splitPartialDirName recovers the (cacheKey, writerID) pair a partial tree
|
|
// name encodes, reporting false for anything this package did not create. The
|
|
// sweep and the adoption scan both refuse to touch a name they cannot parse, so
|
|
// an unrelated directory that happens to sit under `.partial` is never removed.
|
|
func splitPartialDirName(name string) (cacheKey string, writerID string, ok bool) {
|
|
key, writer, found := strings.Cut(name, ".")
|
|
if !found || !cacheKeyPattern.MatchString(key) || !writerIDPattern.MatchString(writer) {
|
|
return "", "", false
|
|
}
|
|
return key, writer, true
|
|
}
|
|
|
|
func CacheKey(spec Spec) (string, error) {
|
|
normalized, err := spec.Normalize()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if normalized.Resolved == nil {
|
|
return "", fmt.Errorf("cache key requires resolved artifact state")
|
|
}
|
|
identity := struct {
|
|
Type string `json:"type"`
|
|
Endpoint string `json:"endpoint"`
|
|
Repo string `json:"repo"`
|
|
Revision string `json:"revision"`
|
|
AllowPatterns []string `json:"allow_patterns,omitempty"`
|
|
IgnorePatterns []string `json:"ignore_patterns,omitempty"`
|
|
}{
|
|
Type: normalized.Source.Type, Endpoint: normalized.Resolved.Endpoint,
|
|
Repo: normalized.Source.Repo, Revision: normalized.Resolved.Revision,
|
|
AllowPatterns: normalized.Source.AllowPatterns, IgnorePatterns: normalized.Source.IgnorePatterns,
|
|
}
|
|
encoded, err := json.Marshal(identity)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
sum := sha256.Sum256(encoded)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func RelativeSnapshotPath(cacheKey string) (string, error) {
|
|
if !cacheKeyPattern.MatchString(cacheKey) {
|
|
return "", fmt.Errorf("invalid artifact cache key")
|
|
}
|
|
return filepath.Join(".artifacts", "huggingface", cacheKey, "snapshot"), nil
|
|
}
|
|
|
|
func LayoutFor(modelsPath string, spec Spec) (Layout, error) {
|
|
if spec.Resolved == nil || !cacheKeyPattern.MatchString(spec.Resolved.CacheKey) {
|
|
return Layout{}, fmt.Errorf("layout requires a resolved cache key")
|
|
}
|
|
root := filepath.Join(modelsPath, ".artifacts")
|
|
final := filepath.Join(root, "huggingface", spec.Resolved.CacheKey)
|
|
return Layout{
|
|
Root: root,
|
|
CacheKey: spec.Resolved.CacheKey,
|
|
Lock: filepath.Join(root, ".locks", spec.Resolved.CacheKey+".lock"),
|
|
PartialRoot: filepath.Join(root, ".partial"),
|
|
Final: final,
|
|
Snapshot: filepath.Join(final, "snapshot"),
|
|
Manifest: filepath.Join(final, "manifest.json"),
|
|
}, nil
|
|
}
|
|
|
|
func ValidateRelativeHubPath(candidate string) error {
|
|
if candidate == "" || filepath.IsAbs(candidate) || strings.ContainsAny(candidate, "\\\x00") {
|
|
return fmt.Errorf("unsafe Hub path %q", candidate)
|
|
}
|
|
parts := strings.SplitSeq(candidate, "/")
|
|
for part := range parts {
|
|
if part == "" || part == "." || part == ".." {
|
|
return fmt.Errorf("unsafe Hub path %q", candidate)
|
|
}
|
|
}
|
|
return nil
|
|
}
|