Skip to content

Commit 6b63b47

Browse files
authored
feat(distributed): support multiple replicas of one model on the same node (#9583)
* feat(distributed): support multiple replicas of one model on the same node The distributed scheduler implicitly assumed `(node_id, model_name)` was unique, but the schema didn't enforce it and the worker keyed all gRPC processes by model name alone. With `MinReplicas=2` against a single worker, the reconciler "scaled up" every 30s but the registry never advanced past 1 row — the worker re-loaded the model in-place every tick until VRAM fragmented and the gRPC process died. This change introduces multi-replica-per-node as a first-class concept, with capacity-aware scheduling, a circuit breaker, and VRAM soft-reservation. Operators can declare per-node capacity via the worker flag `--max-replicas-per-model` (mirrored as auto-label `node.replica-slots=N`) or override per-node from the UI. * Schema: BackendNode gains MaxReplicasPerModel (default 1) and ReservedVRAM. NodeModel gains ReplicaIndex (composite with node_id + model_name). ModelSchedulingConfig gains UnsatisfiableUntil/Ticks for the reconciler circuit breaker. * Registry: replica_index threaded through SetNodeModel, RemoveNodeModel, IncrementInFlight, DecrementInFlight, TouchNodeModel, GetNodeModel, SetNodeModelLoadInfo and the InFlightTrackingClient. New helpers: CountReplicasOnNode, NextFreeReplicaIndex (with ErrNoFreeSlot), RemoveAllNodeModelReplicas, FindNodesWithFreeSlot, ClusterCapacityForModel, ReserveVRAM/ReleaseVRAM (atomic UPDATE with ErrInsufficientVRAM), and the unsatisfiable-flag CRUD. * Worker: processKey now `<modelID>#<replicaIndex>` so concurrent loads of the same model land on distinct ports. Adds CLI flag --max-replicas-per-model (env LOCALAI_MAX_REPLICAS_PER_MODEL, default 1) and emits the auto-label. * Router: scheduleNewModel filters candidates by free slot, allocates the replica index, and soft-reserves VRAM before installing the backend. evictLRUAndFreeNode now deletes the targeted row by ID instead of all replicas of the model on the node — fixes a latent bug where evicting one replica orphaned its siblings. * Reconciler: caps scale-up at ClusterCapacityForModel so a misconfig (MinReplicas > capacity) doesn't loop forever. After 3 consecutive ticks of capacity==0 it sets UnsatisfiableUntil for a 5m cooldown and emits a warning. ClearAllUnsatisfiable fires from Register, ApproveNode, SetNodeLabel(s), RemoveNodeLabel and UpdateMaxReplicasPerModel so a new node joining or label changes wake the reconciler immediately. scaleDownIdle removes highest-replica-index first to keep slots compact. * Heartbeat resets reserved_vram to 0 — worker is the source of truth for actual free VRAM; the reservation is only for the in-tick race window between two scheduling decisions. * Probe path (reconciler.probeLoadedModels and health.doCheckAll) now pass the row's replica_index to RemoveNodeModel so an unreachable replica doesn't orphan healthy siblings. * Admin override: PUT /api/nodes/:id/max-replicas-per-model sets a sticky override (preserved across worker re-registration). DELETE clears the override so the worker's flag applies again on next register. Required because Kong defaults the worker flag to 1, so every worker restart would have silently reverted the UI value. * React UI: always-visible slot badge on the node row (muted at default 1, accented when >1); inline editor in the expanded drawer with pencil-to-edit, Save/Cancel, Esc/Enter, "(override)" indicator when the value is admin-set, and a "Reset" button to hand control back to the worker. Soft confirm when shrinking the cap below the count of loaded replicas. Scheduling rules table gets an "Unsatisfiable until HH:MM" status badge surfacing the cooldown. * node.replica-slots filtered out of the labels strip on the row to avoid duplicating the slot badge. 23 new Ginkgo specs (registry, reconciler, inflight, health) cover: multi-replica row independence, RemoveNodeModel of one replica preserving siblings, NextFreeReplicaIndex slot allocation including ErrNoFreeSlot, capacity-gated scale-up with circuit breaker tripping and recovery on Register, scheduleDownIdle ordering, ClusterCapacity math, ReserveVRAM admission gating, Heartbeat reset, override survival across worker re-registration, and ResetMaxReplicasPerModel handing control back. Plus 8 stdlib tests for the worker processKey / CLI / auto-label. Closes the flap reproduced on Qwen3.6-35B against the nvidia-thor worker (single 128 GiB node, MinReplicas=2): the reconciler now caps the scale-up at the cluster's actual capacity instead of looping. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Read] [Edit] [Bash] [Skill:critique] [Skill:audit] [Skill:polish] [Skill:golang-testing] * refactor(react-ui/nodes): tighten capacity editor copy + adopt ActionMenu for row actions * Capacity editor hint trimmed from operator-doc-style ("Sourced from the worker's `--max-replicas-per-model` flag. Changing it here makes it a sticky admin override that survives worker restarts." → "Saved values stick across worker restarts.") and the override-state copy similarly compressed. The full mechanic is no longer needed in the UI — the override pill carries the meaning and the docs cover the rest. * Node row actions migrated from an inline cluster of icon buttons (Drain / Resume / Trash) to the kebab ActionMenu used by /manage for per-row model actions, so dense Nodes tables stay clean. Approve stays as a prominent primary button — it's a stateful admission gate, not a routine action, and elevating it matches how /manage surfaces install-time decisions outside the menu. * The expanded drawer's Labels section now filters node.replica-slots out of the editable label list. The label is owned by the Capacity editor above; surfacing it again as an editable label invited confusion (the Capacity save would clobber any direct edit). Both backend and agent workers benefit — they share the row rendering path, so the action menu and label filter apply to both. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Edit] [chrome-devtools-mcp] [Skill:critique] [Skill:audit] [Skill:polish] * fix(react-ui/nodes): suppress slot badge on agent workers Agent workers don't load models, so the per-node replica capacity is inapplicable to them. Showing "1× slots" on agent rows was a tiny inconsistency from the unified rendering path — gate the badge on node_type !== 'agent' so it only appears on backend workers. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Edit] [chrome-devtools-mcp] * refactor(react-ui/nodes): distill expanded drawer + restyle scheduling form The expanded node drawer used to stack five panels — slot badge, filled capacity box, Loaded Models h4+empty-state, Installed Backends h4+empty-state, Labels h4+chips+form — making routine inspections feel like a control panel. The scheduling rule form wrapped its mode toggle as two 50%-width filled buttons that competed visually with the actual primary action. * Drawer: collapse three rarely-touched config zones (Capacity, Backends, Labels) into one `<details>` "Manage" disclosure (closed by default) with small uppercase eyebrow labels for each zone instead of parallel h4 sub-headings. Loaded Models stays as the at-a-glance headline with a single-line empty hint instead of a boxed empty state. CapacityEditor renders flat (no filled background) — the Manage disclosure provides framing. * Scheduling form: replace the chunky 50%-width button-tabs with the project's existing `.segmented` control (icon + label, sized to content). Mode hint becomes a single tied line below. Fields stack vertically with helper text under inputs and a hairline divider above the right-aligned Save / Cancel. The empty drawer collapses from ~5 stacked sections (~280px tall) to two lines (~80px). The scheduling form now reads as a designed dialog instead of raw building blocks. Both surfaces now match the typographic density and weight of the rest of the admin pages. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Edit] [chrome-devtools-mcp] [Skill:distill] [Skill:audit] [Skill:polish] * feat(react-ui/nodes): replace scheduling form's model picker with searchable combobox The native <select> made operators scroll through every gallery entry to find a model name. The project already has SearchableModelSelect (used in Studio/Talk/etc.) which combines free-text search with the gallery list and accepts typed model names that aren't installed yet — useful for pre-staging a scheduling rule before the node it'll run on has finished bootstrapping. Also drops the now-unused useModels import (the combobox manages the gallery hook internally). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Edit] * refactor(react-ui/nodes): consolidate key/value chip editor + add replica preset chips The Nodes page was rendering the same key=value chip pattern in two places with subtly different markup: the Labels editor in the expanded drawer and (post-distill) the Node Selector input in the scheduling form. The form's input was also a comma-separated string that operators were getting wrong. * Extract <KeyValueChips> as a fully controlled chip-builder. Parent owns the map and decides what onAdd/onRemove does — form state for the scheduling form, API calls for the live drawer Labels editor. Same visuals everywhere; one component to change when polish needs apply. * Replace the comma-separated Node Selector text input with KeyValueChips. Operators were copying syntax from docs and missing commas; the chip vocabulary makes the key=value structure self-documenting. * Add <ReplicaInput>: numeric input + quick-pick preset chips for Min/Max replicas. Picked over a slider because replica counts are exact specs derived from VRAM math (operator decision, not a fuzzy estimate). The chips give one-click access to common values (1/2/3/4 for Min, 0=no-limit/2/4/8 for Max) without the slider's special-value problem (MaxReplicas=0 is categorical, not a position on a continuum). * Drop the now-unused labelInputs state in the Nodes page (the inline label editor's per-node draft state lived there and is now owned by KeyValueChips). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Edit] [Skill:distill] * test: fix CI fallout from multi-replica refactor (e2e/distributed + playwright) Two breakages caught by CI that didn't surface in the local run: * tests/e2e/distributed/*.go — multiple files used the pre-PR2 registry signatures for SetNodeModel / IncrementInFlight / DecrementInFlight / RemoveNodeModel / TouchNodeModel / GetNodeModel / SetNodeModelLoadInfo and one stale adapter.InstallBackend call in node_lifecycle_test.go. All updated to pass replicaIndex=0 — these tests don't exercise multi-replica behavior, they just need to compile against the new signatures. The chip-builder tests in core/services/nodes/ already cover the multi-replica logic. * core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js — the drawer's distill refactor moved Backends inside a "Manage" <details> disclosure that's collapsed by default. The test helper expanded the node row but never opened Manage, so the per-node backend table was never in the DOM. Helper now clicks `.node-manage > summary` after expanding the row. All 100 playwright tests pass locally; tests/e2e/distributed compiles clean. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: claude-code:opus-4-7 [Edit] [Bash] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent f4036fa commit 6b63b47

34 files changed

Lines changed: 2492 additions & 570 deletions

core/cli/worker.go

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ type WorkerCMD struct {
9090
RegistrationToken string `env:"LOCALAI_REGISTRATION_TOKEN" help:"Token for authenticating with the frontend" group:"registration"`
9191
HeartbeatInterval string `env:"LOCALAI_HEARTBEAT_INTERVAL" default:"10s" help:"Interval between heartbeats" group:"registration"`
9292
NodeLabels string `env:"LOCALAI_NODE_LABELS" help:"Comma-separated key=value labels for this node (e.g. tier=fast,gpu=a100)" group:"registration"`
93+
// MaxReplicasPerModel caps how many replicas of any one model can run on
94+
// this worker concurrently. Default 1 = historical single-replica
95+
// behavior. Set higher when a node has enough VRAM to host multiple
96+
// copies of the same model (e.g. a fat 128 GiB box running 4× of a
97+
// 24 GiB model for throughput). The auto-label `node.replica-slots=N`
98+
// is published so model schedulers can target high-capacity nodes via
99+
// the existing label selector.
100+
MaxReplicasPerModel int `env:"LOCALAI_MAX_REPLICAS_PER_MODEL" default:"1" help:"Max replicas of any single model on this worker. Default 1 preserves single-replica behavior; set higher to allow stacking replicas on a fat node." group:"registration"`
93101

94102
// NATS (required)
95103
NatsURL string `env:"LOCALAI_NATS_URL" required:"" help:"NATS server URL" group:"distributed"`
@@ -567,22 +575,35 @@ func (s *backendSupervisor) getAddr(backend string) string {
567575
return ""
568576
}
569577

578+
// buildProcessKey is the supervisor's stable identifier for a backend gRPC
579+
// process. It includes the replica index so the same model can run multiple
580+
// processes on a worker simultaneously without colliding on the same map slot
581+
// or port. The "#N" suffix is purely internal — the controller never reads it.
582+
func buildProcessKey(modelID, backend string, replicaIndex int) string {
583+
base := modelID
584+
if base == "" {
585+
base = backend
586+
}
587+
return fmt.Sprintf("%s#%d", base, replicaIndex)
588+
}
589+
570590
// installBackend handles the backend.install flow:
571-
// 1. If already running for this model, return existing address
591+
// 1. If already running for this (model, replica) slot, return existing address
572592
// 2. Install backend from gallery (if not already installed)
573593
// 3. Find backend binary
574594
// 4. Start gRPC process on a new port
575595
// Returns the gRPC address of the backend process.
596+
//
597+
// ProcessKey includes the replica index so a worker with MaxReplicasPerModel>1
598+
// can host multiple processes for the same model on distinct ports. Old
599+
// controllers (no replica_index in the request) implicitly target replica 0,
600+
// which preserves single-replica behavior.
576601
func (s *backendSupervisor) installBackend(req messaging.BackendInstallRequest) (string, error) {
577-
// Process key: use ModelID if provided (per-model process), else backend name
578-
processKey := req.ModelID
579-
if processKey == "" {
580-
processKey = req.Backend
581-
}
602+
processKey := buildProcessKey(req.ModelID, req.Backend, int(req.ReplicaIndex))
582603

583-
// If already running for this model, return its address
604+
// If already running for this model+replica, return its address
584605
if addr := s.getAddr(processKey); addr != "" {
585-
xlog.Info("Backend already running for model", "backend", req.Backend, "model", req.ModelID, "addr", addr)
606+
xlog.Info("Backend already running for model replica", "backend", req.Backend, "model", req.ModelID, "replica", req.ReplicaIndex, "addr", addr)
586607
return addr, nil
587608
}
588609

@@ -886,13 +907,18 @@ func (cmd *WorkerCMD) registrationBody() map[string]any {
886907
totalVRAM, _ := xsysinfo.TotalAvailableVRAM()
887908
gpuVendor, _ := xsysinfo.DetectGPUVendor()
888909

910+
maxReplicas := cmd.MaxReplicasPerModel
911+
if maxReplicas < 1 {
912+
maxReplicas = 1
913+
}
889914
body := map[string]any{
890-
"name": nodeName,
891-
"address": cmd.advertiseAddr(),
892-
"http_address": cmd.advertiseHTTPAddr(),
893-
"total_vram": totalVRAM,
894-
"available_vram": totalVRAM, // initially all VRAM is available
895-
"gpu_vendor": gpuVendor,
915+
"name": nodeName,
916+
"address": cmd.advertiseAddr(),
917+
"http_address": cmd.advertiseHTTPAddr(),
918+
"total_vram": totalVRAM,
919+
"available_vram": totalVRAM, // initially all VRAM is available
920+
"gpu_vendor": gpuVendor,
921+
"max_replicas_per_model": maxReplicas,
896922
}
897923

898924
// If no GPU detected, report system RAM so the scheduler/UI has capacity info
@@ -906,19 +932,20 @@ func (cmd *WorkerCMD) registrationBody() map[string]any {
906932
body["token"] = cmd.RegistrationToken
907933
}
908934

909-
// Parse and add static node labels
935+
// Parse and add static node labels. Always include the auto-label
936+
// `node.replica-slots=N` so AND-selectors in ModelSchedulingConfig can
937+
// target high-capacity nodes (e.g. {"node.replica-slots":"4"}).
938+
labels := make(map[string]string)
910939
if cmd.NodeLabels != "" {
911-
labels := make(map[string]string)
912940
for _, pair := range strings.Split(cmd.NodeLabels, ",") {
913941
pair = strings.TrimSpace(pair)
914942
if k, v, ok := strings.Cut(pair, "="); ok {
915943
labels[strings.TrimSpace(k)] = strings.TrimSpace(v)
916944
}
917945
}
918-
if len(labels) > 0 {
919-
body["labels"] = labels
920-
}
921946
}
947+
labels["node.replica-slots"] = strconv.Itoa(maxReplicas)
948+
body["labels"] = labels
922949

923950
return body
924951
}

core/cli/worker_replica_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package cli
2+
3+
import (
4+
. "github.com/onsi/ginkgo/v2"
5+
. "github.com/onsi/gomega"
6+
)
7+
8+
var _ = Describe("Worker per-replica process keying", func() {
9+
Describe("buildProcessKey", func() {
10+
// Pin the supervisor's keying contract: distinct replica indexes for
11+
// the same modelID produce distinct process keys, so the supervisor
12+
// map can hold multiple processes for one model. Dropping the suffix
13+
// would re-introduce the original flap (one model, one slot, churn).
14+
DescribeTable("produces stable, distinct keys",
15+
func(modelID, backend string, replica int, want string) {
16+
Expect(buildProcessKey(modelID, backend, replica)).To(Equal(want))
17+
},
18+
Entry("modelID present, replica 0", "Qwen3-35B", "llama-cpp", 0, "Qwen3-35B#0"),
19+
Entry("modelID present, replica 1", "Qwen3-35B", "llama-cpp", 1, "Qwen3-35B#1"),
20+
Entry("falls back to backend when modelID empty", "", "llama-cpp", 0, "llama-cpp#0"),
21+
Entry("backend fallback with replica 2", "", "llama-cpp", 2, "llama-cpp#2"),
22+
)
23+
24+
It("makes replicas distinguishable", func() {
25+
r0 := buildProcessKey("model-a", "llama-cpp", 0)
26+
r1 := buildProcessKey("model-a", "llama-cpp", 1)
27+
Expect(r0).ToNot(Equal(r1), "replicas of the same model must produce distinct keys")
28+
})
29+
})
30+
31+
Describe("registrationBody", func() {
32+
It("includes max_replicas_per_model and the auto-label", func() {
33+
cmd := &WorkerCMD{
34+
Addr: "worker.example.com:50051",
35+
MaxReplicasPerModel: 4,
36+
}
37+
body := cmd.registrationBody()
38+
39+
Expect(body).To(HaveKey("max_replicas_per_model"))
40+
Expect(body["max_replicas_per_model"]).To(Equal(4))
41+
42+
labels, ok := body["labels"].(map[string]string)
43+
Expect(ok).To(BeTrue(), "labels must be present so selectors can target the slot count")
44+
Expect(labels).To(HaveKeyWithValue("node.replica-slots", "4"))
45+
})
46+
47+
It("coerces zero/unset MaxReplicasPerModel to 1", func() {
48+
cmd := &WorkerCMD{Addr: "worker.example.com:50051"}
49+
body := cmd.registrationBody()
50+
Expect(body["max_replicas_per_model"]).To(Equal(1),
51+
"unset must default to single-replica behavior, not capacity 0")
52+
53+
labels := body["labels"].(map[string]string)
54+
Expect(labels).To(HaveKeyWithValue("node.replica-slots", "1"))
55+
})
56+
57+
It("preserves user-provided labels alongside the auto-label", func() {
58+
cmd := &WorkerCMD{
59+
Addr: "worker.example.com:50051",
60+
MaxReplicasPerModel: 2,
61+
NodeLabels: "tier=fast,gpu=a100",
62+
}
63+
body := cmd.registrationBody()
64+
labels := body["labels"].(map[string]string)
65+
Expect(labels).To(HaveKeyWithValue("tier", "fast"))
66+
Expect(labels).To(HaveKeyWithValue("gpu", "a100"))
67+
Expect(labels).To(HaveKeyWithValue("node.replica-slots", "2"))
68+
})
69+
})
70+
})

core/http/endpoints/localai/nodes.go

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ type RegisterNodeRequest struct {
7373
AvailableRAM uint64 `json:"available_ram,omitempty"`
7474
GPUVendor string `json:"gpu_vendor,omitempty"`
7575
Labels map[string]string `json:"labels,omitempty"`
76+
// MaxReplicasPerModel is the per-node cap on replicas of any single model.
77+
// Workers older than this field omit it; we coerce 0 → 1 below to preserve
78+
// historical single-replica behavior.
79+
MaxReplicasPerModel int `json:"max_replicas_per_model,omitempty"`
7680
}
7781

7882
// RegisterNodeEndpoint registers a new backend node.
@@ -131,17 +135,26 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
131135
tokenHash = hex.EncodeToString(h[:])
132136
}
133137

138+
// Coerce 0 → 1 for backward compat with workers that don't send the field.
139+
// GORM's `default:1` only fires for a missing column; once Go zero-values
140+
// reach the struct field they're written as 0 unless explicitly set here.
141+
maxReplicasPerModel := req.MaxReplicasPerModel
142+
if maxReplicasPerModel < 1 {
143+
maxReplicasPerModel = 1
144+
}
145+
134146
node := &nodes.BackendNode{
135-
Name: req.Name,
136-
NodeType: nodeType,
137-
Address: req.Address,
138-
HTTPAddress: req.HTTPAddress,
139-
TokenHash: tokenHash,
140-
TotalVRAM: req.TotalVRAM,
141-
AvailableVRAM: req.AvailableVRAM,
142-
TotalRAM: req.TotalRAM,
143-
AvailableRAM: req.AvailableRAM,
144-
GPUVendor: req.GPUVendor,
147+
Name: req.Name,
148+
NodeType: nodeType,
149+
Address: req.Address,
150+
HTTPAddress: req.HTTPAddress,
151+
TokenHash: tokenHash,
152+
TotalVRAM: req.TotalVRAM,
153+
AvailableVRAM: req.AvailableVRAM,
154+
TotalRAM: req.TotalRAM,
155+
AvailableRAM: req.AvailableRAM,
156+
GPUVendor: req.GPUVendor,
157+
MaxReplicasPerModel: maxReplicasPerModel,
145158
}
146159

147160
ctx := c.Request().Context()
@@ -386,7 +399,10 @@ func InstallBackendOnNodeEndpoint(unloader nodes.NodeCommandSender) echo.Handler
386399
if req.Backend == "" && req.URI == "" {
387400
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "backend name or uri required"))
388401
}
389-
reply, err := unloader.InstallBackend(nodeID, req.Backend, "", req.BackendGalleries, req.URI, req.Name, req.Alias)
402+
// Admin-driven backend install: not tied to a specific replica slot
403+
// (no model is being loaded). Pass replica 0 to match the worker's
404+
// admin process-key convention (`backend#0`).
405+
reply, err := unloader.InstallBackend(nodeID, req.Backend, "", req.BackendGalleries, req.URI, req.Name, req.Alias, 0)
390406
if err != nil {
391407
xlog.Error("Failed to install backend on node", "node", nodeID, "backend", req.Backend, "uri", req.URI, "error", err)
392408
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to install backend on node"))
@@ -467,8 +483,8 @@ func UnloadModelOnNodeEndpoint(unloader nodes.NodeCommandSender, registry *nodes
467483
xlog.Error("Failed to stop backend after model unload", "node", nodeID, "model", req.ModelName, "error", err)
468484
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "model unloaded but backend stop failed"))
469485
}
470-
// Remove from registry
471-
registry.RemoveNodeModel(c.Request().Context(), nodeID, req.ModelName)
486+
// Remove every replica of this model on the node from the registry.
487+
registry.RemoveAllNodeModelReplicas(c.Request().Context(), nodeID, req.ModelName)
472488
return c.JSON(http.StatusOK, map[string]string{"message": "model unloaded"})
473489
}
474490
}
@@ -494,7 +510,7 @@ func DeleteModelOnNodeEndpoint(unloader nodes.NodeCommandSender, registry *nodes
494510
// Non-fatal — backend process may not be running
495511
xlog.Warn("StopBackend failed during model deletion (non-fatal)", "node", nodeID, "model", req.ModelName, "error", err)
496512
}
497-
registry.RemoveNodeModel(c.Request().Context(), nodeID, req.ModelName)
513+
registry.RemoveAllNodeModelReplicas(c.Request().Context(), nodeID, req.ModelName)
498514
return c.JSON(http.StatusOK, map[string]string{"message": "model deleted from node"})
499515
}
500516
}
@@ -669,6 +685,78 @@ func GetNodeLabelsEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
669685
}
670686
}
671687

688+
// UpdateMaxReplicasPerModelRequest is the body for the per-node replica cap endpoint.
689+
type UpdateMaxReplicasPerModelRequest struct {
690+
// Value is the new per-model replica cap on this node. Must be >= 1.
691+
Value int `json:"value"`
692+
}
693+
694+
// UpdateMaxReplicasPerModelEndpoint sets the per-node cap on how many replicas
695+
// of any one model can be loaded concurrently. The corresponding
696+
// `node.replica-slots` auto-label is refreshed so existing AND-selectors keep
697+
// matching, and any unsatisfiable scheduling cooldowns are cleared so the
698+
// reconciler retries on the next tick.
699+
//
700+
// This is a transient admin override — a worker re-registration restores the
701+
// value the worker was started with (--max-replicas-per-model). For permanent
702+
// fleet changes, change the worker flag.
703+
//
704+
// @Summary Update a node's max replicas per model
705+
// @Tags Nodes
706+
// @Param id path string true "Node ID"
707+
// @Param request body UpdateMaxReplicasPerModelRequest true "New value"
708+
// @Success 200 {object} map[string]int
709+
// @Failure 400 {object} map[string]any "value must be >= 1"
710+
// @Failure 404 {object} map[string]any "node not found"
711+
// @Router /api/nodes/{id}/max-replicas-per-model [put]
712+
func UpdateMaxReplicasPerModelEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
713+
return func(c echo.Context) error {
714+
ctx := c.Request().Context()
715+
nodeID := c.Param("id")
716+
if _, err := registry.Get(ctx, nodeID); err != nil {
717+
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
718+
}
719+
var req UpdateMaxReplicasPerModelRequest
720+
if err := c.Bind(&req); err != nil {
721+
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "invalid request body"))
722+
}
723+
if req.Value < 1 {
724+
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "value must be >= 1"))
725+
}
726+
if err := registry.UpdateMaxReplicasPerModel(ctx, nodeID, req.Value); err != nil {
727+
xlog.Error("Failed to update max_replicas_per_model", "node", nodeID, "value", req.Value, "error", err)
728+
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to update max replicas per model"))
729+
}
730+
return c.JSON(http.StatusOK, map[string]int{"max_replicas_per_model": req.Value})
731+
}
732+
}
733+
734+
// ResetMaxReplicasPerModelEndpoint clears the admin override on a node, so
735+
// the next worker re-registration is allowed to update the value from its
736+
// CLI flag again. The current value is left in place until the worker calls
737+
// register.
738+
//
739+
// @Summary Reset a node's max replicas per model to the worker default
740+
// @Tags Nodes
741+
// @Param id path string true "Node ID"
742+
// @Success 200 {object} map[string]bool
743+
// @Failure 404 {object} map[string]any "node not found"
744+
// @Router /api/nodes/{id}/max-replicas-per-model [delete]
745+
func ResetMaxReplicasPerModelEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
746+
return func(c echo.Context) error {
747+
ctx := c.Request().Context()
748+
nodeID := c.Param("id")
749+
if _, err := registry.Get(ctx, nodeID); err != nil {
750+
return c.JSON(http.StatusNotFound, nodeError(http.StatusNotFound, "node not found"))
751+
}
752+
if err := registry.ResetMaxReplicasPerModel(ctx, nodeID); err != nil {
753+
xlog.Error("Failed to reset max_replicas_per_model override", "node", nodeID, "error", err)
754+
return c.JSON(http.StatusInternalServerError, nodeError(http.StatusInternalServerError, "failed to reset override"))
755+
}
756+
return c.JSON(http.StatusOK, map[string]bool{"reset": true})
757+
}
758+
}
759+
672760
// SetNodeLabelsEndpoint replaces all labels for a node.
673761
func SetNodeLabelsEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
674762
return func(c echo.Context) error {

core/http/react-ui/e2e/nodes-per-node-backend-actions.spec.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ async function expandNodeAndWaitForBackends(page) {
8585
// Click the row to expand it. The chevron toggle and the row both work,
8686
// but clicking the name cell is the most user-like.
8787
await page.getByText(NODE_NAME).first().click()
88+
// Backends, Capacity and Labels live behind a "Manage" <details>
89+
// disclosure (the drawer was distilled to keep at-a-glance content
90+
// lean — see distill refactor in the multi-replica branch). Open it
91+
// by clicking the summary inside the .node-manage scope so the
92+
// per-node backend table is in the DOM before assertions run.
93+
await page.locator('.node-manage > summary').first().click()
8894
await expect(page.getByRole('cell', { name: BACKEND_NAME, exact: true })).toBeVisible({ timeout: 10_000 })
8995
}
9096

core/http/react-ui/src/App.css

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1977,6 +1977,41 @@ select.input {
19771977
opacity: 0.8;
19781978
}
19791979

1980+
/* Small caps eyebrow inside the drawer's "Manage" disclosure. Replaces the
1981+
h4 sub-headings that used to stack inside the drawer — at this depth, an
1982+
eyebrow keeps the typographic hierarchy from feeling parallel to the
1983+
page-level h1/h2 stack. */
1984+
.drawer-eyebrow {
1985+
font-size: 0.6875rem;
1986+
font-weight: var(--font-weight-semibold);
1987+
letter-spacing: 0.06em;
1988+
text-transform: uppercase;
1989+
color: var(--color-text-muted);
1990+
margin-bottom: var(--spacing-xs);
1991+
}
1992+
1993+
/* "Manage" disclosure inside the node drawer. The chevron rotates with the
1994+
open state so the affordance reads as an accordion, not a link. */
1995+
.node-manage > summary {
1996+
user-select: none;
1997+
outline: none;
1998+
}
1999+
.node-manage > summary::-webkit-details-marker {
2000+
display: none;
2001+
}
2002+
.node-manage > summary:focus-visible {
2003+
outline: 2px solid var(--color-primary);
2004+
outline-offset: 2px;
2005+
border-radius: var(--radius-sm);
2006+
}
2007+
.node-manage__chevron {
2008+
font-size: 0.625rem;
2009+
transition: transform var(--duration-fast) ease-out;
2010+
}
2011+
.node-manage[open] > summary .node-manage__chevron {
2012+
transform: rotate(90deg);
2013+
}
2014+
19802015
/* Node-status indicator — replaces the tiny bullet with a proper LED-style
19812016
dot next to a bold status label. Colors are applied inline from statusConfig
19822017
so one primitive handles healthy/unhealthy/draining/pending in one shape. */

0 commit comments

Comments
 (0)