Skip to content

Commit 170d55c

Browse files
localai-botmudler
andauthored
fix(distributed): honor NodeSelector in cached-replica lookup, stop empty-backend reconciler scaleups (#9652)
* fix(distributed): honor NodeSelector in cached-replica lookup, stop empty-backend reconciler scaleups Two distinct bugs were causing tight retry loops in the distributed scheduler: 1. FindAndLockNodeWithModel ignored the model's NodeSelector. When a model was loaded on multiple nodes and only some matched the current selector, the function returned the lowest-in_flight node — even one the selector excluded. Route()'s post-check then fell through to scheduleNewModel, which targeted the matching node where the model was already at MaxReplicasPerModel capacity. Eviction couldn't help (the only loaded model on that node was the one being requested, and it was busy), so every request looped through "evicting LRU" → "all models busy". Fix: thread an optional candidateNodeIDs filter through FindAndLockNodeWithModel. Route() resolves the selector once via a new resolveSelectorCandidates helper and passes the matching IDs to both the cached-replica lookup and scheduleNewModel. The same helper replaces the inline selector block in scheduleNewModel. 2. ScheduleAndLoadModel (reconciler scale-up path) fell back to scheduleNewModel with backendType="" when no replica had ever been loaded for a model. The worker rejected the resulting backend.install ("backend name is empty") on every reconciler tick (~30s). Fix: remove the broken fallback. When GetModelLoadInfo has nothing stored, return a clear error instead of firing a doomed NATS install. The reconciler's existing scale-up failure log surfaces it once per tick; the model auto-replicates as soon as Route() serves it once and stores load info. Also downgrade the post-LoadModel-failure StopGRPC error to Debug — that cleanup attempt usually hits "model not found" because LoadModel failed before registering the process, and the outer "Failed to load model" error already carries the real reason. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:claude-opus-4-7 [Read] [Edit] [Bash] * test(distributed): cover selector-aware FindAndLockNodeWithModel and reconciler scaleup guard Two regression tests for the bugs fixed in the previous commit: 1. FindAndLockNodeWithModel — registry-level integration tests verify the candidateNodeIDs filter: - Returns the included node even when an excluded node has lower in_flight (the original selector-mismatch loop scenario). - Returns not-found when the model is loaded only on excluded nodes, forcing Route() to fall through to a fresh schedule instead of reusing the excluded replica. 2. ScheduleAndLoadModel — mock-based test verifies the reconciler scale-up path returns an error and does NOT fire backend.install when no replica has been loaded yet. fakeUnloader gains an installCalls slice so this negative assertion is direct. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:claude-opus-4-7 [Read] [Edit] [Bash] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 28b4857 commit 170d55c

8 files changed

Lines changed: 150 additions & 33 deletions

File tree

core/services/nodes/interfaces.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import (
99

1010
// ModelRouter is used by SmartRouter for routing decisions and model lifecycle.
1111
type ModelRouter interface {
12-
FindAndLockNodeWithModel(ctx context.Context, modelName string) (*BackendNode, *NodeModel, error)
12+
FindAndLockNodeWithModel(ctx context.Context, modelName string, candidateNodeIDs []string) (*BackendNode, *NodeModel, error)
1313
DecrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
1414
IncrementInFlight(ctx context.Context, nodeID, modelName string, replicaIndex int) error
1515
RemoveNodeModel(ctx context.Context, nodeID, modelName string, replicaIndex int) error

core/services/nodes/model_router_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func newFakeModelRouterForSmartRouter() *fakeModelRouterForSmartRouter {
2727
}
2828
}
2929

30-
func (f *fakeModelRouterForSmartRouter) FindAndLockNodeWithModel(_ context.Context, _ string) (*BackendNode, *NodeModel, error) {
30+
func (f *fakeModelRouterForSmartRouter) FindAndLockNodeWithModel(_ context.Context, _ string, _ []string) (*BackendNode, *NodeModel, error) {
3131
f.mu.Lock()
3232
defer f.mu.Unlock()
3333
return f.node, f.nodeModel, f.findErr

core/services/nodes/registry.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -652,16 +652,26 @@ func (r *NodeRegistry) FindNodesWithModel(ctx context.Context, modelName string)
652652
// model loaded and increments its in-flight counter within a single transaction.
653653
// The SELECT FOR UPDATE row lock prevents concurrent eviction from removing the
654654
// NodeModel row between the find and increment operations.
655-
func (r *NodeRegistry) FindAndLockNodeWithModel(ctx context.Context, modelName string) (*BackendNode, *NodeModel, error) {
655+
//
656+
// When candidateNodeIDs is non-empty, only nodes in that set are considered.
657+
// Pass nil (or empty) to consider any node. This lets callers pre-filter by
658+
// NodeSelector so a cached replica on a now-excluded node isn't picked over a
659+
// matching replica elsewhere — the selector-mismatch fall-through path used to
660+
// trigger an eviction-busy loop when both sides had the model loaded.
661+
func (r *NodeRegistry) FindAndLockNodeWithModel(ctx context.Context, modelName string, candidateNodeIDs []string) (*BackendNode, *NodeModel, error) {
656662
var nm NodeModel
657663
var node BackendNode
658664

659665
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
660666
// Order by in_flight ASC (least busy replica), then by available_vram DESC
661667
// (prefer nodes with more free VRAM to spread load across the cluster).
662-
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
668+
q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
663669
Joins("JOIN backend_nodes ON backend_nodes.id = node_models.node_id").
664-
Where("node_models.model_name = ? AND node_models.state = ?", modelName, "loaded").
670+
Where("node_models.model_name = ? AND node_models.state = ?", modelName, "loaded")
671+
if len(candidateNodeIDs) > 0 {
672+
q = q.Where("node_models.node_id IN ?", candidateNodeIDs)
673+
}
674+
if err := q.
665675
Order("node_models.in_flight ASC, backend_nodes.available_vram DESC").
666676
First(&nm).Error; err != nil {
667677
return err

core/services/nodes/registry_test.go

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ var _ = Describe("NodeRegistry", func() {
244244
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
245245
Expect(registry.SetNodeModel(context.Background(), node.ID, "my-model", 0, "loaded", "10.0.0.40:50052", 0)).To(Succeed())
246246

247-
foundNode, foundNM, err := registry.FindAndLockNodeWithModel(context.Background(), "my-model")
247+
foundNode, foundNM, err := registry.FindAndLockNodeWithModel(context.Background(), "my-model", nil)
248248
Expect(err).ToNot(HaveOccurred())
249249
Expect(foundNode.ID).To(Equal(node.ID))
250250
Expect(foundNM.ModelName).To(Equal("my-model"))
@@ -256,7 +256,7 @@ var _ = Describe("NodeRegistry", func() {
256256
})
257257

258258
It("returns error when model is not loaded anywhere", func() {
259-
_, _, err := registry.FindAndLockNodeWithModel(context.Background(), "nonexistent-model")
259+
_, _, err := registry.FindAndLockNodeWithModel(context.Background(), "nonexistent-model", nil)
260260
Expect(err).To(HaveOccurred())
261261
})
262262

@@ -273,10 +273,52 @@ var _ = Describe("NodeRegistry", func() {
273273
Expect(registry.IncrementInFlight(context.Background(), n1.ID, "shared-model", 0)).To(Succeed())
274274
Expect(registry.IncrementInFlight(context.Background(), n1.ID, "shared-model", 0)).To(Succeed())
275275

276-
foundNode, _, err := registry.FindAndLockNodeWithModel(context.Background(), "shared-model")
276+
foundNode, _, err := registry.FindAndLockNodeWithModel(context.Background(), "shared-model", nil)
277277
Expect(err).ToNot(HaveOccurred())
278278
Expect(foundNode.Name).To(Equal("lock-light"))
279279
})
280+
281+
It("filters by candidateNodeIDs even when an excluded node has lower in_flight", func() {
282+
// Reproduces the selector-mismatch loop: a model loaded on a node
283+
// the selector now excludes (excluded) and on a node it includes
284+
// (included). Without the filter the excluded node wins on
285+
// in_flight ASC; with the filter the included node is returned
286+
// directly so Route() can serve from its existing replica.
287+
excluded := makeNode("excluded-node", "10.0.0.43:50051", 8_000_000_000)
288+
included := makeNode("included-node", "10.0.0.44:50051", 8_000_000_000)
289+
Expect(registry.Register(context.Background(), excluded, true)).To(Succeed())
290+
Expect(registry.Register(context.Background(), included, true)).To(Succeed())
291+
292+
Expect(registry.SetNodeModel(context.Background(), excluded.ID, "filtered-model", 0, "loaded", "", 0)).To(Succeed())
293+
Expect(registry.SetNodeModel(context.Background(), included.ID, "filtered-model", 0, "loaded", "", 0)).To(Succeed())
294+
295+
// Make `included` strictly busier than `excluded` so the unfiltered
296+
// query would prefer the excluded one — proving the filter is
297+
// what's steering the result, not the in_flight ordering.
298+
Expect(registry.IncrementInFlight(context.Background(), included.ID, "filtered-model", 0)).To(Succeed())
299+
Expect(registry.IncrementInFlight(context.Background(), included.ID, "filtered-model", 0)).To(Succeed())
300+
301+
foundNode, foundNM, err := registry.FindAndLockNodeWithModel(context.Background(), "filtered-model", []string{included.ID})
302+
Expect(err).ToNot(HaveOccurred())
303+
Expect(foundNode.ID).To(Equal(included.ID))
304+
Expect(foundNM.NodeID).To(Equal(included.ID))
305+
})
306+
307+
It("returns not-found when the model is loaded only on excluded nodes", func() {
308+
loadedExcluded := makeNode("excl-only-node", "10.0.0.45:50051", 8_000_000_000)
309+
emptyIncluded := makeNode("empty-included-node", "10.0.0.46:50051", 8_000_000_000)
310+
Expect(registry.Register(context.Background(), loadedExcluded, true)).To(Succeed())
311+
Expect(registry.Register(context.Background(), emptyIncluded, true)).To(Succeed())
312+
313+
Expect(registry.SetNodeModel(context.Background(), loadedExcluded.ID, "no-match-model", 0, "loaded", "", 0)).To(Succeed())
314+
315+
// Filter restricts to a node that does not have the model — the
316+
// query must return an error so Route() falls through to schedule
317+
// a fresh load on a matching node instead of reusing the excluded
318+
// replica.
319+
_, _, err := registry.FindAndLockNodeWithModel(context.Background(), "no-match-model", []string{emptyIncluded.ID})
320+
Expect(err).To(HaveOccurred())
321+
})
280322
})
281323

282324
Describe("MarkHealthy and MarkUnhealthy round-trip", func() {

core/services/nodes/router.go

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,13 @@ func (r *SmartRouter) ScheduleAndLoadModel(ctx context.Context, modelName string
150150
// Get load info from an existing replica (stored when Route() first loaded the model)
151151
backendType, optsBlob, err := r.registry.GetModelLoadInfo(ctx, modelName)
152152
if err != nil {
153-
// No existing replica with stored opts — fall back to install-only.
154-
// This happens on the very first load (before Route() has stored opts).
155-
xlog.Warn("No stored model load info for reconciler scale-up, falling back to backend install only",
156-
"model", modelName, "error", err)
157-
node, _, _, schedErr := r.scheduleNewModel(ctx, "", modelName, nil)
158-
return node, schedErr
153+
// No replica has ever been loaded for this model, so we have no
154+
// backend type or model options to replicate. The previous fallback
155+
// fired backend.install with backend="" every reconciler tick, which
156+
// the worker rejected ("backend name is empty"). Skip cleanly: the
157+
// model needs to be served at least once via Route() so its load
158+
// info is stored — then the reconciler can replicate it.
159+
return nil, fmt.Errorf("no load info for model %s: serve at least one request for it before the reconciler can replicate (cause: %w)", modelName, err)
159160
}
160161

161162
// Deserialize the stored model options
@@ -198,8 +199,18 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
198199
trackingKey = modelName
199200
}
200201

202+
// Resolve the model's NodeSelector once so cached-replica lookup and the
203+
// new-load scheduler agree on the candidate set. Without this, a cached
204+
// replica on a node the selector now excludes was picked over a matching
205+
// replica elsewhere, and the fall-through then tried to load on the
206+
// matching node where the model was already at capacity (eviction-busy).
207+
candidateNodeIDs, err := r.resolveSelectorCandidates(ctx, trackingKey)
208+
if err != nil {
209+
return nil, err
210+
}
211+
201212
// Step 1: Find and atomically lock a node with this model loaded
202-
node, nm, err := r.registry.FindAndLockNodeWithModel(ctx, trackingKey)
213+
node, nm, err := r.registry.FindAndLockNodeWithModel(ctx, trackingKey, candidateNodeIDs)
203214
if err == nil && node != nil {
204215
modelAddr := node.Address
205216
if nm.Address != "" {
@@ -246,7 +257,7 @@ func (r *SmartRouter) Route(ctx context.Context, modelID, modelName, backendType
246257
// Step 2: Model not loaded — schedule loading with distributed lock to prevent duplicates
247258
loadModel := func() (*RouteResult, error) {
248259
// Re-check after acquiring lock — another request may have loaded it
249-
node, nm, err := r.registry.FindAndLockNodeWithModel(ctx, trackingKey)
260+
node, nm, err := r.registry.FindAndLockNodeWithModel(ctx, trackingKey, candidateNodeIDs)
250261
if err == nil && node != nil {
251262
modelAddr := node.Address
252263
if nm.Address != "" {
@@ -347,6 +358,30 @@ func extractNodeIDs(nodes []BackendNode) []string {
347358
return ids
348359
}
349360

361+
// resolveSelectorCandidates returns the node IDs that match the model's
362+
// NodeSelector. Returns nil when no selector is configured ("any healthy node"
363+
// — registry helpers treat nil as no filter). Returns an error when a
364+
// non-empty selector matches zero healthy nodes, since there is nothing to
365+
// route or schedule on.
366+
func (r *SmartRouter) resolveSelectorCandidates(ctx context.Context, modelID string) ([]string, error) {
367+
sched, _ := r.registry.GetModelScheduling(ctx, modelID)
368+
if sched == nil || sched.NodeSelector == "" {
369+
return nil, nil
370+
}
371+
selector := parseSelectorJSON(sched.NodeSelector)
372+
if len(selector) == 0 {
373+
return nil, nil
374+
}
375+
candidates, err := r.registry.FindNodesBySelector(ctx, selector)
376+
if err != nil {
377+
return nil, fmt.Errorf("looking up nodes for selector %s: %w", sched.NodeSelector, err)
378+
}
379+
if len(candidates) == 0 {
380+
return nil, fmt.Errorf("no healthy nodes match selector for model %s: %s", modelID, sched.NodeSelector)
381+
}
382+
return extractNodeIDs(candidates), nil
383+
}
384+
350385
// nodeMatchesScheduling checks if a node satisfies the scheduling constraints for a model.
351386
// Returns true if no constraints exist or the node matches all selector labels.
352387
func (r *SmartRouter) nodeMatchesScheduling(ctx context.Context, node *BackendNode, modelName string) bool {
@@ -398,18 +433,9 @@ func (r *SmartRouter) scheduleNewModel(ctx context.Context, backendType, modelID
398433
// Check for scheduling constraints (node selector). If a selector is set,
399434
// we restrict the candidate pool to matching nodes; otherwise nil means
400435
// "any healthy node".
401-
sched, _ := r.registry.GetModelScheduling(ctx, modelID)
402-
var candidateNodeIDs []string
403-
404-
if sched != nil && sched.NodeSelector != "" {
405-
selector := parseSelectorJSON(sched.NodeSelector)
406-
if len(selector) > 0 {
407-
candidates, err := r.registry.FindNodesBySelector(ctx, selector)
408-
if err != nil || len(candidates) == 0 {
409-
return nil, "", 0, fmt.Errorf("no healthy nodes match selector for model %s: %v", modelID, sched.NodeSelector)
410-
}
411-
candidateNodeIDs = extractNodeIDs(candidates)
412-
}
436+
candidateNodeIDs, err := r.resolveSelectorCandidates(ctx, modelID)
437+
if err != nil {
438+
return nil, "", 0, err
413439
}
414440

415441
// Narrow candidates to nodes that still have a free replica slot for this

core/services/nodes/router_test.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ type fakeModelRouter struct {
114114
touchCalls []string
115115
}
116116

117-
func (f *fakeModelRouter) FindAndLockNodeWithModel(_ context.Context, modelName string) (*BackendNode, *NodeModel, error) {
117+
func (f *fakeModelRouter) FindAndLockNodeWithModel(_ context.Context, modelName string, _ []string) (*BackendNode, *NodeModel, error) {
118118
return f.findAndLockNode, f.findAndLockNM, f.findAndLockErr
119119
}
120120

@@ -268,13 +268,26 @@ func (f *stubClientFactory) NewClient(_ string, _ bool) grpc.Backend {
268268
type fakeUnloader struct {
269269
installReply *messaging.BackendInstallReply
270270
installErr error
271-
stopCalls []string // "nodeID:model"
271+
installCalls []installCall // every InstallBackend invocation, in order
272+
stopCalls []string // "nodeID:model"
272273
stopErr error
273274
unloadCalls []string
274275
unloadErr error
275276
}
276277

277-
func (f *fakeUnloader) InstallBackend(_, _, _, _, _, _, _ string, _ int) (*messaging.BackendInstallReply, error) {
278+
// installCall captures the args we care about when asserting that the
279+
// reconciler / router did or did not fire a NATS install. The fake records
280+
// every call so tests can verify both presence and shape (e.g. that backend
281+
// is non-empty).
282+
type installCall struct {
283+
nodeID string
284+
backend string
285+
modelID string
286+
replica int
287+
}
288+
289+
func (f *fakeUnloader) InstallBackend(nodeID, backend, modelID, _, _, _, _ string, replica int) (*messaging.BackendInstallReply, error) {
290+
f.installCalls = append(f.installCalls, installCall{nodeID, backend, modelID, replica})
278291
return f.installReply, f.installErr
279292
}
280293

@@ -692,6 +705,28 @@ var _ = Describe("SmartRouter", func() {
692705
})
693706
})
694707

708+
Describe("ScheduleAndLoadModel (mock-based)", func() {
709+
It("returns an error and does not fire a NATS install when no load info is stored", func() {
710+
// Reproduces the reconciler scale-up bug: when GetModelLoadInfo
711+
// returns ErrRecordNotFound (no replica has ever been loaded),
712+
// the previous fallback called scheduleNewModel with an empty
713+
// backend type, which the worker rejected on every reconciler
714+
// tick. The fix bails out cleanly with an explanatory error and
715+
// never sends backend.install.
716+
unloader := &fakeUnloader{}
717+
reg := &fakeModelRouter{}
718+
router := NewSmartRouter(reg, SmartRouterOptions{Unloader: unloader})
719+
720+
node, err := router.ScheduleAndLoadModel(context.Background(), "never-loaded", nil)
721+
722+
Expect(err).To(HaveOccurred())
723+
Expect(node).To(BeNil())
724+
Expect(err.Error()).To(ContainSubstring("never-loaded"))
725+
Expect(unloader.installCalls).To(BeEmpty(),
726+
"reconciler must not fire backend.install when there is no load info to replicate")
727+
})
728+
})
729+
695730
// -----------------------------------------------------------------------
696731
// Integration tests using real PostgreSQL (existing)
697732
// -----------------------------------------------------------------------

pkg/model/initializers.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,12 @@ func (ml *ModelLoader) backendLoader(opts ...Option) (client grpc.Backend, err e
183183

184184
model, err := ml.LoadModel(o.modelID, o.model, ml.grpcModel(backend, o))
185185
if err != nil {
186+
// Defensive cleanup: the model usually wasn't registered yet (LoadModel
187+
// failed before that), so StopGRPC reporting "model not found" is the
188+
// expected case, not an error. The outer Failed-to-load log below
189+
// carries the real reason.
186190
if stopErr := ml.StopGRPC(only(o.modelID)); stopErr != nil {
187-
xlog.Error("error stopping model", "error", stopErr, "model", o.modelID)
191+
xlog.Debug("cleanup stop after failed load", "error", stopErr, "model", o.modelID)
188192
}
189193
xlog.Error("Failed to load model", "modelID", o.modelID, "error", err, "backend", o.backendString)
190194
return nil, err

tests/e2e/distributed/model_routing_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ var _ = Describe("Model Routing", Label("Distributed"), func() {
6363
Expect(models[0].InFlight).To(Equal(2))
6464

6565
// FindAndLockNodeWithModel should return this node and atomically increment in-flight
66-
foundNode, foundModel, err := registry.FindAndLockNodeWithModel(context.Background(), "llama3")
66+
foundNode, foundModel, err := registry.FindAndLockNodeWithModel(context.Background(), "llama3", nil)
6767
Expect(err).ToNot(HaveOccurred())
6868
Expect(foundNode.ID).To(Equal(node.ID))
6969
Expect(foundModel.ModelName).To(Equal("llama3"))

0 commit comments

Comments
 (0)