mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-12 22:33:54 -04:00
The ten backend and model lifecycle verbs stop being NATS requests and become HTTP calls on the worker's own control routes, reached through that worker's tunnel on the `http` stream tag that already carries file staging. Nine subject builders and the per-op install-progress subject are deleted with their entries in the worker's NATS permissions; the request and reply DTOs are untouched, so a body on the wire is byte for byte what the subject carried. This closes the merge gate Task 3 left open, which was worse than lost commands. Once the worker stopped subscribing, PingNode was still asking nodes.<id>.backend.list and nodes.<id>.models.running, so EVERY healthy worker answered no-responders, nodeAnswersOnBus read it as absence and pickReachableNode demoted it on the scheduling path. PingNode is a control RPC now, and no control RPC can produce ErrNoResponders, which is the only error that exclusion acts on. Two specs drive pickReachableNode against a real adapter and a worker answering over its control plane, which is the only arrangement that can see the difference: the router's own double never touches a transport and stayed green for the whole window the defect was live. How a control RPC FAILS is the whole of this change, so it is decided in ONE function reading ONE table. A worker's answer passes through unwrapped, so cluster.IsWorkerAnswer still sees it and a reap guard may act on it; everything else is wrapped in ErrWorkerUnroutable so nothing can. There is no third branch, because a third branch is how the eight collapses on this branch happened: each was a site that decided for itself which errors were evidence. A 404 under the prefix is its own sentinel, because it is the worker stating a deployment fact about ITSELF rather than a verdict about a backend, and only the legacy upgrade fallback may act on it. The caller's budget is checked FIRST. A timeout is not a verdict: a refusal arriving in the instant a deadline expires would otherwise be reported as the worker's non-transient answer, which reaps a row, and nothing orders the two timers. A 5xx and an undecodable body are transport failures, not answers. An empty ModelsRunningReply means "this worker is running nothing", which the reconciler acts on, so it must never be manufactured from a body that would not parse. A stream that ends before its reply line is the same rule one layer up: a tunnel dying mid-install is not the worker saying the install failed. backend.stop is split by node type rather than moved. Agent workers hold no tunnel, so they have no control plane to serve, and they still subscribe to nodes.<id>.backend.stop to drop cached MCP sessions; that subject and its agent permission both survive. It is the honest intermediate state until agent workers hold tunnels too. A failed control RPC no longer demotes a node anywhere. ErrNoResponders meant "not on the bus"; a control failure means "this frontend could not route to it", which is equally what a healthy worker re-homing its tunnel between replicas produces. Absence is a fact read from the database, and the scheduler starts reading it in a later task. The rolling-update fallback re-fires a DESTRUCTIVE force-reinstall, so it runs only on the worker's own 404. Its negative direction was pinned at the admin call site and unpinned at the reconciler's, where widening the condition to any error left all 676 specs green: a background drain nobody is watching would then force-reinstall every queued backend the moment a replica lost its tunnels. Three specs cover it, arranged so the force install IS reachable in the negative case and a fallback that fired would show as a call and a drained row. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
196 lines
7.5 KiB
Go
196 lines
7.5 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/LocalAI/core/services/nodes"
|
|
"github.com/mudler/LocalAI/core/services/workerctl"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var _ = Describe("Node Backend Lifecycle over the worker control plane", Label("Distributed"), func() {
|
|
var (
|
|
infra *TestInfra
|
|
db *gorm.DB
|
|
registry *nodes.NodeRegistry
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
infra = SetupInfra("localai_lifecycle_test")
|
|
|
|
var err error
|
|
db, err = gorm.Open(pgdriver.Open(infra.PGURL), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
registry, err = nodes.NewNodeRegistry(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Context("backend.install", func() {
|
|
It("should send backend.install to a specific node", func() {
|
|
node := &nodes.BackendNode{
|
|
Name: "gpu-node-1", Address: "h1:50051",
|
|
}
|
|
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
|
|
|
|
// The worker serves backend.install on its own control plane.
|
|
workers := NewControlWorkers()
|
|
workers.On(node.ID, workerctl.PathBackendInstall, func(_ string, data []byte) any {
|
|
var req messaging.BackendInstallRequest
|
|
Expect(json.Unmarshal(data, &req)).To(Succeed())
|
|
Expect(req.Backend).To(Equal("llama-cpp"))
|
|
return messaging.BackendInstallReply{Success: true}
|
|
})
|
|
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
installReply, err := adapter.InstallBackend(node.ID, "llama-cpp", "", "", "", "", "", 0, "", nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(installReply.Success).To(BeTrue())
|
|
})
|
|
|
|
It("should propagate error from worker on failed install", func() {
|
|
node := &nodes.BackendNode{
|
|
Name: "fail-node", Address: "h1:50051",
|
|
}
|
|
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
|
|
|
|
// The worker's own verdict: an answer, not a transport failure.
|
|
workers := NewControlWorkers()
|
|
workers.On(node.ID, workerctl.PathBackendInstall, func(string, []byte) any {
|
|
return messaging.BackendInstallReply{Success: false, Error: "backend not found"}
|
|
})
|
|
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
installReply, err := adapter.InstallBackend(node.ID, "nonexistent", "", "", "", "", "", 0, "", nil)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(installReply.Success).To(BeFalse())
|
|
Expect(installReply.Error).To(ContainSubstring("backend not found"))
|
|
})
|
|
})
|
|
|
|
Context("backend.stop (model unload)", func() {
|
|
It("should send backend.stop to nodes hosting the model", func() {
|
|
node := &nodes.BackendNode{
|
|
Name: "gpu-node-2", Address: "h2:50051",
|
|
}
|
|
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
|
|
Expect(registry.SetNodeModel(context.Background(), node.ID, "whisper-large", 0, "loaded", "", 0)).To(Succeed())
|
|
|
|
var stopReceived atomic.Int32
|
|
workers := NewControlWorkers()
|
|
workers.On(node.ID, workerctl.PathBackendStop, func(string, []byte) any {
|
|
stopReceived.Add(1)
|
|
return nil
|
|
})
|
|
|
|
// Frontend calls UnloadRemoteModel (triggered by UI "Stop" or WatchDog)
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
Expect(adapter.UnloadRemoteModel("whisper-large")).To(Succeed())
|
|
|
|
Eventually(func() int32 { return stopReceived.Load() }, "5s").Should(Equal(int32(1)))
|
|
|
|
// Model should be removed from registry
|
|
nodesWithModel, _ := registry.FindNodesWithModel(context.Background(), "whisper-large")
|
|
Expect(nodesWithModel).To(BeEmpty())
|
|
})
|
|
|
|
It("should send backend.stop to all nodes hosting the model", func() {
|
|
node1 := &nodes.BackendNode{Name: "n1", Address: "h1:50051"}
|
|
node2 := &nodes.BackendNode{Name: "n2", Address: "h2:50051"}
|
|
registry.Register(context.Background(), node1, true)
|
|
registry.Register(context.Background(), node2, true)
|
|
registry.SetNodeModel(context.Background(), node1.ID, "shared-model", 0, "loaded", "", 0)
|
|
registry.SetNodeModel(context.Background(), node2.ID, "shared-model", 0, "loaded", "", 0)
|
|
|
|
var count atomic.Int32
|
|
workers := NewControlWorkers()
|
|
for _, id := range []string{node1.ID, node2.ID} {
|
|
workers.On(id, workerctl.PathBackendStop, func(string, []byte) any {
|
|
count.Add(1)
|
|
return nil
|
|
})
|
|
}
|
|
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
adapter.UnloadRemoteModel("shared-model")
|
|
|
|
Eventually(func() int32 { return count.Load() }, "5s").Should(Equal(int32(2)))
|
|
})
|
|
|
|
It("should be no-op for models not on any node", func() {
|
|
// Unloading is idempotent by contract: cleanup paths (model
|
|
// deletion, config edits, watchdog eviction) legitimately run
|
|
// against an already-unloaded model, and the watchdog's LRU
|
|
// reclaimer only untracks a model when shutdown reports success.
|
|
// Callers needing to tell "stopped it" from "nothing to stop" —
|
|
// ShutdownModel, so it can answer 404 — use HasRemoteModel instead.
|
|
// The same contract is pinned at unit level by "with no nodes
|
|
// returns nil" in core/services/nodes/unloader_test.go; keep them
|
|
// in step.
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, NewControlWorkers().Client(), 3*time.Minute, 15*time.Minute)
|
|
Expect(adapter.UnloadRemoteModel("nonexistent-model")).To(Succeed())
|
|
})
|
|
})
|
|
|
|
Context("node.stop (full shutdown)", func() {
|
|
It("should ask the node to shut down over its control plane", func() {
|
|
node := &nodes.BackendNode{
|
|
Name: "stop-me", Address: "h3:50051",
|
|
}
|
|
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
|
|
|
|
var stopped atomic.Int32
|
|
workers := NewControlWorkers()
|
|
workers.On(node.ID, workerctl.PathNodeStop, func(string, []byte) any {
|
|
stopped.Add(1)
|
|
return nil
|
|
})
|
|
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
Expect(adapter.StopNode(node.ID)).To(Succeed())
|
|
|
|
Eventually(func() int32 { return stopped.Load() }, "5s").Should(Equal(int32(1)))
|
|
})
|
|
})
|
|
|
|
Context("wire naming", func() {
|
|
// Written out BY HAND, and not derived from the constants: a frontend
|
|
// and a worker built from different commits reach each other over these
|
|
// literals, and a renamed path is a 404 that looks exactly like a
|
|
// broken tunnel.
|
|
It("should name the backend lifecycle control verbs", func() {
|
|
Expect(workerctl.PathBackendInstall).To(Equal("/v1/control/backend/install"))
|
|
Expect(workerctl.PathBackendStop).To(Equal("/v1/control/backend/stop"))
|
|
Expect(workerctl.PathNodeStop).To(Equal("/v1/control/node/stop"))
|
|
})
|
|
|
|
// The one node subject left, and it is addressed only to AGENT workers:
|
|
// they hold no tunnel and subscribe to it to drop cached MCP sessions.
|
|
It("should keep the agent worker's backend.stop subject", func() {
|
|
Expect(messaging.SubjectNodeBackendStop("node-abc")).To(Equal("nodes.node-abc.backend.stop"))
|
|
})
|
|
})
|
|
|
|
// Design note: LoadModel is a gRPC call through the worker's tunnel, not a
|
|
// control verb. The control plane installs and stops the process; the model
|
|
// is loaded into it over the `grpc` stream tag.
|
|
//
|
|
// Flow:
|
|
// 1. backend.install → worker installs backend + starts gRPC process
|
|
// 2. SmartRouter.Route() → LoadModel over the worker's tunnel
|
|
// 3. [inference over the tunnel]
|
|
// 4. backend.stop → worker stops gRPC process
|
|
})
|