package nodes import ( "context" "errors" "runtime" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/mudler/LocalAI/core/services/nodes/prefixcache" "github.com/mudler/LocalAI/core/services/testutil" "gorm.io/gorm" ) // --------------------------------------------------------------------------- // Fake ModelScheduler // --------------------------------------------------------------------------- type fakeScheduler struct { scheduleNode *BackendNode scheduleErr error scheduleCalls []scheduleCall } type scheduleCall struct { modelName string candidateIDs []string } func (f *fakeScheduler) ScheduleAndLoadModel(_ context.Context, modelName string, candidateNodeIDs []string) (*BackendNode, error) { f.scheduleCalls = append(f.scheduleCalls, scheduleCall{modelName, candidateNodeIDs}) return f.scheduleNode, f.scheduleErr } func mustGetSched(r *NodeRegistry, model string) ModelSchedulingConfig { cfg, err := r.GetModelScheduling(context.Background(), model) Expect(err).ToNot(HaveOccurred()) Expect(cfg).ToNot(BeNil()) return *cfg } var _ = Describe("ReplicaReconciler", func() { var ( db *gorm.DB registry *NodeRegistry ) BeforeEach(func() { if runtime.GOOS == "darwin" { Skip("testcontainers requires Docker, not available on macOS CI") } db = testutil.SetupTestDB() var err error registry, err = NewNodeRegistry(db) Expect(err).ToNot(HaveOccurred()) }) // Helper to register a healthy node with enough replica capacity for // most tests. Pre-PR4 the reconciler ignored capacity, so existing // fixtures didn't bother setting MaxReplicasPerModel — bumping the // default here keeps the test intent ("scale up enough") working under // the new capacity-aware logic. Tests that specifically exercise the // circuit breaker should register nodes with a tighter cap. registerNode := func(name, address string) *BackendNode { node := &BackendNode{ Name: name, NodeType: NodeTypeBackend, Address: address, MaxReplicasPerModel: 4, } Expect(registry.Register(context.Background(), node, true)).To(Succeed()) return node } // Helper to set up a scheduling config. setSchedulingConfig := func(modelName string, minReplicas, maxReplicas int, nodeSelector string) { cfg := &ModelSchedulingConfig{ ModelName: modelName, MinReplicas: minReplicas, MaxReplicas: maxReplicas, NodeSelector: nodeSelector, } Expect(registry.SetModelScheduling(context.Background(), cfg)).To(Succeed()) } Context("spread_all mode", func() { It("targets one replica per matching node (empty selector = all nodes)", func() { n1 := registerNode("s1", "10.1.0.1:50051") registerNode("s2", "10.1.0.2:50051") // spread config, no selector -> all healthy backend nodes (2) Expect(registry.SetModelScheduling(context.Background(), &ModelSchedulingConfig{ ModelName: "spread-model", SpreadAll: true, })).To(Succeed()) scheduler := &fakeScheduler{scheduleNode: n1} reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, }) reconciler.reconcileModel(context.Background(), mustGetSched(registry, "spread-model")) // With current==0 and a target of 2, the MinReplicas floor path // schedules up to cluster capacity (2 nodes). Expect(len(scheduler.scheduleCalls)).To(Equal(2)) }) It("is a no-op when no nodes match", func() { Expect(registry.SetModelScheduling(context.Background(), &ModelSchedulingConfig{ ModelName: "spread-model", SpreadAll: true, NodeSelector: `{"tier":"nope"}`, })).To(Succeed()) scheduler := &fakeScheduler{} reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, }) reconciler.reconcileModel(context.Background(), mustGetSched(registry, "spread-model")) Expect(scheduler.scheduleCalls).To(BeEmpty()) }) }) Context("model below min_replicas", func() { It("scales up to min_replicas", func() { node := registerNode("node-1", "10.0.0.1:50051") setSchedulingConfig("model-a", 2, 4, "") scheduler := &fakeScheduler{ scheduleNode: node, } reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, DB: db, }) // No replicas loaded — should schedule 2 reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(HaveLen(2)) Expect(scheduler.scheduleCalls[0].modelName).To(Equal("model-a")) Expect(scheduler.scheduleCalls[1].modelName).To(Equal("model-a")) }) }) Context("all replicas busy and below max_replicas", func() { It("scales up by 1", func() { node := registerNode("node-busy", "10.0.0.2:50051") setSchedulingConfig("model-b", 1, 4, "") // Load 2 replicas, both busy (in_flight > 0) Expect(registry.SetNodeModel(context.Background(), node.ID, "model-b", 0, "loaded", "addr1", 0)).To(Succeed()) Expect(registry.IncrementInFlight(context.Background(), node.ID, "model-b", 0)).To(Succeed()) node2 := registerNode("node-busy-2", "10.0.0.3:50051") Expect(registry.SetNodeModel(context.Background(), node2.ID, "model-b", 0, "loaded", "addr2", 0)).To(Succeed()) Expect(registry.IncrementInFlight(context.Background(), node2.ID, "model-b", 0)).To(Succeed()) scheduler := &fakeScheduler{ scheduleNode: node, } reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, DB: db, }) reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(HaveLen(1)) Expect(scheduler.scheduleCalls[0].modelName).To(Equal("model-b")) }) }) Context("all replicas busy and at max_replicas", func() { It("does not scale up", func() { node := registerNode("node-max", "10.0.0.4:50051") setSchedulingConfig("model-c", 1, 2, "") // Load 2 replicas (at max), both busy Expect(registry.SetNodeModel(context.Background(), node.ID, "model-c", 0, "loaded", "addr1", 0)).To(Succeed()) Expect(registry.IncrementInFlight(context.Background(), node.ID, "model-c", 0)).To(Succeed()) node2 := registerNode("node-max-2", "10.0.0.5:50051") Expect(registry.SetNodeModel(context.Background(), node2.ID, "model-c", 0, "loaded", "addr2", 0)).To(Succeed()) Expect(registry.IncrementInFlight(context.Background(), node2.ID, "model-c", 0)).To(Succeed()) scheduler := &fakeScheduler{ scheduleNode: node, } reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, DB: db, }) reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(BeEmpty()) }) }) Context("idle replicas above min_replicas", func() { It("scales down after idle delay", func() { node1 := registerNode("node-idle-1", "10.0.0.6:50051") node2 := registerNode("node-idle-2", "10.0.0.7:50051") node3 := registerNode("node-idle-3", "10.0.0.8:50051") setSchedulingConfig("model-d", 1, 4, "") // Load 3 replicas, all idle with last_used in the past pastTime := time.Now().Add(-10 * time.Minute) for _, n := range []*BackendNode{node1, node2, node3} { Expect(registry.SetNodeModel(context.Background(), n.ID, "model-d", 0, "loaded", "", 0)).To(Succeed()) // Set last_used to past time to trigger scale-down db.Model(&NodeModel{}).Where("node_id = ? AND model_name = ?", n.ID, "model-d"). Update("last_used", pastTime) } unloader := &fakeUnloader{} reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Unloader: unloader, DB: db, ScaleDownDelay: 1 * time.Minute, // short delay for test }) reconciler.reconcile(context.Background()) // Should scale down 2 replicas (3 - floor of 1) Expect(unloader.unloadCalls).To(HaveLen(2)) }) }) Context("idle replicas at min_replicas", func() { It("does not scale down", func() { node1 := registerNode("node-keep-1", "10.0.0.9:50051") node2 := registerNode("node-keep-2", "10.0.0.10:50051") setSchedulingConfig("model-e", 2, 4, "") // Load exactly 2 replicas (at min), both idle with past last_used pastTime := time.Now().Add(-10 * time.Minute) for _, n := range []*BackendNode{node1, node2} { Expect(registry.SetNodeModel(context.Background(), n.ID, "model-e", 0, "loaded", "", 0)).To(Succeed()) db.Model(&NodeModel{}).Where("node_id = ? AND model_name = ?", n.ID, "model-e"). Update("last_used", pastTime) } unloader := &fakeUnloader{} reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Unloader: unloader, DB: db, ScaleDownDelay: 1 * time.Minute, }) reconciler.reconcile(context.Background()) Expect(unloader.unloadCalls).To(BeEmpty()) }) }) Context("model with node_selector", func() { It("passes candidate node IDs to scheduler", func() { node1 := registerNode("gpu-node", "10.0.0.11:50051") node2 := registerNode("cpu-node", "10.0.0.12:50051") // Add labels — only node1 matches the selector Expect(registry.SetNodeLabel(context.Background(), node1.ID, "gpu.vendor", "nvidia")).To(Succeed()) Expect(registry.SetNodeLabel(context.Background(), node2.ID, "gpu.vendor", "none")).To(Succeed()) setSchedulingConfig("model-f", 1, 2, `{"gpu.vendor":"nvidia"}`) scheduler := &fakeScheduler{ scheduleNode: node1, } reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, DB: db, }) // No replicas loaded — should schedule 1 with candidate node IDs reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(HaveLen(1)) Expect(scheduler.scheduleCalls[0].modelName).To(Equal("model-f")) Expect(scheduler.scheduleCalls[0].candidateIDs).To(ContainElement(node1.ID)) Expect(scheduler.scheduleCalls[0].candidateIDs).ToNot(ContainElement(node2.ID)) }) }) Describe("Forced-disturb pressure autoscale (Phase 6)", func() { It("scales up when pressure exceeds threshold, replicas= threshold every tick and // drives the model toward MaxReplicas off a single burst. node := registerNode("consume-node", "10.0.0.64:50051") Expect(registry.SetNodeModel(context.Background(), node.ID, "consume-model", 0, "loaded", "addr1", 0)).To(Succeed()) setSchedulingConfig("consume-model", 1, 4, "") pressure := prefixcache.NewPressure(time.Minute) now := time.Now() pressure.Record("consume-model", now) pressure.Record("consume-model", now) pressure.Record("consume-model", now) scheduler := &fakeScheduler{scheduleNode: node} reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, DB: db, Pressure: pressure, }) // First tick: pressure above threshold → one scale-up. reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(HaveLen(1), "first tick must scale up once on the burst") // Second tick: the burst's events are still inside the window, but // the first scale-up Reset them, so no further scale-up occurs. reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(HaveLen(1), "a single burst must not re-trigger scale-up on the next in-window tick") }) It("does not consume the pressure signal when scaleUp fails", func() { // Pressure above threshold and capacity exists, but the scheduler // errors so no replica is actually added. The forced-disturb signal // must be preserved (NOT Reset) so the next tick retries the // scale-up off the same accumulated pressure, instead of having to // re-accumulate a full window of forced-disturbs from scratch. node := registerNode("fail-node", "10.0.0.66:50051") Expect(registry.SetNodeModel(context.Background(), node.ID, "fail-model", 0, "loaded", "addr1", 0)).To(Succeed()) setSchedulingConfig("fail-model", 1, 4, "") pressure := prefixcache.NewPressure(time.Minute) now := time.Now() pressure.Record("fail-model", now) pressure.Record("fail-model", now) pressure.Record("fail-model", now) Expect(pressure.Count("fail-model", time.Now())).To(BeNumerically(">=", 1)) // Scheduler errors: scaleUp attempts but adds nothing. scheduler := &fakeScheduler{scheduleErr: errors.New("schedule boom")} reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, DB: db, Pressure: pressure, }) reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(HaveLen(1), "scaleUp must have attempted exactly one schedule call") Expect(pressure.Count("fail-model", time.Now())).To(BeNumerically(">=", 1), "a failed scaleUp must NOT consume (Reset) the pressure signal — next tick should retry") }) It("consumes the pressure signal only when scaleUp succeeds", func() { // Mirror of the failure case: when the scheduler succeeds and a // replica is actually added, the forced-disturb signal IS consumed // (Reset to 0) so a single burst scales up only once. node := registerNode("ok-node", "10.0.0.67:50051") Expect(registry.SetNodeModel(context.Background(), node.ID, "ok-model", 0, "loaded", "addr1", 0)).To(Succeed()) setSchedulingConfig("ok-model", 1, 4, "") pressure := prefixcache.NewPressure(time.Minute) now := time.Now() pressure.Record("ok-model", now) pressure.Record("ok-model", now) pressure.Record("ok-model", now) scheduler := &fakeScheduler{scheduleNode: node} reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, DB: db, Pressure: pressure, }) reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(HaveLen(1), "successful scaleUp must have scheduled one replica") Expect(pressure.Count("ok-model", time.Now())).To(Equal(0), "a successful scaleUp must consume (Reset) the pressure signal to 0") }) It("performs at most one scale-up per tick when both busy and over pressure", func() { // The single loaded replica is busy (all-replicas-busy fires) AND // pressure is above threshold. Both scale-up paths are eligible in // the same tick. The invariant is at-most-one scaleUp(+1) per tick, // so exactly one schedule call must happen, not two. node := registerNode("dual-node", "10.0.0.65:50051") Expect(registry.SetNodeModel(context.Background(), node.ID, "dual-model", 0, "loaded", "addr1", 0)).To(Succeed()) Expect(registry.IncrementInFlight(context.Background(), node.ID, "dual-model", 0)).To(Succeed()) setSchedulingConfig("dual-model", 1, 4, "") pressure := prefixcache.NewPressure(time.Minute) pressure.Record("dual-model", time.Now()) pressure.Record("dual-model", time.Now()) scheduler := &fakeScheduler{scheduleNode: node} reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{ Registry: registry, Scheduler: scheduler, DB: db, Pressure: pressure, }) reconciler.reconcile(context.Background()) Expect(scheduler.scheduleCalls).To(HaveLen(1), "busy + pressure in one tick must still scale up by exactly one, not two") }) It("does not spin when pressure is high but no capacity exists", func() { // Single node, cap 1, already loaded → capacity 0. Pressure is high // but there is nowhere to place a replica: must not call scheduler. registerCappedNodeFn := func(name, address string, cap int) *BackendNode { node := &BackendNode{ Name: name, NodeType: NodeTypeBackend, Address: address, MaxReplicasPerModel: cap, } Expect(registry.Register(context.Background(), node, true)).To(Succeed()) return node } node := registerCappedNodeFn("pcap-node", "10.0.0.63:50051", 1) Expect(registry.SetNodeModel(context.Background(), node.ID, "pcap-model", 0, "loaded", "addr1", 0)).To(Succeed()) // MaxReplicas high enough that replicas", before)) }) }) Describe("NewNodeRegistry malformed-row pruning", func() { It("drops queue rows for agent nodes and non-existent nodes on startup", func() { agent := &BackendNode{Name: "agent-1", NodeType: NodeTypeAgent, Address: "x"} Expect(registry.Register(context.Background(), agent, true)).To(Succeed()) backend := &BackendNode{Name: "backend-1", NodeType: NodeTypeBackend, Address: "y"} Expect(registry.Register(context.Background(), backend, true)).To(Succeed()) // Three rows: one for a valid backend node (should survive), // one for an agent node (pruned), one for an empty backend name // on the valid node (pruned). Expect(registry.UpsertPendingBackendOp(context.Background(), backend.ID, "foo", OpBackendInstall, nil)).To(Succeed()) Expect(registry.UpsertPendingBackendOp(context.Background(), agent.ID, "foo", OpBackendInstall, nil)).To(Succeed()) Expect(registry.UpsertPendingBackendOp(context.Background(), backend.ID, "", OpBackendInstall, nil)).To(Succeed()) // Re-instantiating the registry runs the cleanup migration. _, err := NewNodeRegistry(db) Expect(err).ToNot(HaveOccurred()) var rows []PendingBackendOp Expect(db.Find(&rows).Error).To(Succeed()) Expect(rows).To(HaveLen(1)) Expect(rows[0].NodeID).To(Equal(backend.ID)) Expect(rows[0].Backend).To(Equal("foo")) }) }) })