mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-14 15:18:03 -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>
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, infra.NC, 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, infra.NC, 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, infra.NC, 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)))
|
|
})
|
|
})
|
|
})
|