From 34764f5ef071d072cb011868c4b1ffddd088c040 Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Mon, 19 Oct 2026 17:52:30 +0200 Subject: [PATCH] A node given a role takes only what asks for it The judges' machine was selected by the judge's profile, but any other model could still be placed beside it. A node labelled llmgw.role now takes only profiles whose selector asks for that role. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 ++- internal/cli/serverinit_judges.go | 3 ++- internal/control/placement/placement.go | 11 +++++++++++ internal/control/placement/placement_test.go | 16 ++++++++++++++++ 4 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edf288b6..2548e386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,8 @@ APIs change between minors without a deprecation period. the store's two; `server init` takes `--judge-node user@host` for each. Each is added as a node labelled `llmgw.role: judge`, and the judge's profile selects it, so the judges run there alone. Under load on a 4-core control plane that was also a node, 38 of 60 requests were refused for want of a judgment. With the judges on a node - of their own, 2 were. + of their own, 2 were. A node with an `llmgw.role` label takes only profiles whose node selector asks for that role, + so no other model is placed on the judges' machine. ### Fixed diff --git a/internal/cli/serverinit_judges.go b/internal/cli/serverinit_judges.go index bcf6d09b..ff6d7516 100644 --- a/internal/cli/serverinit_judges.go +++ b/internal/cli/serverinit_judges.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "gitea.mynodes.xyz/ilker/llmgw/internal/control/placement" "gitea.mynodes.xyz/ilker/llmgw/internal/onboard" "gitea.mynodes.xyz/ilker/llmgw/internal/settings" ) @@ -18,7 +19,7 @@ import ( // judgeRoleLabel marks a node as the judges'. const ( - judgeRoleLabel = "llmgw.role" + judgeRoleLabel = placement.RoleLabel judgeRoleValue = "judge" ) diff --git a/internal/control/placement/placement.go b/internal/control/placement/placement.go index 06eeb389..944a66bf 100644 --- a/internal/control/placement/placement.go +++ b/internal/control/placement/placement.go @@ -548,6 +548,10 @@ func hasWeights(source string, n inventory.Node) (present, known bool) { // It returns the label rather than a bare false so the rejection can name it: // "does not carry ram=32g" is a placement failure someone can act on, and // "no candidates were considered" is not. +// RoleLabel gives a node a role, such as judge: it then takes only profiles +// whose node selector asks for that role. +const RoleLabel = "llmgw.role" + func unmatchedLabel(selector, labels map[string]string) (string, bool) { for k, want := range selector { if got, ok := labels[k]; !ok || got != want { @@ -602,6 +606,13 @@ func (s *Scheduler) eligibleNodes(p Profile, fabric string, avoid []string, reje reject("does not carry " + miss) continue } + // A node given a role is kept for what asks for it: the judges' + // machine runs the judges alone, so nothing beside them takes the + // cores a judgment waits on. + if role, ok := n.Labels[RoleLabel]; ok && p.NodeSelector[RoleLabel] != role { + reject("kept for " + RoleLabel + "=" + role) + continue + } // The engine has to be installed here. The field has been on this // struct since placement was written and was never read, so a node // with no vLLM was as good a candidate as one with it: the replica was diff --git a/internal/control/placement/placement_test.go b/internal/control/placement/placement_test.go index d888cbcf..6dd0a608 100644 --- a/internal/control/placement/placement_test.go +++ b/internal/control/placement/placement_test.go @@ -1099,3 +1099,19 @@ func TestAProfileCanAskForAnEngineVersion(t *testing.T) { t.Errorf("the rejection should say what the node has:\n%s", res.Explain()) } } + +func TestANodeGivenARoleTakesOnlyWhatAsksForIt(t *testing.T) { + judge := node("judge", "", 0) + judge.Labels = map[string]string{RoleLabel: "judge"} + s := New(store(judge)) + // A model that does not ask for the judges' machine is not put there. + res := s.Place([]Profile{{Name: "cpu", Engine: "llamacpp"}}, nil) + if res.Placement != nil || !strings.Contains(res.Explain(), "kept for llmgw.role=judge") { + t.Fatalf("an ordinary model went on the judges' machine:\n%s", res.Explain()) + } + // The judge, which asks for it, is. + res = s.Place([]Profile{{Name: "cpu", Engine: "llamacpp", NodeSelector: map[string]string{RoleLabel: "judge"}}}, nil) + if res.Placement == nil || res.Placement.Nodes[0].NodeID != "judge" { + t.Fatalf("the judge was not placed on its machine:\n%s", res.Explain()) + } +}