Files
LocalAI/pkg/modelartifacts/path.go
mudler's LocalAI [bot] f01038f479 fix(modelartifacts): stage each writer's artifact in its own partial tree (#10995)
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>
2026-07-20 23:07:43 +02:00

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
}