Skip to content

Commit ec49548

Browse files
localai-botmudler
andauthored
fix(modelartifacts): resume interrupted materialization per-file, not from scratch (#11071)
materializeLocked built a download task for every file in the resolved snapshot unconditionally. A completed file is promoted from .downloads/<hash> into snapshot/<path> and its blob deleted, so on any re-entry (a controller pod roll, a resubmit, a crash) the new pass built a task whose .downloads blob no longer existed, re-downloaded the whole file from Hugging Face, and its AfterDownload even removed the already-complete snapshot copy first. The only resume that worked was the downloader's per-file .partial resume for a file caught mid-transfer; completed files were never skipped. Production consequence: installing longcat-video-avatar-1.5 (~35 GB after allow_patterns) on a cluster whose controller rolls hourly (Flux image automation) never converged across ~14 hours. Each roll restarted from the first shard; the completed bytes on disk were repeatedly deleted and re-fetched, and the artifact never promoted. curl of the same files from inside the pod ran fine, proving the loss was the materializer re-fetching, not the network. Before building a task, check whether the file is already materialized and verified in this staging tree's snapshot/ and, if so, keep it and count it complete instead of downloading. "Materialized" means a regular file of the expected size that passes the same verifyDownloadedFile check the download path uses, so the kept manifest entry is byte-for-byte identical to a fresh one and integrity is re-checked. The manifest requires a SHA-256 for every file and non-LFS files carry none to borrow, so a hash is unavoidable for the manifest anyway; a full re-hash of local disk is still orders of magnitude cheaper than re-downloading, and the downloader re-verifies any file it does fetch. Manifest entries are now written at their snapshot index rather than appended in completion order, so a mix of skipped and downloaded files keeps the resolved order that committedResult and staging read. The unconditional root.Remove(destination) now runs only on the fresh-download path; a kept file survives. Skips are logged at INFO with count and bytes so an operator can see resume working. This is the resume-side counterpart to the sibling defects on this path: read/write error conflation and transient retry (#10985), hash-verify progress accounting and silent success on an expired deadline (#11026), and the response-header hang (#11053). The download machinery resumed a single in-flight file; the materializer above it still threw away every completed file on restart. It also makes orphan-partial adoption worth its cost: an adopted tree's completed files were re-downloaded anyway until now. Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 95afddd commit ec49548

2 files changed

Lines changed: 167 additions & 4 deletions

File tree

pkg/modelartifacts/materializer.go

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -370,18 +370,42 @@ func (m *Manager) materializeLocked(ctx context.Context, modelsPath string, spec
370370
totalBytes += file.Size
371371
}
372372

373-
manifest := Manifest{Version: ManifestVersion, Artifact: spec, Files: make([]ManifestFile, 0, len(snapshot.Files))}
373+
// Files land in manifest.Files at their snapshot index, not in completion
374+
// order, so a mix of skipped and freshly downloaded files still records the
375+
// manifest in the resolved snapshot's order. committedResult and staging both
376+
// read this manifest, and getting its order or contents wrong would make a
377+
// corrupt tree look valid.
378+
manifest := Manifest{Version: ManifestVersion, Artifact: spec, Files: make([]ManifestFile, len(snapshot.Files))}
374379
completedBytes := int64(0)
380+
skippedFiles := 0
381+
skippedBytes := int64(0)
375382
tasks := make([]downloader.FileTask, 0, len(snapshot.Files))
376383
for index, file := range snapshot.Files {
377384
if err := ctx.Err(); err != nil {
378385
return Result{}, err
379386
}
387+
file := file
388+
taskIndex := index
389+
// A file already present and verified in this staging tree survives a
390+
// restart: an interrupted pass promotes each completed file into
391+
// snapshot/ before it moves on, so on re-entry (a resubmit, a controller
392+
// roll, an adopted orphan) we must resume past it rather than re-fetch
393+
// tens of gigabytes from scratch. Verification reuses the exact happy-path
394+
// check so the recorded manifest entry is byte-for-byte identical to the
395+
// one a fresh download would have produced; a file that fails it falls
396+
// through to a normal re-download.
397+
snapshotRel := path.Join("snapshot", file.Path)
398+
snapshotAbs := filepath.Join(layout.Partial, filepath.FromSlash(snapshotRel))
399+
if entry, ok := reuseMaterializedFile(snapshotAbs, file); ok {
400+
manifest.Files[taskIndex] = entry
401+
completedBytes += file.Size
402+
skippedFiles++
403+
skippedBytes += file.Size
404+
continue
405+
}
380406
nameSum := sha256.Sum256([]byte(file.Path))
381407
blobRel := path.Join(".downloads", hex.EncodeToString(nameSum[:]))
382408
blobAbs := filepath.Join(layout.Partial, filepath.FromSlash(blobRel))
383-
file := file
384-
taskIndex := index
385409
task := downloader.FileTask{
386410
URI: downloader.URI(file.URL),
387411
Destination: blobAbs,
@@ -421,17 +445,32 @@ func (m *Manager) materializeLocked(ctx context.Context, modelsPath string, spec
421445
if err := root.MkdirAll(path.Dir(destination), 0o750); err != nil {
422446
return err
423447
}
448+
// A freshly downloaded file replaces whatever sits at the
449+
// destination (a stale or unverifiable leftover); a file we chose
450+
// to keep never reaches this path, so the removal only ever
451+
// discards bytes we are about to overwrite.
424452
_ = root.Remove(destination)
425453
if err := root.Rename(blobRel, destination); err != nil {
426454
return err
427455
}
428-
manifest.Files = append(manifest.Files, entry)
456+
manifest.Files[taskIndex] = entry
429457
completedBytes += file.Size
430458
return nil
431459
},
432460
}
433461
tasks = append(tasks, task)
434462
}
463+
// Surface resume at INFO: the absence of this signal is part of what made a
464+
// never-converging download invisible in production, where each restart
465+
// silently re-fetched every completed file.
466+
if skippedFiles > 0 {
467+
xlog.Info("resuming artifact materialization; keeping already-completed files",
468+
"artifact", spec.Name,
469+
"skipped_files", skippedFiles,
470+
"skipped_bytes", skippedBytes,
471+
"remaining_files", len(tasks),
472+
"total_files", len(snapshot.Files))
473+
}
435474
if err := downloader.DownloadFilesWithContext(ctx, tasks, nil); err != nil {
436475
return Result{}, err
437476
}
@@ -487,6 +526,34 @@ func (m *Manager) commit(modelsPath string, spec Spec, layout Layout, manifest M
487526
return Result{Spec: spec, RelativePath: relative, Manifest: manifest}, nil
488527
}
489528

529+
// reuseMaterializedFile reports whether a file already staged in this tree's
530+
// snapshot/ can be kept as-is, returning the manifest entry it should
531+
// contribute. It is the resume counterpart to the download path: a completed
532+
// file is promoted into snapshot/ before the pass moves on, so on re-entry we
533+
// verify what is there and skip the fetch instead of restarting from the first
534+
// shard.
535+
//
536+
// Verification is a full re-hash via the same verifyDownloadedFile the happy
537+
// path uses, not a size-only check. The manifest requires a SHA-256 for every
538+
// file, and a non-LFS file carries no precomputed SHA-256 to borrow, so a hash
539+
// is unavoidable for the manifest's sake; doing it through the shared verifier
540+
// also guarantees the kept entry is byte-for-byte identical to a freshly
541+
// downloaded one and re-checks integrity for free. Reading a large file from
542+
// local disk is still orders of magnitude cheaper than re-downloading it. A
543+
// file that is missing, the wrong size, or fails verification is not reused; the
544+
// caller re-downloads it.
545+
func reuseMaterializedFile(fileName string, source hfapi.SnapshotFile) (ManifestFile, bool) {
546+
info, err := os.Stat(fileName)
547+
if err != nil || !info.Mode().IsRegular() || info.Size() != source.Size {
548+
return ManifestFile{}, false
549+
}
550+
entry, err := verifyDownloadedFile(fileName, source)
551+
if err != nil {
552+
return ManifestFile{}, false
553+
}
554+
return entry, true
555+
}
556+
490557
func verifyDownloadedFile(fileName string, source hfapi.SnapshotFile) (ManifestFile, error) {
491558
file, err := os.Open(fileName)
492559
if err != nil {

pkg/modelartifacts/materializer_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import (
88
"net/http/httptest"
99
"os"
1010
"path/filepath"
11+
"strconv"
12+
"strings"
1113
"sync"
1214
"sync/atomic"
1315

@@ -151,6 +153,100 @@ var _ = Describe("controller artifact materializer", func() {
151153
Expect(entries).To(BeEmpty())
152154
})
153155

156+
It("resumes an interrupted materialization without re-downloading completed files", func() {
157+
contents := map[string][]byte{
158+
"a/first.bin": []byte("first-file-bytes"),
159+
"b/second.bin": []byte("second-file-bytes-longer"),
160+
"third.bin": []byte("third"),
161+
}
162+
// order fixes both the download sequence and the expected manifest order.
163+
order := []string{"a/first.bin", "b/second.bin", "third.bin"}
164+
oid := func(b []byte) string { sum := sha256.Sum256(b); return hex.EncodeToString(sum[:]) }
165+
166+
var mu sync.Mutex
167+
fetched := map[string]int{}
168+
// During run 1 this file 404s, interrupting the pass after the first file
169+
// has already completed and been promoted into the staging snapshot.
170+
failing := "b/second.bin"
171+
armed := true
172+
173+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
174+
name := strings.TrimPrefix(r.URL.Path, "/file/")
175+
body, ok := contents[name]
176+
if !ok {
177+
w.WriteHeader(http.StatusNotFound)
178+
return
179+
}
180+
mu.Lock()
181+
arm := armed
182+
mu.Unlock()
183+
if arm && name == failing {
184+
w.WriteHeader(http.StatusNotFound)
185+
return
186+
}
187+
mu.Lock()
188+
fetched[name]++
189+
mu.Unlock()
190+
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
191+
_, _ = w.Write(body)
192+
}))
193+
DeferCleanup(server.Close)
194+
195+
files := make([]hfapi.SnapshotFile, 0, len(order))
196+
for _, p := range order {
197+
files = append(files, hfapi.SnapshotFile{
198+
Path: p, Size: int64(len(contents[p])), LFSOID: oid(contents[p]),
199+
URL: server.URL + "/file/" + p,
200+
})
201+
}
202+
resolver := &fakeSnapshotResolver{snapshot: hfapi.Snapshot{
203+
Endpoint: "https://huggingface.co", Repo: "owner/repo",
204+
ResolvedRevision: "0123456789abcdef0123456789abcdef01234567",
205+
Files: files,
206+
}}
207+
// A single manager (one writer identity) re-enters the same staging tree on
208+
// the second call, exactly as a resubmit against a still-idle partial does.
209+
manager := modelartifacts.NewManager(resolver)
210+
modelsPath := GinkgoT().TempDir()
211+
spec := modelartifacts.Spec{Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo"}}
212+
213+
_, err := manager.Ensure(context.Background(), modelsPath, spec)
214+
Expect(err).To(HaveOccurred())
215+
mu.Lock()
216+
Expect(fetched["a/first.bin"]).To(Equal(1), "the first file should have completed in run 1")
217+
armed = false
218+
fetched = map[string]int{}
219+
mu.Unlock()
220+
221+
result, err := manager.Ensure(context.Background(), modelsPath, spec)
222+
Expect(err).NotTo(HaveOccurred())
223+
224+
mu.Lock()
225+
defer mu.Unlock()
226+
// The decisive assertion: the already-completed file is NOT re-downloaded.
227+
Expect(fetched).NotTo(HaveKey("a/first.bin"))
228+
// Only the files missing after the interruption are fetched on resume.
229+
Expect(fetched).To(HaveKey("b/second.bin"))
230+
Expect(fetched).To(HaveKey("third.bin"))
231+
232+
// The manifest lists every file, in order, with the correct size and hash.
233+
paths := make([]string, 0, len(result.Manifest.Files))
234+
byPath := map[string]modelartifacts.ManifestFile{}
235+
for _, f := range result.Manifest.Files {
236+
paths = append(paths, f.Path)
237+
byPath[f.Path] = f
238+
}
239+
Expect(paths).To(Equal(order))
240+
for _, p := range order {
241+
Expect(byPath[p].Size).To(Equal(int64(len(contents[p]))))
242+
Expect(byPath[p].SHA256).To(Equal(oid(contents[p])))
243+
}
244+
// Every file, skipped or freshly downloaded, is present on disk after commit.
245+
for _, p := range order {
246+
Expect(os.ReadFile(filepath.Join(modelsPath, filepath.FromSlash(result.RelativePath), filepath.FromSlash(p)))).To(Equal(contents[p]))
247+
}
248+
})
249+
154250
It("emits resolving, downloading, verifying, and committing phases", func() {
155251
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("x")) }))
156252
DeferCleanup(server.Close)

0 commit comments

Comments
 (0)