Skip to content

Commit bebd812

Browse files
committed
fix(distributed): stop flapping agent nodes on backend listing
Only backend workers subscribe to backend.list. ListBackends asked every node that was not pending, offline or draining, so an agent worker could only answer "no responders", which the error handling reads as a node that has gone away. Every poll of the backends view therefore marked each agent node unhealthy, and its next heartbeat marked it healthy again. While unhealthy the node is not schedulable, so this also cost agent capacity for as long as each flap lasted. Skip non-backend workers, as the backend-op fan-out already does for the same reason. A backend worker that does not answer is still marked unhealthy: that one really is gone. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-5 [golangci-lint]
1 parent df1a40f commit bebd812

2 files changed

Lines changed: 95 additions & 2 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package nodes
2+
3+
import (
4+
"context"
5+
"runtime"
6+
"time"
7+
8+
. "github.com/onsi/ginkgo/v2"
9+
. "github.com/onsi/gomega"
10+
"gorm.io/gorm"
11+
12+
"github.com/mudler/LocalAI/core/services/messaging"
13+
"github.com/mudler/LocalAI/core/services/testutil"
14+
)
15+
16+
// Agent workers do not subscribe to the backend.* subjects, so asking one to
17+
// list its backends can only answer "no responders". ListBackends read that as
18+
// a node that had gone away and marked it unhealthy; the node's next heartbeat
19+
// marked it healthy again. Every poll of the backends view therefore flapped
20+
// every agent node in the cluster, and while it was unhealthy the router would
21+
// not schedule onto it.
22+
var _ = Describe("Backend listing across mixed node types", func() {
23+
var (
24+
db *gorm.DB
25+
registry *NodeRegistry
26+
mc *scriptedMessagingClient
27+
mgr *DistributedBackendManager
28+
ctx context.Context
29+
)
30+
31+
BeforeEach(func() {
32+
if runtime.GOOS == "darwin" {
33+
Skip("testcontainers requires Docker, not available on macOS CI")
34+
}
35+
db = testutil.SetupTestDB()
36+
var err error
37+
registry, err = NewNodeRegistry(db)
38+
Expect(err).ToNot(HaveOccurred())
39+
mc = newScriptedMessagingClient()
40+
mgr = &DistributedBackendManager{
41+
local: stubLocalBackendManager{},
42+
adapter: NewRemoteUnloaderAdapter(nil, mc, 3*time.Minute, 15*time.Minute),
43+
registry: registry,
44+
}
45+
ctx = context.Background()
46+
})
47+
48+
register := func(name, nodeType string) *BackendNode {
49+
node := &BackendNode{Name: name, NodeType: nodeType, Address: name + ":50051"}
50+
Expect(registry.Register(ctx, node, true)).To(Succeed())
51+
fetched, err := registry.GetByName(ctx, name)
52+
Expect(err).ToNot(HaveOccurred())
53+
Expect(fetched.Status).To(Equal(StatusHealthy))
54+
return fetched
55+
}
56+
57+
statusOf := func(id string) string {
58+
n, err := registry.Get(ctx, id)
59+
Expect(err).ToNot(HaveOccurred())
60+
return n.Status
61+
}
62+
63+
It("leaves an agent node healthy instead of flapping it", func() {
64+
agent := register("agent-worker-1", NodeTypeAgent)
65+
mc.scriptNoResponders(messaging.SubjectNodeBackendList(agent.ID))
66+
67+
_, err := mgr.ListBackends()
68+
Expect(err).ToNot(HaveOccurred())
69+
70+
Expect(statusOf(agent.ID)).To(Equal(StatusHealthy),
71+
"an agent node cannot answer backend.list and must not be judged on it")
72+
})
73+
74+
It("still marks a backend node unhealthy when it does not answer", func() {
75+
backendNode := register("worker-a", NodeTypeBackend)
76+
mc.scriptNoResponders(messaging.SubjectNodeBackendList(backendNode.ID))
77+
78+
_, err := mgr.ListBackends()
79+
Expect(err).ToNot(HaveOccurred())
80+
81+
Expect(statusOf(backendNode.ID)).To(Equal(StatusUnhealthy),
82+
"a backend worker that does not answer is genuinely gone")
83+
})
84+
})

core/services/nodes/managers_distributed.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,9 @@ func (d *DistributedBackendManager) DeleteBackendDetailed(ctx context.Context, n
331331
// populated from the first node seen so single-node-minded callers still work.
332332
//
333333
// Pending/offline/draining nodes are skipped because they aren't expected to
334-
// answer NATS requests; unhealthy nodes are still queried — ErrNoResponders
335-
// then marks them unhealthy and the loop continues.
334+
// answer NATS requests, and so are non-backend workers, which do not subscribe
335+
// to backend.list at all; unhealthy backend nodes are still queried —
336+
// ErrNoResponders then marks them unhealthy and the loop continues.
336337
func (d *DistributedBackendManager) ListBackends() (gallery.SystemBackends, error) {
337338
result := make(gallery.SystemBackends)
338339
allNodes, err := d.registry.List(context.Background())
@@ -344,6 +345,14 @@ func (d *DistributedBackendManager) ListBackends() (gallery.SystemBackends, erro
344345
if node.Status == StatusPending || node.Status == StatusOffline || node.Status == StatusDraining {
345346
continue
346347
}
348+
// Only backend workers subscribe to backend.list. Asking an agent
349+
// worker can only answer "no responders", which the error handling
350+
// below reads as a node that has gone away, so every poll of this view
351+
// marked every agent node unhealthy and its next heartbeat marked it
352+
// healthy again. The backend-op fan-out skips them for the same reason.
353+
if node.NodeType != "" && node.NodeType != NodeTypeBackend {
354+
continue
355+
}
347356
reply, err := d.adapter.ListBackends(node.ID)
348357
if err != nil {
349358
if errors.Is(err, nats.ErrNoResponders) {

0 commit comments

Comments
 (0)