mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
Avoid holding the global loader lock across backend lifecycle waits and propagate forced shutdown through distributed workers. Track parallel requests with in-flight counters and reserve worker ports until process termination. Add focused race tests and an authoritative FizzBee lifecycle model with a fail-closed conformance target. Assisted-by: Codex:GPT-5 [FizzBee] [Ginkgo] Signed-off-by: Richard Palethorpe <io@richiejp.com>
608 lines
20 KiB
Go
608 lines
20 KiB
Go
package model_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// lifecycleBackend embeds the full backend interface so these tests only need
|
|
// to override the lifecycle methods they exercise. A nil embedded backend is
|
|
// safe because no inference method is called.
|
|
type lifecycleBackend struct {
|
|
grpcPkg.Backend
|
|
busy atomic.Bool
|
|
freeOnce sync.Once
|
|
freeStarted chan struct{}
|
|
freeRelease chan struct{}
|
|
}
|
|
|
|
type failingRemoteUnloader struct {
|
|
err error
|
|
}
|
|
|
|
func (u failingRemoteUnloader) UnloadRemoteModel(string) error {
|
|
return u.err
|
|
}
|
|
|
|
func newLifecycleBackend() *lifecycleBackend {
|
|
return &lifecycleBackend{
|
|
freeStarted: make(chan struct{}),
|
|
freeRelease: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (b *lifecycleBackend) IsBusy() bool {
|
|
return b.busy.Load()
|
|
}
|
|
|
|
func (b *lifecycleBackend) Free(ctx context.Context) error {
|
|
b.freeOnce.Do(func() { close(b.freeStarted) })
|
|
select {
|
|
case <-b.freeRelease:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
var _ = Describe("ModelLoader", func() {
|
|
var (
|
|
modelLoader *model.ModelLoader
|
|
modelPath string
|
|
mockModel *model.Model
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
// Setup the model loader with a test directory
|
|
modelPath = "/tmp/test_model_path"
|
|
os.Mkdir(modelPath, 0755)
|
|
|
|
systemState, err := system.GetSystemState(
|
|
system.WithModelPath(modelPath),
|
|
)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
modelLoader = model.NewModelLoader(systemState)
|
|
})
|
|
|
|
AfterEach(func() {
|
|
// Cleanup test directory
|
|
os.RemoveAll(modelPath)
|
|
})
|
|
|
|
Context("NewModelLoader", func() {
|
|
It("should create a new ModelLoader with an empty model map", func() {
|
|
Expect(modelLoader).ToNot(BeNil())
|
|
Expect(modelLoader.ModelPath).To(Equal(modelPath))
|
|
Expect(modelLoader.ListLoadedModels()).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Context("ExistsInModelPath", func() {
|
|
It("should return true if a file exists in the model path", func() {
|
|
testFile := filepath.Join(modelPath, "test.model")
|
|
os.Create(testFile)
|
|
Expect(modelLoader.ExistsInModelPath("test.model")).To(BeTrue())
|
|
})
|
|
|
|
It("should return false if a file does not exist in the model path", func() {
|
|
Expect(modelLoader.ExistsInModelPath("nonexistent.model")).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Context("ListFilesInModelPath", func() {
|
|
It("should list all valid model files in the model path", func() {
|
|
os.Create(filepath.Join(modelPath, "test.model"))
|
|
os.Create(filepath.Join(modelPath, "model.gguf"))
|
|
os.Create(filepath.Join(modelPath, "README.md"))
|
|
|
|
files, err := modelLoader.ListFilesInModelPath()
|
|
Expect(err).To(BeNil())
|
|
Expect(files).To(ContainElement("test.model"))
|
|
Expect(files).ToNot(ContainElement("model.gguf"))
|
|
Expect(files).ToNot(ContainElement("README.md"))
|
|
})
|
|
})
|
|
|
|
Context("LoadModel", func() {
|
|
It("passes a logical model and managed model file independently", func() {
|
|
const relative = ".artifacts/huggingface/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/snapshot"
|
|
var receivedName, receivedFile string
|
|
mockModel = model.NewModel("managed", "test.model", nil)
|
|
mockModel.MarkHealthy()
|
|
mockLoader := func(_ string, modelName, modelFile string) (*model.Model, error) {
|
|
receivedName, receivedFile = modelName, modelFile
|
|
return mockModel, nil
|
|
}
|
|
|
|
_, err := modelLoader.LoadModelWithFile("managed", "owner/repo", relative, mockLoader)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(receivedName).To(Equal("owner/repo"))
|
|
Expect(receivedFile).To(Equal(filepath.Join(modelPath, filepath.FromSlash(relative))))
|
|
})
|
|
|
|
It("should load a model and keep it in memory", func() {
|
|
mockModel = model.NewModel("foo", "test.model", nil)
|
|
mockModel.MarkHealthy() // skip gRPC health check (no real server)
|
|
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
return mockModel, nil
|
|
}
|
|
|
|
model, err := modelLoader.LoadModel("foo", "test.model", mockLoader)
|
|
Expect(err).To(BeNil())
|
|
Expect(model).To(Equal(mockModel))
|
|
Expect(modelLoader.CheckIsLoaded("foo")).To(Equal(mockModel))
|
|
})
|
|
|
|
It("should return an error if loading the model fails", func() {
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
return nil, errors.New("failed to load model")
|
|
}
|
|
|
|
model, err := modelLoader.LoadModel("foo", "test.model", mockLoader)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(model).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Context("Remote model eviction", func() {
|
|
It("should evict unreachable remote models from cache on health check", func() {
|
|
// Create a remote model (process=nil) with an unreachable address
|
|
remoteModel := model.NewModel("remote-test", "127.0.0.1:1", nil)
|
|
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
return remoteModel, nil
|
|
}
|
|
|
|
_, err := modelLoader.LoadModel("remote-test", "test.model", mockLoader)
|
|
Expect(err).To(BeNil())
|
|
|
|
// CheckIsLoaded should detect the connection error and evict
|
|
result := modelLoader.CheckIsLoaded("remote-test")
|
|
Expect(result).To(BeNil(), "unreachable remote model should be evicted from cache")
|
|
})
|
|
|
|
It("should keep recently-healthy remote models in cache", func() {
|
|
remoteModel := model.NewModel("healthy-remote", "127.0.0.1:1", nil)
|
|
remoteModel.MarkHealthy() // simulate a recent successful health check
|
|
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
return remoteModel, nil
|
|
}
|
|
|
|
loaded, err := modelLoader.LoadModel("healthy-remote", "test.model", mockLoader)
|
|
Expect(err).To(BeNil())
|
|
|
|
// Within TTL, should return the model without health check
|
|
result := modelLoader.CheckIsLoaded("healthy-remote")
|
|
Expect(result).To(Equal(loaded), "recently-healthy model should be returned from cache")
|
|
})
|
|
})
|
|
|
|
Context("ShutdownModel", func() {
|
|
It("should shutdown a loaded model", func() {
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
return model.NewModel("foo", "test.model", nil), nil
|
|
}
|
|
|
|
_, err := modelLoader.LoadModel("foo", "test.model", mockLoader)
|
|
Expect(err).To(BeNil())
|
|
|
|
err = modelLoader.ShutdownModel("foo")
|
|
Expect(err).To(BeNil())
|
|
Expect(modelLoader.CheckIsLoaded("foo")).To(BeNil())
|
|
})
|
|
|
|
It("evicts the local remote-model entry when remote unload fails", func() {
|
|
remote := model.NewModel("remote", "worker.example:50051", nil)
|
|
remote.MarkHealthy()
|
|
_, err := modelLoader.LoadModel("remote", "remote", func(_, _, _ string) (*model.Model, error) {
|
|
return remote, nil
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
unloadErr := errors.New("worker unreachable")
|
|
modelLoader.SetRemoteUnloader(failingRemoteUnloader{err: unloadErr})
|
|
Expect(modelLoader.ShutdownModel("remote")).To(MatchError(unloadErr))
|
|
Expect(modelLoader.ListLoadedModels()).To(BeEmpty())
|
|
|
|
replacement := model.NewModel("remote", "replacement.example:50051", nil)
|
|
replacement.MarkHealthy()
|
|
var reloads atomic.Int32
|
|
loaded, err := modelLoader.LoadModel("remote", "remote", func(_, _, _ string) (*model.Model, error) {
|
|
reloads.Add(1)
|
|
return replacement, nil
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(loaded).To(BeIdenticalTo(replacement))
|
|
Expect(reloads.Load()).To(Equal(int32(1)))
|
|
})
|
|
})
|
|
|
|
Context("Shutdown lifecycle conformance", func() {
|
|
loadBackend := func(id string, backend grpcPkg.Backend) {
|
|
_, err := modelLoader.LoadModel(id, id, func(_, _, _ string) (*model.Model, error) {
|
|
return model.NewModelWithClient(id, id, backend), nil
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
}
|
|
|
|
It("force shutdown bypasses a permanently busy backend and leaves the loader available", func() {
|
|
stuck := newLifecycleBackend()
|
|
stuck.busy.Store(true)
|
|
loadBackend("stuck", stuck)
|
|
|
|
shutdownDone := make(chan error, 1)
|
|
go func() { shutdownDone <- modelLoader.ShutdownModelForce("stuck") }()
|
|
|
|
var shutdownErr error
|
|
Eventually(shutdownDone, "500ms").Should(Receive(&shutdownErr))
|
|
Expect(shutdownErr).NotTo(HaveOccurred())
|
|
Consistently(stuck.freeStarted, "50ms").ShouldNot(Receive(), "force shutdown must skip Free on a stuck backend")
|
|
|
|
other := newLifecycleBackend()
|
|
loadBackend("unrelated", other)
|
|
Expect(modelLoader.ListLoadedModels()).To(ConsistOf(HaveField("ID", "unrelated")))
|
|
})
|
|
|
|
It("keeps unrelated loads available while graceful Free is blocked", func() {
|
|
blocked := newLifecycleBackend()
|
|
loadBackend("blocked", blocked)
|
|
|
|
shutdownDone := make(chan error, 1)
|
|
go func() { shutdownDone <- modelLoader.ShutdownModel("blocked") }()
|
|
Eventually(blocked.freeStarted, "500ms").Should(BeClosed())
|
|
|
|
otherDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := modelLoader.LoadModel("unrelated", "unrelated", func(_, _, _ string) (*model.Model, error) {
|
|
return model.NewModelWithClient("unrelated", "unrelated", newLifecycleBackend()), nil
|
|
})
|
|
otherDone <- err
|
|
}()
|
|
|
|
var otherErr error
|
|
Eventually(otherDone, "500ms").Should(Receive(&otherErr))
|
|
Expect(otherErr).NotTo(HaveOccurred())
|
|
Consistently(shutdownDone, "50ms").ShouldNot(Receive(), "shutdown must still be waiting for Free")
|
|
close(blocked.freeRelease)
|
|
|
|
var shutdownErr error
|
|
Eventually(shutdownDone, "500ms").Should(Receive(&shutdownErr))
|
|
Expect(shutdownErr).NotTo(HaveOccurred())
|
|
})
|
|
|
|
It("serializes a same-model reload behind shutdown", func() {
|
|
blocked := newLifecycleBackend()
|
|
loadBackend("replace-me", blocked)
|
|
|
|
shutdownDone := make(chan error, 1)
|
|
go func() { shutdownDone <- modelLoader.ShutdownModel("replace-me") }()
|
|
Eventually(blocked.freeStarted, "500ms").Should(BeClosed())
|
|
|
|
reloadStarted := make(chan struct{})
|
|
reloadDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := modelLoader.LoadModel("replace-me", "replace-me", func(_, _, _ string) (*model.Model, error) {
|
|
close(reloadStarted)
|
|
return model.NewModelWithClient("replace-me", "replace-me", newLifecycleBackend()), nil
|
|
})
|
|
reloadDone <- err
|
|
}()
|
|
|
|
Consistently(reloadStarted, "50ms").ShouldNot(BeClosed())
|
|
close(blocked.freeRelease)
|
|
|
|
Eventually(shutdownDone, "500ms").Should(Receive(Succeed()))
|
|
Eventually(reloadStarted, "500ms").Should(BeClosed())
|
|
Eventually(reloadDone, "500ms").Should(Receive(Succeed()))
|
|
})
|
|
|
|
It("bounds a graceful wait for a permanently busy backend without blocking other models", func() {
|
|
stuck := newLifecycleBackend()
|
|
stuck.busy.Store(true)
|
|
loadBackend("stuck", stuck)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
shutdownDone := make(chan error, 1)
|
|
go func() { shutdownDone <- modelLoader.ShutdownModelContext(ctx, "stuck", false) }()
|
|
|
|
other := newLifecycleBackend()
|
|
loadBackend("unrelated", other)
|
|
|
|
var shutdownErr error
|
|
Eventually(shutdownDone, "500ms").Should(Receive(&shutdownErr))
|
|
Expect(errors.Is(shutdownErr, model.ErrModelBusy)).To(BeTrue())
|
|
Expect(modelLoader.ListLoadedModels()).To(ConsistOf(
|
|
HaveField("ID", "stuck"),
|
|
HaveField("ID", "unrelated"),
|
|
))
|
|
})
|
|
|
|
It("honors cancellation while waiting for an in-progress load of the same model", func() {
|
|
loadStarted := make(chan struct{})
|
|
loadRelease := make(chan struct{})
|
|
loadDone := make(chan error, 1)
|
|
go func() {
|
|
_, err := modelLoader.LoadModel("loading", "loading", func(_, _, _ string) (*model.Model, error) {
|
|
close(loadStarted)
|
|
<-loadRelease
|
|
return model.NewModelWithClient("loading", "loading", newLifecycleBackend()), nil
|
|
})
|
|
loadDone <- err
|
|
}()
|
|
Eventually(loadStarted, "500ms").Should(BeClosed())
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
defer cancel()
|
|
err := modelLoader.ShutdownModelContext(ctx, "loading", false)
|
|
Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue())
|
|
|
|
close(loadRelease)
|
|
Eventually(loadDone, "500ms").Should(Receive(Succeed()))
|
|
})
|
|
})
|
|
|
|
Context("Concurrent Loading", func() {
|
|
It("should handle concurrent requests for the same model", func() {
|
|
var loadCount int32
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
atomic.AddInt32(&loadCount, 1)
|
|
time.Sleep(100 * time.Millisecond) // Simulate loading time
|
|
m := model.NewModel(modelID, modelName, nil)
|
|
m.MarkHealthy() // skip gRPC health check (no real server)
|
|
return m, nil
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
results := make([]*model.Model, 5)
|
|
errs := make([]error, 5)
|
|
|
|
// Start 5 concurrent requests for the same model
|
|
for i := range 5 {
|
|
wg.Go(func() {
|
|
results[i], errs[i] = modelLoader.LoadModel("concurrent-model", "test.model", mockLoader)
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// All requests should succeed
|
|
for i := range 5 {
|
|
Expect(errs[i]).To(BeNil())
|
|
Expect(results[i]).ToNot(BeNil())
|
|
}
|
|
|
|
// The loader should only have been called once
|
|
Expect(atomic.LoadInt32(&loadCount)).To(Equal(int32(1)))
|
|
|
|
// All results should be the same model instance
|
|
for i := 1; i < 5; i++ {
|
|
Expect(results[i]).To(Equal(results[0]))
|
|
}
|
|
})
|
|
|
|
It("should handle concurrent requests for different models", func() {
|
|
var loadCount int32
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
atomic.AddInt32(&loadCount, 1)
|
|
time.Sleep(50 * time.Millisecond) // Simulate loading time
|
|
m := model.NewModel(modelID, modelName, nil)
|
|
m.MarkHealthy() // skip gRPC health check (no real server)
|
|
return m, nil
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
modelCount := 3
|
|
|
|
// Start concurrent requests for different models
|
|
for i := range modelCount {
|
|
wg.Go(func() {
|
|
modelID := "model-" + string(rune('A'+i))
|
|
_, err := modelLoader.LoadModel(modelID, "test.model", mockLoader)
|
|
Expect(err).To(BeNil())
|
|
})
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
// Each model should be loaded exactly once
|
|
Expect(atomic.LoadInt32(&loadCount)).To(Equal(int32(modelCount)))
|
|
|
|
// All models should be loaded
|
|
Expect(modelLoader.CheckIsLoaded("model-A")).ToNot(BeNil())
|
|
Expect(modelLoader.CheckIsLoaded("model-B")).ToNot(BeNil())
|
|
Expect(modelLoader.CheckIsLoaded("model-C")).ToNot(BeNil())
|
|
})
|
|
|
|
It("should track loading count correctly", func() {
|
|
loadStarted := make(chan struct{})
|
|
loadComplete := make(chan struct{})
|
|
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
close(loadStarted)
|
|
<-loadComplete // Wait until we're told to complete
|
|
return model.NewModel(modelID, modelName, nil), nil
|
|
}
|
|
|
|
// Start loading in background
|
|
go func() {
|
|
modelLoader.LoadModel("slow-model", "test.model", mockLoader)
|
|
}()
|
|
|
|
// Wait for loading to start
|
|
<-loadStarted
|
|
|
|
// Loading count should be 1
|
|
Expect(modelLoader.GetLoadingCount()).To(Equal(1))
|
|
|
|
// Complete the loading
|
|
close(loadComplete)
|
|
|
|
// Wait a bit for cleanup
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// Loading count should be back to 0
|
|
Expect(modelLoader.GetLoadingCount()).To(Equal(0))
|
|
})
|
|
|
|
It("should retry loading if first attempt fails", func() {
|
|
var attemptCount int32
|
|
mockLoader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
count := atomic.AddInt32(&attemptCount, 1)
|
|
if count == 1 {
|
|
// Hold the loading slot so the second request coalesces as a
|
|
// follower before this leader fails. That follower then gets
|
|
// the one in-burst retry, which bypasses the failure cooldown
|
|
// (the cooldown only gates fresh, independent load triggers).
|
|
time.Sleep(50 * time.Millisecond)
|
|
return nil, errors.New("first attempt fails")
|
|
}
|
|
return model.NewModel(modelID, modelName, nil), nil
|
|
}
|
|
|
|
// First goroutine will fail
|
|
var wg sync.WaitGroup
|
|
|
|
var err1, err2 error
|
|
var m1, m2 *model.Model
|
|
|
|
wg.Go(func() {
|
|
m1, err1 = modelLoader.LoadModel("retry-model", "test.model", mockLoader)
|
|
})
|
|
|
|
// Give first goroutine a head start so it owns the loading slot.
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
wg.Go(func() {
|
|
m2, err2 = modelLoader.LoadModel("retry-model", "test.model", mockLoader)
|
|
})
|
|
|
|
wg.Wait()
|
|
|
|
// At least one should succeed (the second attempt after retry)
|
|
successCount := 0
|
|
if err1 == nil && m1 != nil {
|
|
successCount++
|
|
}
|
|
if err2 == nil && m2 != nil {
|
|
successCount++
|
|
}
|
|
Expect(successCount).To(BeNumerically(">=", 1))
|
|
})
|
|
})
|
|
|
|
Context("GetLoadingCount", func() {
|
|
It("should return 0 when nothing is loading", func() {
|
|
Expect(modelLoader.GetLoadingCount()).To(Equal(0))
|
|
})
|
|
})
|
|
|
|
Context("LRU Eviction Retry Settings", func() {
|
|
It("should allow updating retry settings", func() {
|
|
modelLoader.SetLRUEvictionRetrySettings(50, 2*time.Second)
|
|
// Settings are updated - we can verify through behavior if needed
|
|
// For now, just verify the call doesn't panic
|
|
Expect(modelLoader).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Context("Load failure cooldown", func() {
|
|
It("refuses a fresh load within the cooldown window without re-invoking the loader", func() {
|
|
modelLoader.SetLoadFailureCooldown(60*time.Millisecond, 240*time.Millisecond)
|
|
|
|
var loadCount int32
|
|
failing := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
atomic.AddInt32(&loadCount, 1)
|
|
return nil, errors.New("boom")
|
|
}
|
|
|
|
// First attempt runs the loader and fails (not a cooldown error).
|
|
_, err := modelLoader.LoadModel("broken", "test.model", failing)
|
|
Expect(err).To(HaveOccurred())
|
|
var coolErr *model.ModelLoadCooldownError
|
|
Expect(errors.As(err, &coolErr)).To(BeFalse())
|
|
Expect(atomic.LoadInt32(&loadCount)).To(Equal(int32(1)))
|
|
|
|
// An immediate retry is short-circuited: cooldown error, loader untouched.
|
|
_, err = modelLoader.LoadModel("broken", "test.model", failing)
|
|
Expect(errors.As(err, &coolErr)).To(BeTrue())
|
|
Expect(coolErr.ModelID).To(Equal("broken"))
|
|
Expect(coolErr.RetryAfter).To(BeNumerically(">", time.Duration(0)))
|
|
Expect(atomic.LoadInt32(&loadCount)).To(Equal(int32(1)))
|
|
|
|
// Once the window elapses the loader is attempted again.
|
|
Eventually(func() int32 {
|
|
_, _ = modelLoader.LoadModel("broken", "test.model", failing)
|
|
return atomic.LoadInt32(&loadCount)
|
|
}, "1s", "20ms").Should(BeNumerically(">=", 2))
|
|
})
|
|
|
|
It("clears the cooldown after a successful load", func() {
|
|
modelLoader.SetLoadFailureCooldown(60*time.Millisecond, 240*time.Millisecond)
|
|
|
|
var attempts int32
|
|
loader := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
if atomic.AddInt32(&attempts, 1) == 1 {
|
|
return nil, errors.New("boom")
|
|
}
|
|
m := model.NewModel(modelID, modelName, nil)
|
|
m.MarkHealthy()
|
|
return m, nil
|
|
}
|
|
|
|
_, err := modelLoader.LoadModel("flaky", "test.model", loader)
|
|
Expect(err).To(HaveOccurred())
|
|
|
|
// After the window, the retry succeeds and resets the failure state.
|
|
var m *model.Model
|
|
Eventually(func() error {
|
|
m, err = modelLoader.LoadModel("flaky", "test.model", loader)
|
|
return err
|
|
}, "1s", "20ms").Should(Succeed())
|
|
Expect(m).ToNot(BeNil())
|
|
|
|
// A subsequent load returns the cached model, never a cooldown error.
|
|
m2, err := modelLoader.LoadModel("flaky", "test.model", loader)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(m2).To(Equal(m))
|
|
})
|
|
|
|
It("grows the cooldown on consecutive failures", func() {
|
|
modelLoader.SetLoadFailureCooldown(50*time.Millisecond, 10*time.Second)
|
|
failing := func(modelID, modelName, modelFile string) (*model.Model, error) {
|
|
return nil, errors.New("boom")
|
|
}
|
|
|
|
// Failure 1, then read its cooldown.
|
|
_, err := modelLoader.LoadModel("bad", "test.model", failing)
|
|
Expect(err).To(HaveOccurred())
|
|
_, err = modelLoader.LoadModel("bad", "test.model", failing)
|
|
var c1 *model.ModelLoadCooldownError
|
|
Expect(errors.As(err, &c1)).To(BeTrue())
|
|
|
|
// Wait out the first window, trigger failure 2, read its (larger) cooldown.
|
|
time.Sleep(70 * time.Millisecond)
|
|
_, err = modelLoader.LoadModel("bad", "test.model", failing)
|
|
Expect(err).To(HaveOccurred())
|
|
_, err = modelLoader.LoadModel("bad", "test.model", failing)
|
|
var c2 *model.ModelLoadCooldownError
|
|
Expect(errors.As(err, &c2)).To(BeTrue())
|
|
|
|
Expect(c2.RetryAfter).To(BeNumerically(">", c1.RetryAfter))
|
|
})
|
|
})
|
|
})
|