Files
LocalAI/pkg/model/remote_shutdown_test.go
localai-org-maint-botandEttore Di Giacinto 997d403de4 feat(nodes): add fleet operations dashboard (#12046)
* feat(nodes): report CPU telemetry

Assisted-by: Codex:gpt-6

* feat(nodes): add fleet view utilities

Assisted-by: Codex:gpt-6

* feat(nodes): add fleet operations dashboard

Replace the panel roster with aggregate capacity gauges, fleet filtering and selection, bounded bulk actions, and an on-demand node inspector. Extend node details and distributed-mode documentation with CPU and models-disk telemetry.

Assisted-by: Codex:gpt-6

* fix(nodes): harden fleet lifecycle actions

Assisted-by: Codex:gpt-6

* fix(nodes): restore compact fleet composition

Keep fleet health, capacity, and attention in one compact overview at ordinary desktop widths. The inspector now overlays the roster until the workbench can preserve a useful table beside it.

Assisted-by: Codex:gpt-6

* feat(nodes): add accessible running models workbench

Assisted-by: Codex:gpt-6

* fix(nodes): correct model view ARIA links

Keep each tab panel available for its controlling tab while native hidden state removes inactive content from accessibility navigation. Model controls now expose only supported state and valid inspector relationships.

Assisted-by: Codex:gpt-6

* fix(nodes): align lifecycle and capacity states

Pending nodes now expose approval wherever node actions appear, while other lifecycle controls follow the server transition rules. Capacity totals exclude incomplete readings so missing availability remains unknown.

Assisted-by: Codex:gpt-6

* fix(nodes): restore approved dashboard composition

Assisted-by: Codex:gpt-6

* fix(nodes): integrate operate navigation

Assisted-by: Codex:gpt-6

* fix(nodes): restore low density fleet view

Assisted-by: Codex:gpt-6

* fix(nodes): preserve complete operate menu

Assisted-by: Codex:gpt-6

* fix(nodes): preserve inspector workspace height

Assisted-by: Codex:gpt-6

* fix(nodes): restore standard operate navigation

Assisted-by: Codex:gpt-6

* feat(ui): add collapsible console rail

Assisted-by: Codex:gpt-6

* feat(nodes): stop models from fleet view

Assisted-by: Codex:gpt-6

* fix(nodes): make inspector a full height drawer

Assisted-by: Codex:gpt-6

* fix(model): stop mixed local and remote placements

Assisted-by: Codex:gpt-6

* fix(ui): announce action menu navigation

Assisted-by: Codex:gpt-6

* fix(nodes): keep inspector within viewport

Assisted-by: Codex:gpt-6

* fix(ui): preserve focus across model actions

Assisted-by: Codex:gpt-6

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-15 00:57:14 +02:00

170 lines
5.7 KiB
Go

package model_test
import (
"context"
"errors"
"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)
localProcess := process.New(
process.WithTemporaryStateDir(),
process.WithName("/bin/sleep"),
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))
})
})