Files
LocalAI/core/services/nodes/unloader_test.go
T
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

519 lines
19 KiB
Go

package nodes
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"github.com/nats-io/nats.go"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/core/services/messaging"
)
// --- Fakes ---
// fakeModelLocator implements ModelLocator with configurable node lists.
type fakeModelLocator struct {
nodes []BackendNode
findErr error
removedPairs []modelNodePair // records RemoveNodeModel calls
removedReplicas []modelReplicaRef // records RemoveNodeModel calls including the replica index
}
type modelNodePair struct {
nodeID string
modelName string
}
// modelReplicaRef records a row removal at full replica granularity.
// modelNodePair drops the index because RemoveAllNodeModelReplicas has none;
// the backend delete/upgrade paths address exactly one replica row, so an
// assertion that ignored the index could not tell a correct removal from one
// that wiped a sibling replica still serving traffic.
type modelReplicaRef struct {
nodeID string
modelName string
replicaIndex int
}
func (f *fakeModelLocator) FindNodesWithModel(_ context.Context, _ string) ([]BackendNode, error) {
return f.nodes, f.findErr
}
func (f *fakeModelLocator) RemoveNodeModel(_ context.Context, nodeID, modelName string, replicaIndex int) error {
f.removedPairs = append(f.removedPairs, modelNodePair{nodeID, modelName})
f.removedReplicas = append(f.removedReplicas, modelReplicaRef{nodeID, modelName, replicaIndex})
return nil
}
func (f *fakeModelLocator) RemoveAllNodeModelReplicas(_ context.Context, nodeID, modelName string) error {
f.removedPairs = append(f.removedPairs, modelNodePair{nodeID, modelName})
return nil
}
// fakeMessagingClient implements messaging.MessagingClient, recording Publish
// and Request calls so we can assert on subjects and payloads.
type fakeMessagingClient struct {
mu sync.Mutex
published []publishCall
publishErr error // error to return from Publish
requestReply []byte
requestErr error
requestCalls []requestCall
}
type publishCall struct {
Subject string
Data []byte
}
type requestCall struct {
Subject string
Data []byte
Timeout time.Duration
}
func (f *fakeMessagingClient) Publish(subject string, data any) error {
f.mu.Lock()
defer f.mu.Unlock()
var raw []byte
if data != nil {
var err error
raw, err = json.Marshal(data)
if err != nil {
return err
}
}
f.published = append(f.published, publishCall{Subject: subject, Data: raw})
return f.publishErr
}
func (f *fakeMessagingClient) Subscribe(_ string, _ func([]byte)) (messaging.Subscription, error) {
return &fakeSubscription{}, nil
}
func (f *fakeMessagingClient) QueueSubscribe(_ string, _ string, _ func([]byte)) (messaging.Subscription, error) {
return &fakeSubscription{}, nil
}
func (f *fakeMessagingClient) QueueSubscribeReply(_ string, _ string, _ func(data []byte, reply func([]byte))) (messaging.Subscription, error) {
return &fakeSubscription{}, nil
}
func (f *fakeMessagingClient) SubscribeReply(_ string, _ func(data []byte, reply func([]byte))) (messaging.Subscription, error) {
return &fakeSubscription{}, nil
}
func (f *fakeMessagingClient) Request(subject string, data []byte, timeout time.Duration) ([]byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.requestCalls = append(f.requestCalls, requestCall{Subject: subject, Data: data, Timeout: timeout})
return f.requestReply, f.requestErr
}
func (f *fakeMessagingClient) IsConnected() bool { return true }
func (f *fakeMessagingClient) Close() {}
type fakeSubscription struct{}
func (f *fakeSubscription) Unsubscribe() error { return nil }
func mustJSON(v any) []byte {
data, err := json.Marshal(v)
Expect(err).ToNot(HaveOccurred())
return data
}
// --- Tests ---
var _ = Describe("RemoteUnloaderAdapter", func() {
var (
locator *fakeModelLocator
mc *fakeMessagingClient
adapter *RemoteUnloaderAdapter
)
BeforeEach(func() {
locator = &fakeModelLocator{}
mc = &fakeMessagingClient{}
// backend.stop is request-reply, so the default fake must answer the
// way a current worker does. Specs that care about the reply override
// requestReply themselves.
mc.requestReply = mustJSON(messaging.BackendStopReply{
Success: true,
StoppedProcessKeys: []string{"llama#0"},
ReportsStoppedProcesses: true,
})
adapter = NewRemoteUnloaderAdapter(locator, mc, 3*time.Minute, 15*time.Minute)
})
// HasRemoteModel carries the distinction that UnloadRemoteModel
// deliberately does not, so ShutdownModel can answer 404 for a model that
// is loaded neither locally nor anywhere in the cluster without making the
// shared unload path fail for every idempotent cleanup caller.
Describe("HasRemoteModel", func() {
It("reports false when no node has the model", func() {
locator.nodes = nil
loaded, err := adapter.HasRemoteModel(context.Background(), "my-model")
Expect(err).ToNot(HaveOccurred())
Expect(loaded).To(BeFalse())
})
It("reports true when a node has the model", func() {
locator.nodes = []BackendNode{{ID: "node-1", Name: "worker-1"}}
loaded, err := adapter.HasRemoteModel(context.Background(), "my-model")
Expect(err).ToNot(HaveOccurred())
Expect(loaded).To(BeTrue())
})
It("surfaces a registry failure instead of reporting absence", func() {
// An unreachable registry is not evidence that the model is gone;
// reporting false would let ShutdownModel answer a confident 404
// on the strength of a failed lookup.
locator.findErr = errors.New("registry unavailable")
_, err := adapter.HasRemoteModel(context.Background(), "my-model")
Expect(err).To(HaveOccurred())
})
})
Describe("UnloadRemoteModel", func() {
It("with no nodes returns nil", func() {
// Unloading is idempotent: cleanup paths (model deletion, config
// edits, watchdog eviction) legitimately run against an already
// unloaded model, and turning that into an error wedges the
// watchdog's LRU reclaimer, which only untracks a model when
// shutdown reports success. The same contract is pinned end to end
// by "should be no-op for models not on any node" in
// tests/e2e/distributed/node_lifecycle_test.go — keep them in step.
locator.nodes = nil
Expect(adapter.UnloadRemoteModel("my-model")).To(Succeed())
Expect(mc.requestCalls).To(BeEmpty())
})
It("broadcasts to all nodes with model", func() {
locator.nodes = []BackendNode{
{ID: "node-1", Name: "worker-1"},
{ID: "node-2", Name: "worker-2"},
}
Expect(adapter.UnloadRemoteModel("llama")).To(Succeed())
// Should have asked each node to stop the backend.
Expect(mc.requestCalls).To(HaveLen(2))
Expect(mc.requestCalls[0].Subject).To(Equal(messaging.SubjectNodeBackendStop("node-1")))
Expect(mc.requestCalls[1].Subject).To(Equal(messaging.SubjectNodeBackendStop("node-2")))
// Should have removed the model from each node in the registry.
Expect(locator.removedPairs).To(HaveLen(2))
Expect(locator.removedPairs[0]).To(Equal(modelNodePair{"node-1", "llama"}))
Expect(locator.removedPairs[1]).To(Equal(modelNodePair{"node-2", "llama"}))
})
It("stops each node once when the registry returns multiple replicas", func() {
locator.nodes = []BackendNode{
{ID: "node-1", Name: "worker-1"},
{ID: "node-1", Name: "worker-1"},
{ID: "node-2", Name: "worker-2"},
}
Expect(adapter.UnloadRemoteModel("llama")).To(Succeed())
Expect(mc.requestCalls).To(HaveLen(2))
Expect(mc.requestCalls[0].Subject).To(Equal(messaging.SubjectNodeBackendStop("node-1")))
Expect(mc.requestCalls[1].Subject).To(Equal(messaging.SubjectNodeBackendStop("node-2")))
Expect(locator.removedPairs).To(ConsistOf(
modelNodePair{"node-1", "llama"},
modelNodePair{"node-2", "llama"},
))
})
It("continues when one node fails", func() {
locator.nodes = []BackendNode{
{ID: "node-fail", Name: "worker-fail"},
{ID: "node-ok", Name: "worker-ok"},
}
// Use a messaging client that fails the first Request call only.
failOnce := &failOnceMessagingClient{inner: mc, failOn: 0}
adapter = NewRemoteUnloaderAdapter(locator, failOnce, 3*time.Minute, 15*time.Minute)
Expect(adapter.UnloadRemoteModel("llama")).To(HaveOccurred())
// The second node should still have been processed.
// The first node's StopBackend errored, so RemoveNodeModel was NOT called for it.
// The second node's StopBackend succeeded, so RemoveNodeModel WAS called.
Expect(locator.removedPairs).To(HaveLen(1))
Expect(locator.removedPairs[0].nodeID).To(Equal("node-ok"))
})
It("propagates forced shutdown to every worker", func() {
locator.nodes = []BackendNode{{ID: "node-1", Name: "worker-1"}}
Expect(adapter.UnloadRemoteModelContext(context.Background(), "llama", true)).To(Succeed())
var payload messaging.BackendStopRequest
Expect(json.Unmarshal(mc.requestCalls[0].Data, &payload)).To(Succeed())
Expect(payload).To(Equal(messaging.BackendStopRequest{Backend: "llama", Force: true}))
})
})
Describe("StopBackend", func() {
It("with empty backend asks the worker to stop everything", func() {
Expect(adapter.StopBackend("node-1", "")).To(Succeed())
Expect(mc.requestCalls).To(HaveLen(1))
Expect(mc.requestCalls[0].Subject).To(Equal(messaging.SubjectNodeBackendStop("node-1")))
// An empty Backend is the wire signal for "stop all"; the worker's
// decodeBackendStopRequest reads it the same way it read the bare
// nil payload this replaced.
var payload messaging.BackendStopRequest
Expect(json.Unmarshal(mc.requestCalls[0].Data, &payload)).To(Succeed())
Expect(payload.Backend).To(BeEmpty())
})
// The bug this reply exists for: the worker could not stop what was
// asked, and the caller was told everything was fine.
It("reports a stop the worker could not carry out", func() {
mc.requestReply = mustJSON(messaging.BackendStopReply{
Success: false,
Error: "llama#0: process refused to die",
ReportsStoppedProcesses: true,
})
err := adapter.StopBackend("node-1", "llama-backend")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("process refused to die"))
})
// Nothing running under that name is the state the caller asked for, so
// it stays a success — eviction and cleanup paths stop models that are
// already gone all the time.
It("succeeds when the worker matched no running process", func() {
mc.requestReply = mustJSON(messaging.BackendStopReply{
Success: true,
ReportsStoppedProcesses: true,
})
Expect(adapter.StopBackend("node-1", "llama-backend")).To(Succeed())
})
// A worker built before BackendStopReply performs the stop and never
// answers. Failing here would break every stop on a fleet mid-upgrade.
It("assumes delivery when an older worker never answers", func() {
mc.requestErr = nats.ErrTimeout
Expect(adapter.StopBackend("node-1", "llama-backend")).To(Succeed())
})
// A closed connection is not an old worker, and callers depend on
// hearing about it: UnloadRemoteModel skips the registry cleanup for a
// node it could not reach.
It("reports a transport failure rather than assuming delivery", func() {
mc.requestErr = nats.ErrConnectionClosed
Expect(adapter.StopBackend("node-1", "llama-backend")).To(HaveOccurred())
})
It("with backend name sends JSON", func() {
Expect(adapter.StopBackend("node-1", "llama-backend")).To(Succeed())
Expect(mc.requestCalls).To(HaveLen(1))
var payload messaging.BackendStopRequest
Expect(json.Unmarshal(mc.requestCalls[0].Data, &payload)).To(Succeed())
Expect(payload.Backend).To(Equal("llama-backend"))
Expect(payload.Force).To(BeFalse())
})
})
Describe("StopModelReplica", func() {
It("requests an acknowledged stop for the exact process", func() {
mc.requestReply, _ = json.Marshal(messaging.ModelStopReply{Matched: true, Terminated: true, ProcessKey: "llama#2"})
replica := NodeModel{ModelName: "llama", ReplicaIndex: 2, Address: "127.0.0.1:5002", ConfigRevision: "rev-1"}
reply, err := adapter.StopModelReplica(context.Background(), "node-1", replica, true)
Expect(err).NotTo(HaveOccurred())
Expect(reply.Terminated).To(BeTrue())
Expect(mc.requestCalls).To(HaveLen(1))
Expect(mc.requestCalls[0].Subject).To(Equal(messaging.SubjectNodeModelStop("node-1")))
Expect(mc.requestCalls[0].Timeout).To(BeNumerically(">", 0))
var request messaging.ModelStopRequest
Expect(json.Unmarshal(mc.requestCalls[0].Data, &request)).To(Succeed())
Expect(request).To(Equal(messaging.ModelStopRequest{
ModelName: "llama", ProcessKey: "llama#2", ExpectedAddress: "127.0.0.1:5002", Force: true, ConfigRevision: "rev-1",
}))
})
})
Describe("StopNode", func() {
It("publishes to correct subject", func() {
Expect(adapter.StopNode("node-abc")).To(Succeed())
Expect(mc.published).To(HaveLen(1))
Expect(mc.published[0].Subject).To(Equal(messaging.SubjectNodeStop("node-abc")))
Expect(mc.published[0].Data).To(BeNil())
})
})
Describe("DeleteModelFiles", func() {
It("with no nodes returns nil", func() {
locator.nodes = nil
Expect(adapter.DeleteModelFiles("my-model")).To(Succeed())
})
It("continues on failure", func() {
locator.nodes = []BackendNode{
{ID: "node-1", Name: "w1"},
{ID: "node-2", Name: "w2"},
}
// Request will fail for all calls.
mc.requestErr = fmt.Errorf("timeout")
Expect(adapter.DeleteModelFiles("my-model")).To(Succeed())
// Both nodes attempted.
Expect(mc.requestCalls).To(HaveLen(2))
Expect(mc.requestCalls[0].Subject).To(Equal(messaging.SubjectNodeModelDelete("node-1")))
Expect(mc.requestCalls[1].Subject).To(Equal(messaging.SubjectNodeModelDelete("node-2")))
})
})
})
// failOnceMessagingClient wraps fakeMessagingClient but fails the Publish call
// at index failOn (0-based) and succeeds all others.
type failOnceMessagingClient struct {
inner *fakeMessagingClient
failOn int
callIdx int
mu sync.Mutex
}
func (f *failOnceMessagingClient) Publish(subject string, data any) error {
f.mu.Lock()
idx := f.callIdx
f.callIdx++
f.mu.Unlock()
if idx == f.failOn {
return fmt.Errorf("simulated failure")
}
return f.inner.Publish(subject, data)
}
func (f *failOnceMessagingClient) Subscribe(subject string, handler func([]byte)) (messaging.Subscription, error) {
return f.inner.Subscribe(subject, handler)
}
func (f *failOnceMessagingClient) QueueSubscribe(subject, queue string, handler func([]byte)) (messaging.Subscription, error) {
return f.inner.QueueSubscribe(subject, queue, handler)
}
func (f *failOnceMessagingClient) QueueSubscribeReply(subject, queue string, handler func(data []byte, reply func([]byte))) (messaging.Subscription, error) {
return f.inner.QueueSubscribeReply(subject, queue, handler)
}
func (f *failOnceMessagingClient) SubscribeReply(subject string, handler func(data []byte, reply func([]byte))) (messaging.Subscription, error) {
return f.inner.SubscribeReply(subject, handler)
}
func (f *failOnceMessagingClient) Request(subject string, data []byte, timeout time.Duration) ([]byte, error) {
f.mu.Lock()
idx := f.callIdx
f.callIdx++
f.mu.Unlock()
if idx == f.failOn {
return nil, fmt.Errorf("simulated failure")
}
return f.inner.Request(subject, data, timeout)
}
func (f *failOnceMessagingClient) IsConnected() bool { return true }
func (f *failOnceMessagingClient) Close() {}
var _ = Describe("RemoteUnloaderAdapter timeout configuration", func() {
It("passes the configured install timeout to the messaging client", func() {
mc := newScriptedMessagingClient()
mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true, Address: "127.0.0.1:0"})
adapter := NewRemoteUnloaderAdapter(nil, mc, 7*time.Minute, 11*time.Minute)
_, err := adapter.InstallBackend("n1", "llama-cpp", "", "[]", "", "", "", 0, "", nil)
Expect(err).ToNot(HaveOccurred())
Expect(mc.calls).To(HaveLen(1))
Expect(mc.calls[0].Timeout).To(Equal(7 * time.Minute))
})
It("passes the configured upgrade timeout to the messaging client", func() {
mc := newScriptedMessagingClient()
mc.scriptReply(messaging.SubjectNodeBackendUpgrade("n1"), messaging.BackendUpgradeReply{Success: true})
adapter := NewRemoteUnloaderAdapter(nil, mc, 7*time.Minute, 11*time.Minute)
_, err := adapter.UpgradeBackend("n1", "llama-cpp", "[]", "", "", "", 0, "", nil)
Expect(err).ToNot(HaveOccurred())
Expect(mc.calls).To(HaveLen(1))
Expect(mc.calls[0].Timeout).To(Equal(11 * time.Minute))
})
})
var _ = Describe("RemoteUnloaderAdapter NATS timeout handling", func() {
It("wraps nats.ErrTimeout from InstallBackend in galleryop.ErrWorkerStillInstalling", func() {
mc := newScriptedMessagingClient()
mc.scriptErr(messaging.SubjectNodeBackendInstall("n1"), nats.ErrTimeout)
adapter := NewRemoteUnloaderAdapter(nil, mc, 100*time.Millisecond, 1*time.Second)
_, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "", nil)
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeTrue(),
"expected wrapped ErrWorkerStillInstalling, got %v", err)
})
It("does NOT wrap non-timeout errors", func() {
mc := newScriptedMessagingClient()
mc.scriptErr(messaging.SubjectNodeBackendInstall("n1"), nats.ErrNoResponders)
adapter := NewRemoteUnloaderAdapter(nil, mc, 100*time.Millisecond, 1*time.Second)
_, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "", nil)
Expect(err).To(HaveOccurred())
Expect(errors.Is(err, galleryop.ErrWorkerStillInstalling)).To(BeFalse())
Expect(errors.Is(err, nats.ErrNoResponders)).To(BeTrue())
})
})
var _ = Describe("RemoteUnloaderAdapter install progress streaming", func() {
It("forwards BackendInstallProgressEvent values into the onProgress callback when the worker publishes them", func() {
mc := newScriptedMessagingClient()
mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true, Address: "127.0.0.1:0"})
mc.scheduleProgressPublish("n1", "op-abc", []messaging.BackendInstallProgressEvent{
{OpID: "op-abc", NodeID: "n1", Backend: "vllm", FileName: "vllm.tar.zst", Current: "100 MB", Total: "1 GB", Percentage: 10},
{OpID: "op-abc", NodeID: "n1", Backend: "vllm", FileName: "vllm.tar.zst", Current: "500 MB", Total: "1 GB", Percentage: 50},
})
adapter := NewRemoteUnloaderAdapter(nil, mc, 1*time.Second, 1*time.Second)
var (
received []messaging.BackendInstallProgressEvent
mu sync.Mutex
)
onProgress := func(ev messaging.BackendInstallProgressEvent) {
mu.Lock()
defer mu.Unlock()
received = append(received, ev)
}
_, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "op-abc", onProgress)
Expect(err).ToNot(HaveOccurred())
Eventually(func() int {
mu.Lock()
defer mu.Unlock()
return len(received)
}, "1s").Should(Equal(2))
})
It("does NOT subscribe when onProgress is nil (reconciler retry path)", func() {
mc := newScriptedMessagingClient()
mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true})
adapter := NewRemoteUnloaderAdapter(nil, mc, 1*time.Second, 1*time.Second)
_, err := adapter.InstallBackend("n1", "vllm", "", "[]", "", "", "", 0, "", nil)
Expect(err).ToNot(HaveOccurred())
Expect(mc.subscribeCalls()).To(BeEmpty(),
"reconciler-driven retries must not subscribe to the per-op progress subject")
})
})