A node given a role takes only what asks for it
CI / test (push) Successful in 5m24s

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) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-19 17:52:30 +02:00
1 parent 51ca09287f
commit 34764f5ef0
4 files changed
+31 -2

No files matched your search

+2 -1
View File
@@ -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
+2 -1
View File
@@ -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"
)
+11
View File
@@ -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
@@ -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())
}
}