Skip to content

Commit a32c8b3

Browse files
committed
Drop redundant healthcheck
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 34b8562 commit a32c8b3

3 files changed

Lines changed: 76 additions & 58 deletions

File tree

core/services/nodes/health.go

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ func (hm *HealthMonitor) checkAll(ctx context.Context) {
103103
}
104104

105105
// doCheckAll performs the actual health check logic for all nodes.
106+
// Node liveness is determined by heartbeat freshness — both backend and agent
107+
// workers send periodic HTTP heartbeats to the frontend, so a stale heartbeat
108+
// means the worker supervisor is down. This is simpler and more reliable than
109+
// probing individual gRPC backend processes (which can crash independently).
110+
//
111+
// Per-model health checks (opt-in) separately probe each model's gRPC address
112+
// and remove stale model records without affecting the node's overall status.
106113
func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
107114
nodes, err := hm.registry.List(ctx)
108115
if err != nil {
@@ -115,12 +122,12 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
115122
continue
116123
}
117124

118-
// Check heartbeat staleness first
125+
// Node liveness: heartbeat staleness check.
126+
// Workers (both backend and agent) send HTTP heartbeats to the frontend.
127+
// If the heartbeat is stale, the worker is presumed down.
119128
if time.Since(node.LastHeartbeat) > hm.staleThreshold {
120129
xlog.Warn("Node heartbeat stale", "node", node.Name, "lastHeartbeat", node.LastHeartbeat)
121130
if hm.autoOffline {
122-
// Mark offline instead of deregistering — preserves the node row
123-
// so re-registration restores the previous approval status
124131
xlog.Info("Marking stale node offline", "node", node.Name)
125132
if err := hm.registry.MarkOffline(ctx, node.ID); err != nil {
126133
xlog.Error("Failed to mark stale node offline", "node", node.Name, "error", err)
@@ -131,44 +138,19 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
131138
continue
132139
}
133140

134-
// Only gRPC health-check nodes that have models loaded.
135-
// Idle nodes (no models) haven't started their gRPC process yet
136-
// in NATS mode — connection refused is expected, not a failure.
137-
// Heartbeats alone are sufficient to prove the supervisor is alive.
138-
models, _ := hm.registry.GetNodeModels(ctx, node.ID)
139-
if len(models) == 0 {
140-
continue
141-
}
142-
143-
client := hm.clientFactory.NewClient(node.Address, false)
144-
checkCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
145-
alive, err := client.HealthCheck(checkCtx)
146-
cancel()
147-
148-
if !alive || err != nil {
149-
xlog.Warn("Node health check failed", "node", node.Name, "address", node.Address, "error", err)
150-
hm.registry.MarkUnhealthy(ctx, node.ID)
151-
if closer, ok := client.(io.Closer); ok {
152-
closer.Close()
153-
}
154-
continue
155-
}
156-
157-
// Close the node-level gRPC client now that we're done with it
158-
if closer, ok := client.(io.Closer); ok {
159-
closer.Close()
160-
}
161-
141+
// Heartbeat is fresh — node is alive
162142
if node.Status == StatusUnhealthy {
163-
// Node recovered
164143
xlog.Info("Node recovered", "node", node.Name)
165144
if err := hm.registry.MarkHealthy(ctx, node.ID); err != nil {
166145
xlog.Error("Failed to mark node healthy", "node", node.Name, "error", err)
167146
}
168147
}
169148

170-
// Per-model backend health check: probe each model's distinct gRPC address
149+
// Per-model backend health check (opt-in): probe each model's gRPC address
150+
// and remove stale model records. This does NOT affect the node's status —
151+
// a crashed backend process is a model-level issue, not a node-level one.
171152
if hm.perModelHealthCheck {
153+
models, _ := hm.registry.GetNodeModels(ctx, node.ID)
172154
for _, m := range models {
173155
if m.Address == "" || m.Address == node.Address {
174156
continue

core/services/nodes/health_mock_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,13 @@ func makeTestNode(id, name, address string, status string, lastHeartbeat time.Ti
275275
}
276276
}
277277

278+
// makeTestNodeWithHTTP creates a BackendNode with HTTPAddress set
279+
func makeTestNodeWithHTTP(id, name, address, httpAddress string, status string, lastHeartbeat time.Time) *BackendNode {
280+
n := makeTestNode(id, name, address, status, lastHeartbeat)
281+
n.HTTPAddress = httpAddress
282+
return n
283+
}
284+
278285
// helper to build a HealthMonitor with fakes
279286
func newTestHealthMonitor(store NodeHealthStore, factory BackendClientFactory, autoOffline bool, staleThreshold time.Duration) *HealthMonitor {
280287
return &HealthMonitor{

core/services/nodes/health_test.go

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ var _ = Describe("HealthMonitor", func() {
9595
Expect(fetched.Status).To(Equal(StatusHealthy))
9696
})
9797

98-
It("recovers unhealthy node when gRPC check succeeds", func() {
98+
It("recovers unhealthy node when heartbeat is fresh", func() {
9999
node := makeNode("unhealthy-worker", "10.0.0.5:50051", 8_000_000_000)
100100
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
101101
Expect(node.Status).To(Equal(StatusHealthy))
@@ -106,15 +106,11 @@ var _ = Describe("HealthMonitor", func() {
106106
Expect(err).ToNot(HaveOccurred())
107107
Expect(fetched.Status).To(Equal(StatusUnhealthy))
108108

109-
// Load a model so gRPC check is attempted
110-
Expect(registry.SetNodeModel(context.Background(), node.ID, "test-model", "loaded", "10.0.0.5:50052")).To(Succeed())
111-
112-
// Create health monitor with a factory that returns healthy clients
113-
factory := newFakeBackendClientFactory()
114-
factory.setClient("10.0.0.5:50051", &fakeBackendClient{healthy: true})
115-
hmWithFactory := NewHealthMonitor(registry, nil, 15*time.Second, 30*time.Second, "", false, factory)
109+
// Refresh heartbeat (simulates the worker sending a heartbeat)
110+
Expect(db.Model(&BackendNode{}).Where("id = ?", node.ID).
111+
Update("last_heartbeat", time.Now()).Error).ToNot(HaveOccurred())
116112

117-
hmWithFactory.doCheckAll(context.Background())
113+
hm.doCheckAll(context.Background())
118114

119115
fetched, err = registry.Get(context.Background(), node.ID)
120116
Expect(err).ToNot(HaveOccurred())
@@ -207,44 +203,77 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
207203
Expect(calls).NotTo(ContainElement(ContainSubstring("MarkOffline")))
208204
})
209205

210-
It("marks node unhealthy when gRPC check fails", func() {
206+
It("keeps node healthy when heartbeat is fresh (with models loaded)", func() {
211207
store := newFakeNodeHealthStore()
212208
factory := newFakeBackendClientFactory()
213209
hm := newTestHealthMonitor(store, factory, true, staleThreshold)
214210

215-
node := makeTestNode("node-5", "failing-worker", "10.0.0.5:50051", StatusHealthy, freshTime())
211+
node := makeTestNode("node-5", "active-worker", "10.0.0.5:50051", StatusHealthy, freshTime())
216212
store.addNode(node)
217213
store.addNodeModel("node-5", NodeModel{NodeID: "node-5", ModelName: "llama-7b"})
218214

219-
// Configure gRPC health check to fail
220-
factory.setClient("10.0.0.5:50051", &fakeBackendClient{
221-
healthy: false,
222-
err: fmt.Errorf("connection refused"),
223-
})
224-
215+
// No gRPC client needed — health is determined by heartbeat, not gRPC probe
225216
hm.doCheckAll(context.Background())
226217

227-
Expect(store.getNode("node-5").Status).To(Equal(StatusUnhealthy))
228-
Expect(store.getCalls()).To(ContainElement("MarkUnhealthy:node-5"))
218+
Expect(store.getNode("node-5").Status).To(Equal(StatusHealthy))
219+
calls := store.getCalls()
220+
Expect(calls).NotTo(ContainElement(ContainSubstring("MarkUnhealthy")))
229221
})
230222

231-
It("recovers unhealthy node when gRPC check succeeds", func() {
223+
It("recovers unhealthy node when heartbeat is fresh", func() {
232224
store := newFakeNodeHealthStore()
233225
factory := newFakeBackendClientFactory()
234226
hm := newTestHealthMonitor(store, factory, true, staleThreshold)
235227

236228
node := makeTestNode("node-6", "recovering-worker", "10.0.0.6:50051", StatusUnhealthy, freshTime())
237229
store.addNode(node)
238-
store.addNodeModel("node-6", NodeModel{NodeID: "node-6", ModelName: "llama-7b"})
239-
240-
// Configure gRPC health check to succeed
241-
factory.setClient("10.0.0.6:50051", &fakeBackendClient{healthy: true})
242230

243231
hm.doCheckAll(context.Background())
244232

245-
// Should have called MarkHealthy to recover the node
246233
Expect(store.getCalls()).To(ContainElement("MarkHealthy:node-6"))
247234
Expect(store.getNode("node-6").Status).To(Equal(StatusHealthy))
248235
})
236+
237+
It("node stays healthy when gRPC backend crashes but heartbeat is fresh", func() {
238+
store := newFakeNodeHealthStore()
239+
factory := newFakeBackendClientFactory()
240+
hm := newTestHealthMonitor(store, factory, true, staleThreshold)
241+
242+
// Worker has a model loaded but the backend process crashed —
243+
// node should remain healthy because heartbeat is fresh
244+
node := makeTestNode("node-crash", "crash-worker", "10.0.0.9:50051", StatusHealthy, freshTime())
245+
store.addNode(node)
246+
store.addNodeModel("node-crash", NodeModel{NodeID: "node-crash", ModelName: "piper-model", Address: "10.0.0.9:50053"})
247+
248+
// gRPC backend is dead — but health is heartbeat-based, not gRPC-based
249+
factory.setClient("10.0.0.9:50051", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
250+
251+
hm.doCheckAll(context.Background())
252+
253+
Expect(store.getNode("node-crash").Status).To(Equal(StatusHealthy))
254+
calls := store.getCalls()
255+
Expect(calls).NotTo(ContainElement(ContainSubstring("MarkUnhealthy")))
256+
})
257+
258+
It("removes stale model via per-model health check without affecting node status", func() {
259+
store := newFakeNodeHealthStore()
260+
factory := newFakeBackendClientFactory()
261+
hm := newTestHealthMonitor(store, factory, true, staleThreshold)
262+
hm.perModelHealthCheck = true
263+
264+
node := makeTestNode("node-model", "model-worker", "10.0.0.10:50051", StatusHealthy, freshTime())
265+
store.addNode(node)
266+
store.addNodeModel("node-model", NodeModel{NodeID: "node-model", ModelName: "piper-model", Address: "10.0.0.10:50053"})
267+
268+
// Model backend is dead
269+
factory.setClient("10.0.0.10:50053", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
270+
271+
hm.doCheckAll(context.Background())
272+
273+
// Node should remain healthy — only the model record is removed
274+
Expect(store.getNode("node-model").Status).To(Equal(StatusHealthy))
275+
Expect(store.getCalls()).To(ContainElement("RemoveNodeModel:node-model:piper-model"))
276+
Expect(store.getCalls()).NotTo(ContainElement(ContainSubstring("MarkUnhealthy")))
277+
})
249278
})
250279
})

0 commit comments

Comments
 (0)