Skip to content

Commit b4fdb41

Browse files
localai-botmudler
andauthored
fix(distributed): cascade-clean stale node_models rows + filter routing by healthy status (#9754)
* fix(distributed): cascade-clean stale node_models on drain and filter routing by healthy status Stale node_models rows (state="loaded") were surviving past the healthy state of their owning node, causing /embeddings (and other inference paths) to dispatch to a backend whose process was gone or drained. The downstream symptom in a live cluster was pgvector rejecting inserts with "vector cannot have more than 16000 dimensions (SQLSTATE 54000)" because the misbehaving backend silently returned a malformed (oversized) tensor; the Models page showed the model as "running" without an associated node, like a stale entry, even though the node was no longer visible in the Nodes view. Two changes here, plus a third in a follow-up commit: - MarkDraining now cascade-deletes node_models rows for the affected node, mirroring MarkOffline. Drains are explicit operator actions — the box has been intentionally taken out of rotation — so clearing the rows stops the Models UI from misreporting and prevents the routing layer from picking those rows if scheduling logic is ever relaxed. In-flight requests already hold their gRPC client through Route() and finish normally; the only observable effect is a non-fatal IncrementInFlight warning, acceptable for a drain. MarkUnhealthy is deliberately left status-only: it fires from managers_distributed / reconciler on a single nats.ErrNoResponders with no retry, so a transient NATS hiccup must not nuke every loaded model and force a full reload on recovery. - FindAndLockNodeWithModel's inner JOIN now filters on backend_nodes.status = healthy in addition to node_models.state = loaded. The previous version relied on the second node-fetch step to reject non-healthy nodes, but a concurrent reader could still pick the same stale row in the same window. Belt-and-braces. - DistributedConfig.PerModelHealthCheck renamed to DisablePerModelHealthCheck and inverted at the call site so per-model gRPC probing is on by default. The probe (now made consecutive-miss aware in a follow-up commit) independently health- checks each model's gRPC address and removes stale node_models rows when the backend has crashed even though the worker's node-level heartbeat is still arriving. Migration: the field had no CLI flag, env var binding, or YAML key in tree (only the bare struct field), so there is no user-facing migration. Anything constructing DistributedConfig in code needs to drop the assignment (default now does the right thing) or invert it. Assisted-by: Claude:claude-opus-4-7 go-vet go-test golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): require consecutive misses before per-model probe removes a row The per-model gRPC probe used to remove a node_models row on a single failed health check. With the per-model probe now on by default, that made any 5-second gRPC blip (network jitter, a long-running request hogging the worker's gRPC server thread, brief GC pause) trigger a full reload of the affected model — too eager for production. Require perModelMissThreshold (3) consecutive failed probes before removal. At the default 15s tick a model must be unreachable for ~45s before reap; a single successful probe in between resets the streak. Per-(node, model, replica) state tracked under a mutex on the monitor. If the removal call itself fails, the miss counter is left in place so the next tick retries rather than starting the streak over. Tests: - removes stale model via per-model health check after consecutive failures (replaces the single-shot expectation) - preserves model row when an intermittent failure is followed by a success (covers the reset-on-success path and verifies the counter reset by failing twice more without crossing threshold) - newTestHealthMonitor initializes the misses map so direct-construct test helpers don't nil-map-panic in the probe path Assisted-by: Claude:claude-opus-4-7 go-vet go-test golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 0245b33 commit b4fdb41

6 files changed

Lines changed: 144 additions & 15 deletions

File tree

core/application/distributed.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
169169
cfg.Distributed.HealthCheckIntervalOrDefault(),
170170
cfg.Distributed.StaleNodeThresholdOrDefault(),
171171
routerAuthToken,
172-
cfg.Distributed.PerModelHealthCheck,
172+
!cfg.Distributed.DisablePerModelHealthCheck,
173173
)
174174

175175
// Initialize job store

core/config/distributed_config.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,15 @@ type DistributedConfig struct {
3131
DrainTimeout time.Duration // Time to wait for in-flight requests during drain (default 30s)
3232
HealthCheckInterval time.Duration // Health monitor check interval (default 15s)
3333
StaleNodeThreshold time.Duration // Time before a node is considered stale (default 60s)
34-
PerModelHealthCheck bool // Enable per-model backend health checking (default false)
34+
// DisablePerModelHealthCheck turns off the health monitor's per-model
35+
// gRPC probe. When enabled (the default), the monitor pings each model's
36+
// gRPC address and removes stale node_models rows whose backend has
37+
// crashed even though the worker's node-level heartbeat is still arriving.
38+
// Without per-model probing, /embeddings and /completions can be dispatched
39+
// to a backend that silently returns garbage (see also the cascading
40+
// model-row cleanup on MarkUnhealthy / MarkDraining).
41+
DisablePerModelHealthCheck bool
42+
3543
MCPCIJobTimeout time.Duration // MCP CI job execution timeout (default 10m)
3644

3745
MaxUploadSize int64 // Maximum upload body size in bytes (default 50 GB)

core/services/nodes/health.go

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,24 @@ import (
1212
"gorm.io/gorm"
1313
)
1414

15+
// perModelMissThreshold is the number of consecutive failed gRPC probes
16+
// against a model's backend before the model is removed from the registry.
17+
// A single failure can be transient (network blip, brief GC pause on the
18+
// worker, a long-running request hogging the gRPC server thread); requiring
19+
// N consecutive misses avoids deleting healthy rows over noise. At the
20+
// default 15s tick this means a model has to be unreachable for ~45s before
21+
// it gets reaped.
22+
const perModelMissThreshold = 3
23+
24+
// modelKey identifies a specific (node, model, replica) tuple. We track miss
25+
// counts per tuple because the same model name can be loaded on multiple
26+
// replicas on the same node.
27+
type modelKey struct {
28+
NodeID string
29+
ModelName string
30+
ReplicaIndex int
31+
}
32+
1533
// HealthMonitor periodically checks the health of registered backend nodes.
1634
type HealthMonitor struct {
1735
registry NodeHealthStore
@@ -21,6 +39,8 @@ type HealthMonitor struct {
2139
autoOffline bool // mark stale nodes as offline (preserves approval status)
2240
clientFactory BackendClientFactory // creates gRPC backend clients
2341
perModelHealthCheck bool // check each model's backend process individually
42+
missesMu sync.Mutex
43+
misses map[modelKey]int // consecutive failed-probe counts; reset on success or model removal
2444
cancel context.CancelFunc
2545
cancelMu sync.Mutex
2646
}
@@ -46,6 +66,7 @@ func NewHealthMonitor(registry NodeHealthStore, db *gorm.DB, checkInterval, stal
4666
autoOffline: true,
4767
clientFactory: factory,
4868
perModelHealthCheck: perModelHealthCheck,
69+
misses: make(map[modelKey]int),
4970
}
5071
}
5172

@@ -152,9 +173,11 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
152173
}
153174
}
154175

155-
// Per-model backend health check (opt-in): probe each model's gRPC address
156-
// and remove stale model records. This does NOT affect the node's status —
157-
// a crashed backend process is a model-level issue, not a node-level one.
176+
// Per-model backend health check: probe each model's gRPC address and
177+
// remove stale model records. This does NOT affect the node's status —
178+
// a crashed backend process is a model-level issue, not a node-level
179+
// one. A model is only removed after perModelMissThreshold consecutive
180+
// failed probes so a single network/GC blip doesn't force a reload.
158181
if hm.perModelHealthCheck {
159182
models, _ := hm.registry.GetNodeModels(ctx, node.ID)
160183
for _, m := range models {
@@ -163,15 +186,43 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
163186
}
164187
mClient := hm.clientFactory.NewClient(m.Address, false)
165188
mCheckCtx, mCancel := context.WithTimeout(ctx, 5*time.Second)
166-
if ok, _ := mClient.HealthCheck(mCheckCtx); !ok {
167-
xlog.Warn("Model backend unhealthy, removing from registry",
168-
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.Address)
169-
hm.registry.RemoveNodeModel(ctx, node.ID, m.ModelName, m.ReplicaIndex)
170-
}
189+
ok, _ := mClient.HealthCheck(mCheckCtx)
171190
mCancel()
172191
if closer, ok := mClient.(io.Closer); ok {
173192
closer.Close()
174193
}
194+
195+
key := modelKey{NodeID: node.ID, ModelName: m.ModelName, ReplicaIndex: m.ReplicaIndex}
196+
hm.missesMu.Lock()
197+
if ok {
198+
// Probe succeeded — wipe any previous miss streak.
199+
delete(hm.misses, key)
200+
hm.missesMu.Unlock()
201+
continue
202+
}
203+
hm.misses[key]++
204+
misses := hm.misses[key]
205+
hm.missesMu.Unlock()
206+
207+
if misses < perModelMissThreshold {
208+
xlog.Debug("Model backend probe failed, awaiting threshold before removal",
209+
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex,
210+
"address", m.Address, "misses", misses, "threshold", perModelMissThreshold)
211+
continue
212+
}
213+
xlog.Warn("Model backend unhealthy after consecutive misses, removing from registry",
214+
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex,
215+
"address", m.Address, "misses", misses)
216+
if err := hm.registry.RemoveNodeModel(ctx, node.ID, m.ModelName, m.ReplicaIndex); err != nil {
217+
xlog.Warn("Failed to remove unhealthy model from registry",
218+
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex, "error", err)
219+
// Leave the miss counter in place so the next tick retries
220+
// the removal rather than starting the streak over.
221+
continue
222+
}
223+
hm.missesMu.Lock()
224+
delete(hm.misses, key)
225+
hm.missesMu.Unlock()
175226
}
176227
}
177228
}

core/services/nodes/health_mock_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ func newTestHealthMonitor(store NodeHealthStore, factory BackendClientFactory, a
324324
staleThreshold: staleThreshold,
325325
autoOffline: autoOffline,
326326
clientFactory: factory,
327+
misses: make(map[modelKey]int),
327328
}
328329
}
329330

core/services/nodes/health_test.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
255255
Expect(calls).NotTo(ContainElement(ContainSubstring("MarkUnhealthy")))
256256
})
257257

258-
It("removes stale model via per-model health check without affecting node status", func() {
258+
It("removes stale model via per-model health check after consecutive failures", func() {
259259
store := newFakeNodeHealthStore()
260260
factory := newFakeBackendClientFactory()
261261
hm := newTestHealthMonitor(store, factory, true, staleThreshold)
@@ -268,12 +268,52 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
268268
// Model backend is dead
269269
factory.setClient("10.0.0.10:50053", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
270270

271+
// First (perModelMissThreshold-1) probes must NOT remove the row —
272+
// a single failure could be a transient blip.
273+
for i := 0; i < perModelMissThreshold-1; i++ {
274+
hm.doCheckAll(context.Background())
275+
Expect(store.getCalls()).NotTo(ContainElement(ContainSubstring("RemoveNodeModel")),
276+
"removed too early at miss %d", i+1)
277+
}
278+
279+
// Threshold-th consecutive miss triggers removal.
271280
hm.doCheckAll(context.Background())
272281

273282
// Node should remain healthy — only the specific replica record is removed.
274283
Expect(store.getNode("node-model").Status).To(Equal(StatusHealthy))
275284
Expect(store.getCalls()).To(ContainElement("RemoveNodeModel:node-model:piper-model:0"))
276285
Expect(store.getCalls()).NotTo(ContainElement(ContainSubstring("MarkUnhealthy")))
277286
})
287+
288+
It("preserves model row when an intermittent failure is followed by a success", func() {
289+
store := newFakeNodeHealthStore()
290+
factory := newFakeBackendClientFactory()
291+
hm := newTestHealthMonitor(store, factory, true, staleThreshold)
292+
hm.perModelHealthCheck = true
293+
294+
node := makeTestNode("node-flap", "flap-worker", "10.0.0.11:50051", StatusHealthy, freshTime())
295+
store.addNode(node)
296+
store.addNodeModel("node-flap", NodeModel{NodeID: "node-flap", ModelName: "piper-model", Address: "10.0.0.11:50053"})
297+
298+
deadClient := &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")}
299+
liveClient := &fakeBackendClient{healthy: true}
300+
301+
// Two failing probes then a recovery — should NOT remove the row,
302+
// and should reset the miss counter so two more failures don't tip
303+
// it over.
304+
factory.setClient("10.0.0.11:50053", deadClient)
305+
hm.doCheckAll(context.Background())
306+
hm.doCheckAll(context.Background())
307+
factory.setClient("10.0.0.11:50053", liveClient)
308+
hm.doCheckAll(context.Background())
309+
310+
Expect(store.getCalls()).NotTo(ContainElement(ContainSubstring("RemoveNodeModel")))
311+
312+
// Counter is reset; two more failures must not be enough to remove.
313+
factory.setClient("10.0.0.11:50053", deadClient)
314+
hm.doCheckAll(context.Background())
315+
hm.doCheckAll(context.Background())
316+
Expect(store.getCalls()).NotTo(ContainElement(ContainSubstring("RemoveNodeModel")))
317+
})
278318
})
279319
})

core/services/nodes/registry.go

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,13 @@ func (r *NodeRegistry) GetByName(ctx context.Context, name string) (*BackendNode
546546
return &node, nil
547547
}
548548

549-
// MarkUnhealthy sets a node status to unhealthy.
549+
// MarkUnhealthy sets a node status to unhealthy. Deliberately status-only:
550+
// callers fire this on transient triggers (a single nats.ErrNoResponders from
551+
// managers_distributed / reconciler) where the next heartbeat is expected to
552+
// flip the node back to healthy, and cascade-deleting node_models here would
553+
// force a full model reload on every brief NATS hiccup. Stale rows are reaped
554+
// by the per-model health probe (on by default; see HealthMonitor) and by
555+
// MarkOffline when the heartbeat really has gone away.
550556
func (r *NodeRegistry) MarkUnhealthy(ctx context.Context, nodeID string) error {
551557
return r.setStatus(ctx, nodeID, StatusUnhealthy)
552558
}
@@ -556,9 +562,23 @@ func (r *NodeRegistry) MarkHealthy(ctx context.Context, nodeID string) error {
556562
return r.setStatus(ctx, nodeID, StatusHealthy)
557563
}
558564

559-
// MarkDraining sets a node status to draining (no new requests).
565+
// MarkDraining sets a node status to draining (no new requests) and clears its
566+
// model records. Routing already filters out non-healthy nodes, so removing
567+
// the rows on drain doesn't change new-request behavior — but it does stop the
568+
// Models UI from showing the node's models as "running" while the box has been
569+
// taken out of rotation, and it prevents stale rows from being selected if
570+
// (re)scheduling logic gets relaxed elsewhere. In-flight requests already hold
571+
// their gRPC client through Route() and will finish normally; the only
572+
// observable effect is that the per-call IncrementInFlight bookkeeping logs a
573+
// non-fatal warning, which is acceptable for a drain.
560574
func (r *NodeRegistry) MarkDraining(ctx context.Context, nodeID string) error {
561-
return r.setStatus(ctx, nodeID, StatusDraining)
575+
if err := r.setStatus(ctx, nodeID, StatusDraining); err != nil {
576+
return err
577+
}
578+
if err := r.db.WithContext(ctx).Where("node_id = ?", nodeID).Delete(&NodeModel{}).Error; err != nil {
579+
xlog.Warn("Failed to clear model records on draining", "node", nodeID, "error", err)
580+
}
581+
return nil
562582
}
563583

564584
// FindStaleNodes returns nodes that haven't sent a heartbeat within the given threshold.
@@ -673,9 +693,18 @@ func (r *NodeRegistry) FindAndLockNodeWithModel(ctx context.Context, modelName s
673693
// to moderate concurrency where requests don't overlap) collapses to
674694
// "biggest GPU wins every time" and one node ends up taking nearly all
675695
// the load while replicas on other nodes sit idle.
696+
// Filter on backend_nodes.status = healthy in the inner JOIN itself,
697+
// not only in the later node-fetch step. The previous version picked
698+
// a (node_id, replica) pair purely on node_models state, then bailed
699+
// out when the second query couldn't find a healthy node row — but
700+
// any concurrent reader of node_models could still pick the same
701+
// stale row in the same window, and other helpers that mirror this
702+
// JOIN need the same invariant. Belt-and-braces: status filter here
703+
// AND the status-checked node fetch below.
676704
q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
677705
Joins("JOIN backend_nodes ON backend_nodes.id = node_models.node_id").
678-
Where("node_models.model_name = ? AND node_models.state = ?", modelName, "loaded")
706+
Where("node_models.model_name = ? AND node_models.state = ? AND backend_nodes.status = ?",
707+
modelName, "loaded", StatusHealthy)
679708
if len(candidateNodeIDs) > 0 {
680709
q = q.Where("node_models.node_id IN ?", candidateNodeIDs)
681710
}

0 commit comments

Comments
 (0)