diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b82222..afc49713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,16 @@ What v0.10.5 made settable from the command line is now on the pages too: critical model's card no longer refuses a replica that preemption would make room for. +### Restarts borrow a lower class's room + +A restart places the replacement beside the replica it replaces, and where +there is no free room it now makes some the way any placement does: by +stopping a replica its class outranks, which gets its room back once the old +replica has gone. The in-place restart — the model unserved while its +replacement loads — is left for when nothing of a lower class is running. On a +cluster where every model is standard, that is every full one: mark the models +that can wait `opportunistic` (`llmgw ctl model class`, or the model's card). + ## v0.10.5 — 2026-09-30 Published first as release candidates v0.10.5-rc.1 to rc.14; rc.5 failed its diff --git a/internal/control/reconciler/preempt_test.go b/internal/control/reconciler/preempt_test.go index 87559dd4..7e13e925 100644 --- a/internal/control/reconciler/preempt_test.go +++ b/internal/control/reconciler/preempt_test.go @@ -331,3 +331,62 @@ func TestHeadroomCountsWhatAHigherClassCouldTake(t *testing.T) { t.Errorf("a standard model counted room in another's minimum: %+v", got) } } + +func TestARestartBorrowsALowerClassesRoomAndGivesItBack(t *testing.T) { + // chat fills node 1; the only other node holds an opportunistic replica. + chat := gpuModel("chat", "", 1) + extra := gpuModel("extra", catalog.ClassOpportunistic, 1) + h := newFleetHarness(t, []catalog.Model{chat, extra}, gpuNode("1", 8), gpuNode("2", 8)) + h.settle(t, 3) + h.advance(h.r.cfg.MinLifetime + time.Minute) + var old string + for _, rep := range h.r.Replicas() { + if rep.Model == "chat" { + old = rep.ID + } + } + if old == "" || replicasOf(h, "extra") != 1 { + t.Fatalf("setup: %+v", h.r.Replicas()) + } + + if err := h.r.Restart(old); err != nil { + t.Fatal(err) + } + // Replacement first, beside the old one, in the room extra gives up; + // the old one keeps serving until then. + servedThroughout := true + for range 8 { + h.r.Reconcile(t.Context()) + h.markReady() + ready := 0 + for _, rep := range h.r.Replicas() { + if rep.Model == "chat" && rep.State == Ready { + ready++ + } + } + if ready == 0 { + servedThroughout = false + } + h.advance(h.r.cfg.PreemptGrace + time.Second) + } + if !servedThroughout { + t.Error("chat had no ready replica during its restart") + } + var newChat bool + for _, rep := range h.r.Replicas() { + if rep.Model == "chat" && rep.ID != old && rep.State == Ready { + newChat = true + } + if rep.ID == old { + t.Errorf("the old replica is still there: %+v", rep) + } + } + if !newChat { + t.Fatalf("no new chat replica: %+v", h.r.Replicas()) + } + // And extra has its replica back, in the room the old chat left. + h.settle(t, 4) + if replicasOf(h, "extra") != 1 { + t.Errorf("the opportunistic model did not get its room back: %+v", h.r.Replicas()) + } +}