Files
LocalAI/pkg/model/remote_shutdown_test.go
T
Richard Palethorpe 3de91d48d9 feat(motion): add GEM-X backend and duplex streaming API
Add the GEM-X native backend with live pose and root displacement output, bounded duplex WebSocket streaming, session authorization, metrics and usage accounting. Share the embedded bidirectional RPC channel adapter across streaming backends.

Include backend build and gallery integration, API documentation, schemas and tests.

Assisted-by: Codex:gpt-6
Signed-off-by: Richard Palethorpe <io@richiejp.com>
(cherry picked from commit 016dc3d71a)
2026-10-13 08:27:24 +00:00

173 lines
5.8 KiB
Go

package model_test
import (
"context"
"errors"
"os/exec"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
process "github.com/mudler/go-processmanager"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// fakeRemoteUnloader records the models it was asked to unload so the specs can
// assert the remote path was actually taken (not merely that no error
// surfaced). It mirrors the real adapter's contract: unloading is idempotent
// and reports nil even when nothing was loaded, so presence is a separate
// question.
type fakeRemoteUnloader struct {
called []string
unloadErr error
present bool
presenceErr error
asked []string
}
func (f *fakeRemoteUnloader) UnloadRemoteModel(modelName string) error {
f.called = append(f.called, modelName)
return f.unloadErr
}
func (f *fakeRemoteUnloader) HasRemoteModel(_ context.Context, modelName string) (bool, error) {
f.asked = append(f.asked, modelName)
return f.present, f.presenceErr
}
// unloaderWithoutPresence is a RemoteModelUnloader that does NOT implement
// RemoteModelPresenceChecker, pinning the compatibility path for third-party
// implementations of the older interface.
type unloaderWithoutPresence struct{ called []string }
func (u *unloaderWithoutPresence) UnloadRemoteModel(modelName string) error {
u.called = append(u.called, modelName)
return nil
}
// In distributed mode the authoritative record of "is this model loaded" is
// the shared node registry, not this replica's in-memory store. A frontend
// replica that never served the model itself (load balancer picked another
// replica, or this one restarted) has no local entry, so ShutdownModel
// short-circuited on a local-store miss and reported "model not found" for a
// model that was demonstrably running on a worker — while the remote unload
// path it documents was never reached.
var _ = Describe("ShutdownModel in distributed mode", func() {
var (
modelLoader *model.ModelLoader
unloader *fakeRemoteUnloader
)
BeforeEach(func() {
systemState, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir()))
Expect(err).ToNot(HaveOccurred())
modelLoader = model.NewModelLoader(systemState)
unloader = &fakeRemoteUnloader{}
})
It("delegates to the remote unloader when the model is not in the local store", func() {
unloader.present = true
modelLoader.SetRemoteUnloader(unloader)
err := modelLoader.ShutdownModel("longcat-video-avatar-1.5")
Expect(unloader.called).To(ConsistOf("longcat-video-avatar-1.5"),
"a model absent locally may still be loaded on a worker; the remote unloader must be consulted")
Expect(err).ToNot(HaveOccurred(),
"stopping a model that is running on a worker must succeed, not report 'model not found'")
})
DescribeTable("stops mixed local and remote placements",
func(remoteErr error) {
unloader.unloadErr = remoteErr
modelLoader.SetRemoteUnloader(unloader)
sleepPath, err := exec.LookPath("sleep")
Expect(err).NotTo(HaveOccurred())
localProcess := process.New(
process.WithTemporaryStateDir(),
process.WithName(sleepPath),
process.WithArgs("300"),
)
Expect(localProcess.Run()).To(Succeed())
DeferCleanup(func() {
if localProcess.IsAlive() {
_ = localProcess.Stop()
}
})
_, err = modelLoader.LoadModel("mixed", "mixed", func(_, _, _ string) (*model.Model, error) {
return model.NewModel("mixed", "local", localProcess), nil
})
Expect(err).NotTo(HaveOccurred())
var hookCalls int
modelLoader.OnModelUnload(func(modelName string) {
Expect(modelName).To(Equal("mixed"))
hookCalls++
})
err = modelLoader.ShutdownModelForce("mixed")
Expect(localProcess.IsAlive()).To(BeFalse())
Expect(modelLoader.ListLoadedModels()).To(BeEmpty())
Expect(unloader.called).To(ConsistOf("mixed"))
Expect(hookCalls).To(Equal(1))
if remoteErr == nil {
Expect(err).NotTo(HaveOccurred())
return
}
Expect(errors.Is(err, remoteErr)).To(BeTrue())
},
Entry("when every placement stops", nil),
Entry("when local stop succeeds but a remote placement fails", errors.New("worker unreachable")),
)
It("reports not-found only after the registry confirms no node has it", func() {
unloader.present = false
modelLoader.SetRemoteUnloader(unloader)
err := modelLoader.ShutdownModel("never-loaded")
Expect(unloader.asked).To(ConsistOf("never-loaded"),
"the registry must be consulted before declaring a model not found")
Expect(err).To(MatchError(model.ErrModelNotFound),
"absent locally AND cluster-wide is the only case that may report not-found")
Expect(unloader.called).To(BeEmpty(),
"nothing to unload — no point publishing a stop for a model no node holds")
})
It("does not claim not-found when the registry lookup fails", func() {
// An unreachable registry is not evidence of absence. Reporting 404
// here would tell an operator the model is gone on the strength of a
// failed lookup.
unloader.presenceErr = errors.New("registry unavailable")
modelLoader.SetRemoteUnloader(unloader)
err := modelLoader.ShutdownModel("maybe-loaded")
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(model.ErrModelNotFound))
})
It("still unloads via an unloader that cannot answer presence", func() {
// Older RemoteModelUnloader implementations have no presence check.
// They must keep working: attempt the unload rather than refusing it.
legacy := &unloaderWithoutPresence{}
modelLoader.SetRemoteUnloader(legacy)
err := modelLoader.ShutdownModel("some-model")
Expect(legacy.called).To(ConsistOf("some-model"))
Expect(err).ToNot(HaveOccurred())
})
It("still reports not-found when no remote unloader is configured", func() {
// Single-node behavior must be unchanged.
err := modelLoader.ShutdownModel("never-loaded")
Expect(err).To(MatchError(model.ErrModelNotFound))
})
})