Skip to content

Commit ea1df89

Browse files
committed
fix(distributed): preserve UI-added node labels across worker re-register
The register endpoint called SetNodeLabels(req.Labels) — replace-all semantics — so every worker re-register wiped every label not in the worker's body. The bug existed since labels were introduced in PR #9186 (Mar 31), but only triggered for workers that supplied labels via --node-labels. PR #9583 (the multi-replica refactor) added an auto-mirrored `node.replica-slots` label to every worker's registration body, which made `len(req.Labels) > 0` always true — turning a latent edge-case bug into a universal one. Operators reported "labels assigned to node do not persist": labels survived until the next worker restart, then disappeared. Fix: iterate req.Labels and call SetNodeLabel (upsert) for each instead of SetNodeLabels (delete-then-recreate). Worker-managed labels still refresh on re-register; UI-added labels survive. Trade-off: an operator who removes a label from --node-labels won't have it auto-removed from the DB on next register — they can clean it via the UI. Acceptable, since the alternative (current behavior) silently destroys operator state. Regression test added first (TDD): RegisterNodeEndpoint registers a node, the test simulates a UI add via SetNodeLabel, then re-registers with a different worker label set; assertion that the UI-added label survives. Test fails against the broken code, passes against the fix. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Edit] [Bash]
1 parent 3280b9a commit ea1df89

2 files changed

Lines changed: 58 additions & 4 deletions

File tree

core/http/endpoints/localai/nodes.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,16 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
163163
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to register node"))
164164
}
165165

166-
// Store static labels from worker and apply auto-labels
167-
if len(req.Labels) > 0 {
168-
if err := registry.SetNodeLabels(ctx, node.ID, req.Labels); err != nil {
169-
xlog.Warn("Failed to set node labels", "node", node.ID, "error", err)
166+
// Merge worker-supplied labels into the node's existing label set,
167+
// then apply auto-labels. Using SetNodeLabels here would have
168+
// delete-then-recreate semantics — every worker re-register would
169+
// wipe any UI-added label (since PR #9583 the worker always sends
170+
// the auto-mirror `node.replica-slots`, which made the latent bug
171+
// from PR #9186 trigger universally instead of only for workers
172+
// with `--node-labels` set).
173+
for k, v := range req.Labels {
174+
if err := registry.SetNodeLabel(ctx, node.ID, k, v); err != nil {
175+
xlog.Warn("Failed to set node label", "node", node.ID, "key", k, "error", err)
170176
}
171177
}
172178
registry.ApplyAutoLabels(ctx, node.ID, node)

core/http/endpoints/localai/nodes_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,54 @@ var _ = Describe("Node HTTP handlers", func() {
180180
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
181181
Expect(resp["status"]).To(Equal(nodes.StatusPending))
182182
})
183+
184+
// Regression: a worker re-register used to wipe every UI-added label
185+
// because the endpoint called SetNodeLabels (replace-all) with only
186+
// what the worker sent. Operators reported "labels assigned to node
187+
// do not persist" — the labels survived until the next worker
188+
// restart, then disappeared.
189+
It("preserves UI-added labels across worker re-register", func() {
190+
ctx := context.Background()
191+
e := echo.New()
192+
193+
// 1. Worker first-registers with one label.
194+
body1 := `{"name":"worker-merge","address":"10.0.0.50:50051","labels":{"tier":"a","gpu":"a100"}}`
195+
req1 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body1))
196+
req1.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
197+
rec1 := httptest.NewRecorder()
198+
handler := RegisterNodeEndpoint(registry, "", true, nil, "")
199+
Expect(handler(e.NewContext(req1, rec1))).To(Succeed())
200+
Expect(rec1.Code).To(Equal(http.StatusCreated))
201+
202+
node, err := registry.GetByName(ctx, "worker-merge")
203+
Expect(err).ToNot(HaveOccurred())
204+
Expect(node).ToNot(BeNil())
205+
206+
// 2. Operator adds a label via the UI.
207+
Expect(registry.SetNodeLabel(ctx, node.ID, "owner", "ettore")).To(Succeed())
208+
209+
// 3. Worker restarts and re-registers, sending its own labels
210+
// (different from the UI-added one).
211+
body2 := `{"name":"worker-merge","address":"10.0.0.50:50051","labels":{"tier":"b","gpu":"a100"}}`
212+
req2 := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body2))
213+
req2.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
214+
rec2 := httptest.NewRecorder()
215+
Expect(handler(e.NewContext(req2, rec2))).To(Succeed())
216+
Expect(rec2.Code).To(Equal(http.StatusCreated))
217+
218+
// 4. Assert the UI-added label survived AND the worker labels updated.
219+
labels, err := registry.GetNodeLabels(ctx, node.ID)
220+
Expect(err).ToNot(HaveOccurred())
221+
byKey := map[string]string{}
222+
for _, l := range labels {
223+
byKey[l.Key] = l.Value
224+
}
225+
Expect(byKey).To(HaveKeyWithValue("owner", "ettore"),
226+
"UI-added label must survive a worker re-register")
227+
Expect(byKey).To(HaveKeyWithValue("tier", "b"),
228+
"worker label updates must apply on re-register")
229+
Expect(byKey).To(HaveKeyWithValue("gpu", "a100"))
230+
})
183231
})
184232

185233
Describe("ListNodesEndpoint", func() {

0 commit comments

Comments
 (0)