mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-16 00:00:58 -04:00
nodes.<id>.backend.stop was the last worker-facing NATS subject, and it existed only because ONE publisher had not moved. An agent worker already mounted workerctl.PathBackendStop on the tunnel it holds, and a backend worker already took its stop there, so RemoteUnloaderAdapter branched on NodeType to pick a carrier for a verb both kinds of worker served the same way. The branch is gone, and with it nodeTypeOf and its NodeTypeBackend default, which removes one of the ten NodeType branches left to sweep. The adapter loses its messaging.MessagingClient outright rather than keeping an unused field: it now holds no publisher, so re-routing any verb back onto the bus is a change to the struct and to every caller of the constructor, and does not compile until all of them agree. messaging.SubjectNodeBackendStop and subjectNodePrefix are deleted, the agent worker's subscription with them. pkg/natsauth drops the per-node backend.stop grant from the agent SUB list. That is a narrowing of eleven entries to ten, never to nothing: NATS reads an EMPTY allow list as unrestricted, so the coverage spec asserts both that the retired subject is no longer covered and that the queue subjects an agent worker lives on still are. The e2e half proves it against a real enforcing server: one spec subscribes successfully on an agent-minted JWT, the next is refused the retired subject on a JWT minted the same way. Both halves of the old split were pinned, so both pins are re-aimed rather than deleted, and the two node types are asserted separately rather than as one parameterised case, because only two cases can show that the two used to differ. Three assertions that the adapter published nothing are deleted instead: with no publisher to hold, no change could ever redden them. The CLI's handler set moves into agentWorkerControlHandlers so a spec can stand it up and post to it. That wiring was a bare literal no spec pinned, and deleting the subscription made it the ONLY carrier for backend.stop: a dropped field would have been a 404 the frontend reads as a worker too old to serve the verb, and nothing in the repo would have noticed. Mutations: the agent branch restored off the control route reddens two specs; the backend branch restored, separately, reddens five; PathBackendDelete in place of PathBackendStop reddens nine across both node types; dropping the CLI wiring line reddens the new wiring table; re-adding the allow-list entry reddens the unit spec and the JWT e2e spec; and restoring the publisher for real does not compile. Four comments this change falsified are fixed, in core/cli, pkg/model and the distributed-mode docs, which now say both kinds of worker serve POST /v1/control/backend/stop and what each does with it. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
284 lines
9.8 KiB
Go
284 lines
9.8 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/services/galleryop"
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/LocalAI/core/services/nodes"
|
|
"github.com/mudler/LocalAI/core/services/workerctl"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var _ = Describe("Model and Backend Managers", Label("Distributed"), func() {
|
|
var (
|
|
infra *TestInfra
|
|
db *gorm.DB
|
|
registry *nodes.NodeRegistry
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
infra = SetupInfra("localai_managers_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("LocalModelManager", func() {
|
|
var (
|
|
tempDir string
|
|
ss *system.SystemState
|
|
ml *model.ModelLoader
|
|
localMgr *galleryop.LocalModelManager
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
tempDir, err = os.MkdirTemp("", "manager-model-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
ss, err = system.GetSystemState(system.WithModelPath(tempDir))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
ml = model.NewModelLoader(ss)
|
|
|
|
appCfg := config.NewApplicationConfig()
|
|
appCfg.SystemState = ss
|
|
localMgr = galleryop.NewLocalModelManager(appCfg, ml)
|
|
})
|
|
|
|
AfterEach(func() {
|
|
os.RemoveAll(tempDir)
|
|
})
|
|
|
|
It("should delete a model from the local filesystem", func() {
|
|
// Create a fake model config file
|
|
modelName := "test-model"
|
|
configFile := filepath.Join(tempDir, modelName+".yaml")
|
|
Expect(os.WriteFile(configFile, []byte("name: test-model\n"), 0644)).To(Succeed())
|
|
|
|
err := localMgr.DeleteModel(modelName)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Assert file is gone
|
|
_, err = os.Stat(configFile)
|
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Context("LocalBackendManager", func() {
|
|
var (
|
|
tempDir string
|
|
ss *system.SystemState
|
|
ml *model.ModelLoader
|
|
localMgr *galleryop.LocalBackendManager
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
var err error
|
|
tempDir, err = os.MkdirTemp("", "manager-backend-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
ss, err = system.GetSystemState(system.WithBackendPath(tempDir))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
ml = model.NewModelLoader(ss)
|
|
|
|
appCfg := config.NewApplicationConfig()
|
|
appCfg.SystemState = ss
|
|
localMgr = galleryop.NewLocalBackendManager(appCfg, ml)
|
|
})
|
|
|
|
AfterEach(func() {
|
|
os.RemoveAll(tempDir)
|
|
})
|
|
|
|
It("should delete a backend from the local filesystem", func() {
|
|
// Create a fake backend directory with run.sh
|
|
backendName := "test-backend"
|
|
backendDir := filepath.Join(tempDir, backendName)
|
|
Expect(os.MkdirAll(backendDir, 0750)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(backendDir, "run.sh"), []byte("#!/bin/bash\necho test"), 0755)).To(Succeed())
|
|
|
|
err := localMgr.DeleteBackend(backendName)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Assert directory is gone
|
|
_, err = os.Stat(backendDir)
|
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
Context("DistributedModelManager", func() {
|
|
It("should delete model locally AND send model.delete to worker nodes", func() {
|
|
// Register two nodes with the model
|
|
node1 := &nodes.BackendNode{Name: "dm-n1", Address: "h1:50051"}
|
|
node2 := &nodes.BackendNode{Name: "dm-n2", Address: "h2:50051"}
|
|
Expect(registry.Register(context.Background(), node1, true)).To(Succeed())
|
|
Expect(registry.Register(context.Background(), node2, true)).To(Succeed())
|
|
Expect(registry.SetNodeModel(context.Background(), node1.ID, "big-model", 0, "loaded", "", 0)).To(Succeed())
|
|
Expect(registry.SetNodeModel(context.Background(), node2.ID, "big-model", 0, "loaded", "", 0)).To(Succeed())
|
|
|
|
// Both workers serve model.delete on their own control plane.
|
|
var deleteCount atomic.Int32
|
|
workers := NewControlWorkers()
|
|
for _, id := range []string{node1.ID, node2.ID} {
|
|
workers.On(id, workerctl.PathModelDelete, func(_ string, data []byte) any {
|
|
var req messaging.ModelDeleteRequest
|
|
Expect(json.Unmarshal(data, &req)).To(Succeed())
|
|
Expect(req.ModelName).To(Equal("big-model"))
|
|
deleteCount.Add(1)
|
|
return messaging.ModelDeleteReply{Success: true}
|
|
})
|
|
}
|
|
|
|
// Create temp dir for local model files
|
|
tempDir, err := os.MkdirTemp("", "dist-model-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
// Create a fake model config file
|
|
modelFile := filepath.Join(tempDir, "big-model.yaml")
|
|
Expect(os.WriteFile(modelFile, []byte("name: big-model\n"), 0644)).To(Succeed())
|
|
|
|
ss, err := system.GetSystemState(system.WithModelPath(tempDir))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
ml := model.NewModelLoader(ss)
|
|
appCfg := config.NewApplicationConfig()
|
|
appCfg.SystemState = ss
|
|
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
distMgr := nodes.NewDistributedModelManager(appCfg, ml, adapter)
|
|
|
|
err = distMgr.DeleteModel("big-model")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Local file should be deleted
|
|
_, statErr := os.Stat(modelFile)
|
|
Expect(os.IsNotExist(statErr)).To(BeTrue())
|
|
|
|
// Both workers should have received model.delete
|
|
Eventually(func() int32 { return deleteCount.Load() }, "5s").Should(Equal(int32(2)))
|
|
})
|
|
})
|
|
|
|
Context("DistributedBackendManager", func() {
|
|
It("should delete backend locally AND fan out backend.delete to all healthy nodes", func() {
|
|
// Register 3 nodes: 2 healthy, 1 unhealthy
|
|
node1 := &nodes.BackendNode{Name: "db-n1", Address: "h1:50051"}
|
|
node2 := &nodes.BackendNode{Name: "db-n2", Address: "h2:50051"}
|
|
node3 := &nodes.BackendNode{Name: "db-n3", Address: "h3:50051"}
|
|
Expect(registry.Register(context.Background(), node1, true)).To(Succeed())
|
|
Expect(registry.Register(context.Background(), node2, true)).To(Succeed())
|
|
Expect(registry.Register(context.Background(), node3, true)).To(Succeed())
|
|
Expect(registry.MarkUnhealthy(context.Background(), node3.ID)).To(Succeed())
|
|
|
|
// All 3 workers serve backend.delete on their control plane.
|
|
var deleteCount atomic.Int32
|
|
workers := NewControlWorkers()
|
|
for _, id := range []string{node1.ID, node2.ID} {
|
|
workers.On(id, workerctl.PathBackendDelete, func(_ string, data []byte) any {
|
|
var req messaging.BackendDeleteRequest
|
|
Expect(json.Unmarshal(data, &req)).To(Succeed())
|
|
Expect(req.Backend).To(Equal("my-backend"))
|
|
deleteCount.Add(1)
|
|
return messaging.BackendDeleteReply{Success: true}
|
|
})
|
|
}
|
|
|
|
var unhealthyReceived atomic.Int32
|
|
workers.On(node3.ID, workerctl.PathBackendDelete, func(string, []byte) any {
|
|
unhealthyReceived.Add(1)
|
|
return messaging.BackendDeleteReply{Success: true}
|
|
})
|
|
|
|
// Create temp dir for local backend files
|
|
tempDir, err := os.MkdirTemp("", "dist-backend-test-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
// Create a fake backend directory
|
|
backendDir := filepath.Join(tempDir, "my-backend")
|
|
Expect(os.MkdirAll(backendDir, 0750)).To(Succeed())
|
|
Expect(os.WriteFile(filepath.Join(backendDir, "run.sh"), []byte("#!/bin/bash\necho test"), 0755)).To(Succeed())
|
|
|
|
ss, err := system.GetSystemState(system.WithBackendPath(tempDir))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
ml := model.NewModelLoader(ss)
|
|
appCfg := config.NewApplicationConfig()
|
|
appCfg.SystemState = ss
|
|
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
distMgr := nodes.NewDistributedBackendManager(appCfg, ml, adapter, registry, nil)
|
|
|
|
err = distMgr.DeleteBackend("my-backend")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Local backend dir should be deleted
|
|
_, statErr := os.Stat(backendDir)
|
|
Expect(os.IsNotExist(statErr)).To(BeTrue())
|
|
|
|
// 2 healthy nodes should have received backend.delete
|
|
Eventually(func() int32 { return deleteCount.Load() }, "5s").Should(Equal(int32(2)))
|
|
|
|
// Unhealthy node should NOT have received backend.delete
|
|
Consistently(func() int32 { return unhealthyReceived.Load() }, "1s").Should(Equal(int32(0)))
|
|
})
|
|
|
|
It("should succeed when backend exists only on remote workers (not locally)", func() {
|
|
// Register a healthy node
|
|
node1 := &nodes.BackendNode{Name: "db-remote-only", Address: "h1:50051"}
|
|
Expect(registry.Register(context.Background(), node1, true)).To(Succeed())
|
|
|
|
var deleteCount atomic.Int32
|
|
workers := NewControlWorkers()
|
|
workers.On(node1.ID, workerctl.PathBackendDelete, func(_ string, data []byte) any {
|
|
var req messaging.BackendDeleteRequest
|
|
Expect(json.Unmarshal(data, &req)).To(Succeed())
|
|
Expect(req.Backend).To(Equal("remote-only-backend"))
|
|
deleteCount.Add(1)
|
|
return messaging.BackendDeleteReply{Success: true}
|
|
})
|
|
|
|
// Use a temp dir with NO local backend directory — simulates frontend node
|
|
tempDir, err := os.MkdirTemp("", "dist-backend-remote-only-*")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
ss, err := system.GetSystemState(system.WithBackendPath(tempDir))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
ml := model.NewModelLoader(ss)
|
|
appCfg := config.NewApplicationConfig()
|
|
appCfg.SystemState = ss
|
|
|
|
adapter := nodes.NewRemoteUnloaderAdapter(registry, workers.Client(), 3*time.Minute, 15*time.Minute)
|
|
distMgr := nodes.NewDistributedBackendManager(appCfg, ml, adapter, registry, nil)
|
|
|
|
// Should NOT return an error even though the backend doesn't exist locally
|
|
err = distMgr.DeleteBackend("remote-only-backend")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// The healthy worker should still receive the deletion request
|
|
Eventually(func() int32 { return deleteCount.Load() }, "5s").Should(Equal(int32(1)))
|
|
})
|
|
})
|
|
})
|