Files
LocalAI/pkg/modelartifacts/partial.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

208 lines
7.4 KiB
Go

package modelartifacts
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
"github.com/mudler/xlog"
)
const (
// PartialOrphanTTL is how long an unclaimed partial tree survives before a
// sweep reclaims it. Writer-unique staging means a crashed writer's tree is
// never overwritten by its successor, so without reaping, every crash
// during a large download leaks its bytes onto a shared volume forever.
//
// It matches the window the startup reaper already uses for stray
// *.partial files, and it is orders of magnitude beyond any write-free
// interval a live writer can have: the download stall guard aborts a silent
// transfer after DownloadStallTimeout, and the only other gap between
// writes is hashing one downloaded file.
PartialOrphanTTL = 24 * time.Hour
// partialAdoptionIdleWindow is how long a foreign partial tree for the same
// artifact must have been untouched before this writer takes it over and
// resumes from it. Without adoption, writer-unique staging would silently
// cost the resume that a restarted process relies on to finish a
// multi-tens-of-gigabytes repo.
//
// Adoption is normally justified by the artifact lock alone: it only ever
// runs while this process holds that lock, and flock is released exactly
// when the owning process dies, so a peer that respects the lock is
// provably not writing. The idle window is the second line of defence for
// the case this whole change is about, where the lock fails to exclude.
//
// The window is a deliberate compromise in both directions. Too long and a
// restarted replica re-downloads from zero; too short and a broken lock
// lets us claim a live peer's tree. Both cost the same thing - one wasted
// download - because every adopted blob is still SHA-verified before it is
// promoted, so a wrongly adopted tree fails verification instead of
// committing corruption.
partialAdoptionIdleWindow = 5 * time.Minute
)
// newWriterID draws the identity that makes this process run's partial tree its
// own.
//
// It has to be unique across every process that can reach the same models
// volume. That rules out the PID, which repeats freely across containers
// sharing a NAS mount, and the hostname, which a restarted pod can inherit
// while the dead pod's tree is still on disk. 64 bits of crypto/rand depends on
// nothing outside this process; a collision would need two replicas to draw the
// same value in the same 24h window, and even then it degrades to the
// shared-tree behaviour that was the status quo, not to something worse.
func newWriterID() string {
var raw [8]byte
// crypto/rand.Read never returns an error; it panics if the system RNG is
// unusable, which is not a condition worth threading through this API.
_, _ = rand.Read(raw[:])
return hex.EncodeToString(raw[:])
}
// newestModTime reports the most recent modification anywhere in tree.
//
// The directory's own mtime is not enough: writing bytes into
// `.downloads/<blob>.partial` does not touch any ancestor, so a busy writer's
// top-level directory can look hours old. Reading the whole subtree is what
// makes "is anyone still working here?" answerable. Unreadable entries are
// skipped rather than aborting the walk, but they count as "now" so a tree we
// cannot fully inspect is never judged stale.
func newestModTime(tree string) (time.Time, error) {
newest := time.Time{}
err := filepath.WalkDir(tree, func(_ string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
newest = time.Now()
return nil
}
info, err := entry.Info()
if err != nil {
newest = time.Now()
return nil
}
if info.ModTime().After(newest) {
newest = info.ModTime()
}
return nil
})
return newest, err
}
// idleFor reports whether tree has seen no modification for at least window.
func idleFor(tree string, window time.Duration) bool {
newest, err := newestModTime(tree)
if err != nil {
return false
}
return time.Since(newest) >= window
}
// removePartialTree deletes one staging tree, refusing any path that is not a
// direct, correctly named child of the partial root. Writer-unique staging
// turned a self-healing overwrite into an explicit delete, and an explicit
// delete on a shared models volume is worth being paranoid about.
func removePartialTree(partialRoot, tree string) error {
name := filepath.Base(tree)
if filepath.Dir(tree) != partialRoot {
return fmt.Errorf("refusing to remove partial tree outside %q", partialRoot)
}
if _, _, ok := splitPartialDirName(name); !ok {
return fmt.Errorf("refusing to remove unrecognised partial tree %q", name)
}
root, err := os.OpenRoot(partialRoot)
if err != nil {
return err
}
defer func() { _ = root.Close() }()
return root.RemoveAll(name)
}
// adoptOrphanPartial hands this writer a dead peer's staging tree for the same
// artifact, so a download interrupted by a crash or a restart resumes instead
// of starting over.
//
// The claim is a rename, which is atomic: two writers racing for the same
// orphan cannot both win, and the loser simply starts fresh. A failure to adopt
// is never fatal - it costs bytes, not correctness - so every error here is
// logged and swallowed.
func adoptOrphanPartial(layout Layout) {
entries, err := os.ReadDir(layout.PartialRoot)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
candidate := filepath.Join(layout.PartialRoot, name)
if candidate == layout.Partial {
continue
}
cacheKey, _, ok := splitPartialDirName(name)
if !ok || cacheKey != layout.CacheKey {
continue
}
if !idleFor(candidate, partialAdoptionIdleWindow) {
continue
}
if err := os.Rename(candidate, layout.Partial); err != nil {
xlog.Debug("could not adopt an abandoned artifact partial", "partial", candidate, "error", err)
continue
}
xlog.Info("resuming an abandoned artifact download", "partial", candidate, "adopted-as", layout.Partial)
return
}
}
// SweepStalePartialTrees reclaims staging trees left behind by writers that
// never finished, returning how many it removed.
//
// A tree is removed only when its name is one this package writes, it is not
// the caller's own, and nothing anywhere inside it has been touched for
// olderThan. That last condition is what makes the sweep safe against a live
// writer: a running download writes continuously, so its newest mtime is
// seconds old, and a stalled one is aborted by the downloader's own watchdog
// long before it could look abandoned.
//
// A missing tree is not an error, and one unremovable entry does not abort the
// sweep.
func SweepStalePartialTrees(modelsPath string, olderThan time.Duration, keep string) (int, error) {
partialRoot := filepath.Join(modelsPath, ".artifacts", ".partial")
entries, err := os.ReadDir(partialRoot)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
removed := 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
tree := filepath.Join(partialRoot, name)
if tree == keep {
continue
}
if _, _, ok := splitPartialDirName(name); !ok {
continue
}
if !idleFor(tree, olderThan) {
continue
}
if err := removePartialTree(partialRoot, tree); err != nil {
xlog.Warn("failed to remove abandoned artifact partial", "partial", tree, "error", err)
continue
}
removed++
xlog.Info("removed abandoned artifact partial", "partial", tree)
}
return removed, nil
}