mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-29 17:44:30 -04:00
A request with narrower allow_patterns hashes to a different CacheKey than an already-committed broader sibling, so committedResult misses and materializeLocked re-fetches files the sibling already holds. After the own-tree reuseMaterializedFile miss, consult committed sibling trees for the same Source (type+endpoint+repo+revision), re-verify the file through verifyDownloadedFile (full SHA-256, never size-only), and hard-link it into the writer's staging snapshot (copy fallback only on EXDEV). Each file is matched individually against the sibling's manifest, so a broader request can never inherit a narrower sibling's gaps as if complete. The sibling manifest set is loaded and source-matched once per materialization (files indexed by path) instead of once per staged file, so a models volume with 20 committed artifacts and a 300-file snapshot does one manifest pass rather than ~6000 reads and JSON parses. The sibling-reuse behavior cases live in the package's registered Ginkgo suite so repository test conventions apply. Refs #11047 Signed-off-by: supermario_leo <leo.stack@outlook.com>
231 lines
8.9 KiB
Go
231 lines
8.9 KiB
Go
package modelartifacts_test
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
|
|
"github.com/mudler/LocalAI/pkg/modelartifacts"
|
|
)
|
|
|
|
const siblingReuseRevision = "0123456789abcdef0123456789abcdef01234567"
|
|
|
|
// recordingResolver serves a fixed full file set filtered by each request's
|
|
// allow/ignore patterns, so a narrower request genuinely resolves to a strict
|
|
// subset of a broader sibling's files. The HTTP server behind it records every
|
|
// fetch, which is the signal the sibling-reuse fix is verified through. The
|
|
// function under fix is never mocked: a real Manager drives the real staging +
|
|
// commit path against this stub collaborator.
|
|
type recordingResolver struct {
|
|
endpoint string
|
|
repo string
|
|
files []hfapi.SnapshotFile
|
|
server *httptest.Server
|
|
|
|
mu sync.Mutex
|
|
fetched map[string]int
|
|
}
|
|
|
|
func newRecordingResolver(files []hfapi.SnapshotFile, contents map[string][]byte) *recordingResolver {
|
|
r := &recordingResolver{
|
|
endpoint: "https://huggingface.co",
|
|
repo: "owner/repo",
|
|
files: files,
|
|
fetched: map[string]int{},
|
|
}
|
|
r.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
name := strings.TrimPrefix(req.URL.Path, "/file/")
|
|
body, ok := contents[name]
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
r.mu.Lock()
|
|
r.fetched[name]++
|
|
r.mu.Unlock()
|
|
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
|
|
_, _ = w.Write(body)
|
|
}))
|
|
return r
|
|
}
|
|
|
|
func (r *recordingResolver) ResolveSnapshot(_ context.Context, req hfapi.SnapshotRequest) (hfapi.Snapshot, error) {
|
|
files, err := hfapi.FilterSnapshotFiles(r.files, req.AllowPatterns, req.IgnorePatterns)
|
|
if err != nil {
|
|
return hfapi.Snapshot{}, err
|
|
}
|
|
out := make([]hfapi.SnapshotFile, len(files))
|
|
for i, f := range files {
|
|
f.URL = r.server.URL + "/file/" + f.Path
|
|
out[i] = f
|
|
}
|
|
return hfapi.Snapshot{
|
|
Endpoint: r.endpoint, Repo: r.repo,
|
|
RequestedRevision: req.Revision, ResolvedRevision: siblingReuseRevision, Files: out,
|
|
}, nil
|
|
}
|
|
|
|
func (r *recordingResolver) fetchCount(path string) int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return r.fetched[path]
|
|
}
|
|
|
|
func (r *recordingResolver) resetFetches() {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.fetched = map[string]int{}
|
|
}
|
|
|
|
func siblingReuseFiles(contents map[string][]byte) []hfapi.SnapshotFile {
|
|
paths := []string{"a/first.bin", "b/second.bin", "c/third.bin"}
|
|
files := make([]hfapi.SnapshotFile, 0, len(paths))
|
|
for _, p := range paths {
|
|
sum := sha256.Sum256(contents[p])
|
|
files = append(files, hfapi.SnapshotFile{
|
|
Path: p, Size: int64(len(contents[p])), LFSOID: hex.EncodeToString(sum[:]),
|
|
})
|
|
}
|
|
return files
|
|
}
|
|
|
|
// The narrow-request case proves the fix for #11047:
|
|
// a request with narrower allow_patterns (a strict subset) reuses files an
|
|
// already-committed broader sibling holds, hard-linking instead of re-fetching.
|
|
//
|
|
// On master this is RED: a narrower allow_patterns set hashes to a different
|
|
// CacheKey (path.go:62), so committedResult misses and materializeLocked
|
|
// re-fetches the file (fetches > 0) into a separate copy (no os.SameFile). On
|
|
// the branch it is GREEN: reuseFromCommittedSibling hits the broad sibling,
|
|
// verifies the file via verifyDownloadedFile, and hard-links it (fetches == 0,
|
|
// os.SameFile true).
|
|
var _ = Describe("committed sibling reuse", func() {
|
|
It("reuses files from a broader committed sibling", func() {
|
|
contents := map[string][]byte{
|
|
"a/first.bin": []byte("first-file-bytes"),
|
|
"b/second.bin": []byte("second-file-bytes-longer"),
|
|
"c/third.bin": []byte("third-file"),
|
|
}
|
|
resolver := newRecordingResolver(siblingReuseFiles(contents), contents)
|
|
defer resolver.server.Close()
|
|
|
|
modelsPath := GinkgoT().TempDir()
|
|
manager := modelartifacts.NewManager(resolver,
|
|
modelartifacts.WithLocker(func(string) modelartifacts.Locker { return bypassedLocker{} }))
|
|
|
|
// Commit the broad sibling: all three files, fetched from the resolver.
|
|
broadSpec := modelartifacts.Spec{Source: modelartifacts.Source{
|
|
Type: modelartifacts.SourceTypeHuggingFace, Repo: "owner/repo",
|
|
}}
|
|
broad, err := manager.Ensure(context.Background(), modelsPath, broadSpec)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(broad.CacheHit).To(BeFalse())
|
|
Expect(resolver.fetchCount("a/first.bin")).To(BeNumerically(">", 0),
|
|
"the broad sibling must have fetched a/first.bin to commit it")
|
|
|
|
resolver.resetFetches()
|
|
|
|
// Narrowed request: a strict subset of the broad sibling's file set.
|
|
narrowSpec := modelartifacts.Spec{Source: modelartifacts.Source{
|
|
Type: modelartifacts.SourceTypeHuggingFace, Repo: "owner/repo",
|
|
AllowPatterns: []string{"a/first.bin"},
|
|
}}
|
|
narrow, err := manager.Ensure(context.Background(), modelsPath, narrowSpec)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(narrow.CacheHit).To(BeFalse())
|
|
|
|
// (b) The sibling-present file must NOT be re-fetched: zero fetches. This is
|
|
// the assertion that is RED on master (one fetch) and GREEN on the branch.
|
|
Expect(resolver.fetchCount("a/first.bin")).To(Equal(0),
|
|
"a/first.bin must be reused from the committed broad sibling, not re-fetched")
|
|
|
|
// (a) The narrowed tree's staged file is the same inode as the broad
|
|
// sibling's file (hard-link), not a freshly downloaded second copy. RED on
|
|
// master (separate file), GREEN on the branch (hard-link).
|
|
broadFile := filepath.Join(modelsPath, filepath.FromSlash(broad.RelativePath), "a", "first.bin")
|
|
narrowFile := filepath.Join(modelsPath, filepath.FromSlash(narrow.RelativePath), "a", "first.bin")
|
|
broadInfo, err := os.Stat(broadFile)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
narrowInfo, err := os.Stat(narrowFile)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(os.SameFile(broadInfo, narrowInfo)).To(BeTrue(),
|
|
"the narrowed request must hard-link the broad sibling's file rather than store a second copy")
|
|
|
|
// The reused bytes are intact end to end.
|
|
Expect(os.ReadFile(narrowFile)).To(Equal(contents["a/first.bin"]))
|
|
})
|
|
|
|
// The broader-request case is the manifest file-set guard: a broader request
|
|
// against a narrower committed sibling must still fetch the files the sibling
|
|
// lacks and commit a complete tree. Sibling-reuse can never serve an incomplete
|
|
// model as complete, because each requested file is matched individually against
|
|
// the sibling's manifest.
|
|
It("fetches files missing from a narrower committed sibling", func() {
|
|
contents := map[string][]byte{
|
|
"a/first.bin": []byte("first-file-bytes"),
|
|
"b/second.bin": []byte("second-file-bytes-longer"),
|
|
"c/third.bin": []byte("third-file"),
|
|
}
|
|
resolver := newRecordingResolver(siblingReuseFiles(contents), contents)
|
|
defer resolver.server.Close()
|
|
|
|
modelsPath := GinkgoT().TempDir()
|
|
manager := modelartifacts.NewManager(resolver,
|
|
modelartifacts.WithLocker(func(string) modelartifacts.Locker { return bypassedLocker{} }))
|
|
|
|
// Commit a NARROW sibling first: only a/first.bin and b/second.bin.
|
|
narrowSpec := modelartifacts.Spec{Source: modelartifacts.Source{
|
|
Type: modelartifacts.SourceTypeHuggingFace, Repo: "owner/repo",
|
|
AllowPatterns: []string{"a/first.bin", "b/second.bin"},
|
|
}}
|
|
narrow, err := manager.Ensure(context.Background(), modelsPath, narrowSpec)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
narrowPaths := make([]string, 0, len(narrow.Manifest.Files))
|
|
for _, f := range narrow.Manifest.Files {
|
|
narrowPaths = append(narrowPaths, f.Path)
|
|
}
|
|
Expect(narrowPaths).To(Equal([]string{"a/first.bin", "b/second.bin"}))
|
|
|
|
resolver.resetFetches()
|
|
|
|
// A BROADER request asks for all three files, including c/third.bin which the
|
|
// narrow sibling does not hold.
|
|
broadSpec := modelartifacts.Spec{Source: modelartifacts.Source{
|
|
Type: modelartifacts.SourceTypeHuggingFace, Repo: "owner/repo",
|
|
}}
|
|
broad, err := manager.Ensure(context.Background(), modelsPath, broadSpec)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// The file the narrow sibling lacks MUST be fetched: sibling-reuse must not
|
|
// inherit a narrower tree's gaps as if the broad request were complete.
|
|
Expect(resolver.fetchCount("c/third.bin")).To(BeNumerically(">", 0),
|
|
"c/third.bin is absent from the narrow sibling and must be fetched, not served as complete")
|
|
|
|
// The broad tree's manifest file set is exactly the full set — never the
|
|
// narrow sibling's subset. This file-set comparison proves no incomplete model
|
|
// is ever served as complete via sibling-reuse.
|
|
broadPaths := make([]string, 0, len(broad.Manifest.Files))
|
|
for _, f := range broad.Manifest.Files {
|
|
broadPaths = append(broadPaths, f.Path)
|
|
}
|
|
Expect(broadPaths).To(Equal([]string{"a/first.bin", "b/second.bin", "c/third.bin"}))
|
|
|
|
// Every file is present on disk with the right bytes after commit.
|
|
for _, p := range []string{"a/first.bin", "b/second.bin", "c/third.bin"} {
|
|
Expect(os.ReadFile(filepath.Join(modelsPath, filepath.FromSlash(broad.RelativePath), filepath.FromSlash(p)))).
|
|
To(Equal(contents[p]))
|
|
}
|
|
})
|
|
})
|