Skip to content

Commit 75fba9e

Browse files
authored
fix(distributed): scope Upgrade All to nodes that have the backend installed (#9678)
In distributed mode the React UI's "Upgrade All" button fanned every detected outdated backend out to every healthy backend node, including nodes that never had that backend installed. On heterogeneous clusters this surfaced as platform errors (e.g. mac-mini-m4 asked to upgrade cpu-insightface-development, which has no darwin/arm64 variant) and left forever-retrying pending_backend_ops rows. DistributedBackendManager.UpgradeBackend now queries ListBackends() first, builds the target node-ID set from SystemBackend.Nodes, and only fans out to those nodes — every per-node primitive (adapter.InstallBackend, the pending-ops queue, BackendOpResult) is unchanged. enqueueAndDrainBackendOp gains an optional targetNodeIDs allowlist; Install/Delete keep their fan-to-everyone semantics by passing nil. If no node reports the backend installed, UpgradeBackend now returns a clear "not installed on any node" error instead of producing a stuck queue. Adds Ginkgo coverage for the smart fan-out: backend on a subset of nodes goes only to those nodes; backend on no node returns the new error and never sends a NATS install request. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 16b2d4c commit 75fba9e

2 files changed

Lines changed: 97 additions & 6 deletions

File tree

core/services/nodes/managers_distributed.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ func (r BackendOpResult) Err() error {
114114
// deletes the row and reports "success". For non-healthy nodes the status
115115
// is "queued" — no attempt is made right now, reconciler will pick it up
116116
// when the node returns.
117-
func (d *DistributedBackendManager) enqueueAndDrainBackendOp(ctx context.Context, op, backend string, galleriesJSON []byte, apply func(node BackendNode) error) (BackendOpResult, error) {
117+
// targetNodeIDs is an optional allowlist: when non-nil, only nodes whose ID is
118+
// in the set are visited. Used by UpgradeBackend to avoid asking nodes that
119+
// never had the backend installed to "upgrade" it — such requests fail at the
120+
// gallery (no platform variant) and would otherwise leave a forever-retrying
121+
// pending_backend_ops row. nil means "fan out to every node" (Install/Delete).
122+
func (d *DistributedBackendManager) enqueueAndDrainBackendOp(ctx context.Context, op, backend string, galleriesJSON []byte, targetNodeIDs map[string]bool, apply func(node BackendNode) error) (BackendOpResult, error) {
118123
allNodes, err := d.registry.List(ctx)
119124
if err != nil {
120125
return BackendOpResult{}, err
@@ -133,6 +138,9 @@ func (d *DistributedBackendManager) enqueueAndDrainBackendOp(ctx context.Context
133138
if node.NodeType != "" && node.NodeType != NodeTypeBackend {
134139
continue
135140
}
141+
if targetNodeIDs != nil && !targetNodeIDs[node.ID] {
142+
continue
143+
}
136144
if err := d.registry.UpsertPendingBackendOp(ctx, node.ID, backend, op, galleriesJSON); err != nil {
137145
xlog.Warn("Failed to enqueue backend op", "op", op, "node", node.Name, "backend", backend, "error", err)
138146
result.Nodes = append(result.Nodes, NodeOpStatus{
@@ -218,7 +226,7 @@ func (d *DistributedBackendManager) DeleteBackend(name string) error {
218226
}
219227

220228
ctx := context.Background()
221-
result, err := d.enqueueAndDrainBackendOp(ctx, OpBackendDelete, name, nil, func(node BackendNode) error {
229+
result, err := d.enqueueAndDrainBackendOp(ctx, OpBackendDelete, name, nil, nil, func(node BackendNode) error {
222230
reply, err := d.adapter.DeleteBackend(node.ID, name)
223231
if err != nil {
224232
return err
@@ -241,7 +249,7 @@ func (d *DistributedBackendManager) DeleteBackendDetailed(ctx context.Context, n
241249
if err := d.local.DeleteBackend(name); err != nil && !errors.Is(err, gallery.ErrBackendNotFound) {
242250
return BackendOpResult{}, err
243251
}
244-
return d.enqueueAndDrainBackendOp(ctx, OpBackendDelete, name, nil, func(node BackendNode) error {
252+
return d.enqueueAndDrainBackendOp(ctx, OpBackendDelete, name, nil, nil, func(node BackendNode) error {
245253
reply, err := d.adapter.DeleteBackend(node.ID, name)
246254
if err != nil {
247255
return err
@@ -327,7 +335,7 @@ func (d *DistributedBackendManager) InstallBackend(ctx context.Context, op *gall
327335
galleriesJSON, _ := json.Marshal(op.Galleries)
328336
backendName := op.GalleryElementName
329337

330-
result, err := d.enqueueAndDrainBackendOp(ctx, OpBackendInstall, backendName, galleriesJSON, func(node BackendNode) error {
338+
result, err := d.enqueueAndDrainBackendOp(ctx, OpBackendInstall, backendName, galleriesJSON, nil, func(node BackendNode) error {
331339
// Admin-driven backend install: not tied to a specific replica slot.
332340
// Pass replica 0 — the worker's processKey is "backend#0" when no
333341
// modelID is supplied, matching pre-PR4 behavior.
@@ -347,11 +355,28 @@ func (d *DistributedBackendManager) InstallBackend(ctx context.Context, op *gall
347355
}
348356

349357
// UpgradeBackend reuses the install NATS subject (the worker re-downloads
350-
// from the gallery). Same queue semantics as Install/Delete.
358+
// from the gallery). Unlike Install/Delete, upgrade only targets the nodes
359+
// that already report this backend as installed — fanning out to every node
360+
// would ask workers to "upgrade" something they never had, which fails at
361+
// the gallery (e.g. a darwin/arm64 worker has no platform variant for a
362+
// linux-only backend) and leaves a forever-retrying pending_backend_ops row.
351363
func (d *DistributedBackendManager) UpgradeBackend(ctx context.Context, name string, progressCb galleryop.ProgressCallback) error {
352364
galleriesJSON, _ := json.Marshal(d.backendGalleries)
353365

354-
result, err := d.enqueueAndDrainBackendOp(ctx, OpBackendUpgrade, name, galleriesJSON, func(node BackendNode) error {
366+
installed, err := d.ListBackends()
367+
if err != nil {
368+
return fmt.Errorf("failed to list cluster backends: %w", err)
369+
}
370+
entry, ok := installed[name]
371+
if !ok || len(entry.Nodes) == 0 {
372+
return fmt.Errorf("backend %q is not installed on any node", name)
373+
}
374+
targetNodeIDs := make(map[string]bool, len(entry.Nodes))
375+
for _, n := range entry.Nodes {
376+
targetNodeIDs[n.NodeID] = true
377+
}
378+
379+
result, err := d.enqueueAndDrainBackendOp(ctx, OpBackendUpgrade, name, galleriesJSON, targetNodeIDs, func(node BackendNode) error {
355380
reply, err := d.adapter.InstallBackend(node.ID, name, "", string(galleriesJSON), "", "", "", 0)
356381
if err != nil {
357382
return err

core/services/nodes/managers_distributed_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,11 +240,30 @@ var _ = Describe("DistributedBackendManager", func() {
240240
})
241241

242242
Describe("UpgradeBackend", func() {
243+
// scriptInstalled tells the worker(s) named in `nodeIDs` to claim
244+
// `backend` is installed when DistributedBackendManager.ListBackends()
245+
// fans out backend.list. Anything not scripted defaults to an empty
246+
// reply, which means "this node has no backends installed" and so
247+
// upgrade should skip it.
248+
scriptInstalled := func(backend string, nodeIDs ...string) {
249+
for _, id := range nodeIDs {
250+
mc.scriptReply(messaging.SubjectNodeBackendList(id),
251+
messaging.BackendListReply{Backends: []messaging.NodeBackendInfo{{Name: backend}}})
252+
}
253+
}
254+
scriptNoBackends := func(nodeIDs ...string) {
255+
for _, id := range nodeIDs {
256+
mc.scriptReply(messaging.SubjectNodeBackendList(id),
257+
messaging.BackendListReply{Backends: nil})
258+
}
259+
}
260+
243261
Context("when every node fails to upgrade", func() {
244262
It("returns an aggregated error", func() {
245263
n1 := registerHealthyBackend("worker-a", "10.0.0.1:50051")
246264
n2 := registerHealthyBackend("worker-b", "10.0.0.2:50051")
247265

266+
scriptInstalled("vllm-development", n1.ID, n2.ID)
248267
mc.scriptReply(messaging.SubjectNodeBackendInstall(n1.ID),
249268
messaging.BackendInstallReply{Success: false, Error: "image manifest not found"})
250269
mc.scriptReply(messaging.SubjectNodeBackendInstall(n2.ID),
@@ -262,11 +281,58 @@ var _ = Describe("DistributedBackendManager", func() {
262281
Context("when every node succeeds", func() {
263282
It("returns nil", func() {
264283
n1 := registerHealthyBackend("worker-a", "10.0.0.1:50051")
284+
scriptInstalled("vllm-development", n1.ID)
265285
mc.scriptReply(messaging.SubjectNodeBackendInstall(n1.ID),
266286
messaging.BackendInstallReply{Success: true})
267287
Expect(mgr.UpgradeBackend(ctx, "vllm-development", nil)).To(Succeed())
268288
})
269289
})
290+
291+
// Smart fan-out: only nodes that actually report the backend installed
292+
// receive the upgrade NATS request. Reproduces the bug where the
293+
// "Upgrade All" UI button asked a darwin/arm64 worker to upgrade a
294+
// linux-only backend it never had, producing a "no child with platform
295+
// darwin/arm64 in index" error and a stuck pending_backend_ops row.
296+
Context("when only one of two healthy nodes has the backend installed", func() {
297+
It("upgrades only on that node and skips the other entirely", func() {
298+
has := registerHealthyBackend("linux-amd64-worker", "10.0.0.1:50051")
299+
lacks := registerHealthyBackend("mac-mini-m4", "10.0.0.2:50051")
300+
301+
scriptInstalled("cpu-insightface-development", has.ID)
302+
scriptNoBackends(lacks.ID)
303+
mc.scriptReply(messaging.SubjectNodeBackendInstall(has.ID),
304+
messaging.BackendInstallReply{Success: true})
305+
// Deliberately don't script SubjectNodeBackendInstall for `lacks`:
306+
// if the manager attempts it, the scripted-client default returns
307+
// fakeNoRespondersErr and the assertion below fails loudly.
308+
309+
Expect(mgr.UpgradeBackend(ctx, "cpu-insightface-development", nil)).To(Succeed())
310+
311+
mc.mu.Lock()
312+
defer mc.mu.Unlock()
313+
for _, call := range mc.calls {
314+
Expect(call.Subject).ToNot(Equal(messaging.SubjectNodeBackendInstall(lacks.ID)),
315+
"upgrade leaked to %s which does not have the backend installed", lacks.Name)
316+
}
317+
})
318+
})
319+
320+
Context("when no node has the backend installed", func() {
321+
It("returns a clear error and never attempts an install request", func() {
322+
n1 := registerHealthyBackend("worker-a", "10.0.0.1:50051")
323+
scriptNoBackends(n1.ID)
324+
325+
err := mgr.UpgradeBackend(ctx, "vllm-development", nil)
326+
Expect(err).To(HaveOccurred())
327+
Expect(err.Error()).To(ContainSubstring("not installed on any node"))
328+
329+
mc.mu.Lock()
330+
defer mc.mu.Unlock()
331+
for _, call := range mc.calls {
332+
Expect(call.Subject).ToNot(Equal(messaging.SubjectNodeBackendInstall(n1.ID)))
333+
}
334+
})
335+
})
270336
})
271337

272338
Describe("DeleteBackend", func() {

0 commit comments

Comments
 (0)