Files
llmgw/internal/control/modelsvc/modelsvc_test.go
T
Ilker ManapandClaude Opus 5 707d774c76 Change a model's replica count without editing a file on the control plane
The count is scaling.min, and until now the only way to change it was to edit
server.yaml on the host and restart — or, for a model added at runtime, to
remove it and add it back, which means having its whole definition to hand.

`llmgw ctl scale <model> --min N` instead. Only the scaling block moves:
everything else is read back from what was stored and written out untouched, so
changing a number cannot quietly become a redefinition.

The catalog is rebuilt and validated before anything is written, as in Add. A
max below a min is refused with the stored definition untouched — persisting a
rejected change would come back on the next restart as a catalog that does not
build.

The answer reports the scaling in force rather than the request. The catalog
fills in a max that was left out, and a caller echoing its own request would
print a number the cluster is not using.

A model declared in configuration is refused, for the reason Remove already
refuses one: the YAML is read on every start, so the new count would hold until
the next restart and then silently revert. A cluster disagreeing with its own
config file is worse than an error saying where to edit.

The help says what --max does, because it is not what it looks like: a model
with a minimum runs exactly that many and nothing scales it up under load, and
an on-demand model loads one replica however high its max.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-27 11:25:05 +02:00

340 lines
11 KiB
Go

package modelsvc
import (
"context"
"encoding/json"
"log/slog"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
type memStore struct {
rows map[string]store.StoredModel
err error
}
func newMemStore() *memStore { return &memStore{rows: map[string]store.StoredModel{}} }
func (m *memStore) PutModel(_ context.Context, s store.StoredModel) error {
if m.err != nil {
return m.err
}
m.rows[s.Name] = s
return nil
}
func (m *memStore) DeleteModel(_ context.Context, name string) error {
delete(m.rows, name)
return nil
}
func (m *memStore) Models(context.Context) ([]store.StoredModel, error) {
out := make([]store.StoredModel, 0, len(m.rows))
for _, r := range m.rows {
out = append(out, r)
}
return out, nil
}
type recorder struct{ last *catalog.Catalog }
func (r *recorder) ReplaceCatalog(_ context.Context, c *catalog.Catalog) { r.last = c }
func model(name string) catalog.Model {
return catalog.Model{
Name: name, Source: "/models/" + name,
Scaling: catalog.Scaling{Min: 1, Max: 1},
Profiles: []catalog.Profile{{Name: "p", Engine: "llamacpp", Weight: 1}},
}
}
func newService(t *testing.T, declared ...catalog.Model) (*Service, *memStore, *recorder) {
t.Helper()
st, rec := newMemStore(), &recorder{}
return New(declared, st, rec, slog.New(slog.DiscardHandler)), st, rec
}
func TestAddedModelReachesTheCatalogAndSurvivesARestart(t *testing.T) {
// The whole point: trying a model should not mean a config change, a
// restart and an outage — and it should still be there tomorrow.
s, st, rec := newService(t, model("declared"))
if err := s.Add(t.Context(), model("added"), "ilker"); err != nil {
t.Fatal(err)
}
if _, ok := rec.last.Resolve("added"); !ok {
t.Error("the new model is not in the catalog that was applied")
}
if _, ok := rec.last.Resolve("declared"); !ok {
t.Error("the declared model was lost")
}
if _, ok := st.rows["added"]; !ok {
t.Error("the model was not persisted")
}
// A fresh service over the same store, as after a restart.
s2, _, _ := newService(t, model("declared"))
s2.store = st
built, err := s2.Load(t.Context())
if err != nil {
t.Fatal(err)
}
if _, ok := built.Resolve("added"); !ok {
t.Error("the model did not survive a restart")
}
}
func TestConfigurationWinsOverTheDatabase(t *testing.T) {
// The dangerous direction: a request for a reviewed production model must
// never be answered by something added from a laptop.
s, _, _ := newService(t, model("shared"))
err := s.Add(t.Context(), model("shared"), "ilker")
if err == nil {
t.Fatal("a stored model was allowed to shadow a declared one")
}
if !strings.Contains(err.Error(), "declared in configuration") {
t.Errorf("the error does not say why: %v", err)
}
}
func TestADeclaredModelCannotBeRemovedAtRuntime(t *testing.T) {
// It would come back on the next restart, so the cluster would disagree
// with its own config file until then.
s, _, _ := newService(t, model("declared"))
err := s.Remove(t.Context(), "declared")
if err == nil {
t.Fatal("a declared model was removed at runtime")
}
if !strings.Contains(err.Error(), "comes back") {
t.Errorf("the error does not explain the consequence: %v", err)
}
}
func TestNothingIsPersistedWhenTheCatalogWouldBeInvalid(t *testing.T) {
// A model that is written and then skipped on every start is a model that
// looks added and never appears.
s, st, rec := newService(t)
bad := model("bad")
bad.Fallbacks = []string{"a-model-that-does-not-exist"}
if err := s.Add(t.Context(), bad, "ilker"); err == nil {
t.Fatal("a model naming a missing fallback was accepted")
}
if len(st.rows) != 0 {
t.Errorf("it was persisted anyway: %v", st.rows)
}
if rec.last != nil {
t.Error("a catalog was applied for a model that was rejected")
}
}
func TestRemovingAModelOthersFallBackToIsRefused(t *testing.T) {
// Leaving a fallback pointing at nothing is discovered during the outage it
// was meant to cover.
s, _, _ := newService(t)
if err := s.Add(t.Context(), model("small"), "ilker"); err != nil {
t.Fatal(err)
}
big := model("big")
big.Fallbacks = []string{"small"}
if err := s.Add(t.Context(), big, "ilker"); err != nil {
t.Fatal(err)
}
if err := s.Remove(t.Context(), "small"); err == nil {
t.Fatal("removed a model another falls back to")
}
}
func TestOneUnreadableStoredModelDoesNotStopTheCluster(t *testing.T) {
// Refusing to start over one bad row would let a model somebody added from
// a laptop take the cluster down, and the declared models are precisely the
// ones that must survive that.
s, st, _ := newService(t, model("declared"))
good, _ := json.Marshal(model("good"))
st.rows["good"] = store.StoredModel{Name: "good", Definition: good, CreatedAt: time.Now()}
st.rows["broken"] = store.StoredModel{
Name: "broken", Definition: []byte(`{"name":`), CreatedAt: time.Now(),
}
built, err := s.Load(t.Context())
if err != nil {
t.Fatalf("one bad row stopped the catalog from loading: %v", err)
}
for _, name := range []string{"declared", "good"} {
if _, ok := built.Resolve(name); !ok {
t.Errorf("%s was lost alongside the bad row", name)
}
}
if _, ok := built.Resolve("broken"); ok {
t.Error("the unreadable model was loaded anyway")
}
}
func TestAStoredModelThatNoLongerValidatesIsSkippedAlone(t *testing.T) {
// Checked one at a time and against what is already accepted, so one bad
// model cannot take a good one with it.
s, st, _ := newService(t)
bad := model("bad")
bad.Profiles = nil // a model with no profile cannot be placed
badJSON, _ := json.Marshal(bad)
goodJSON, _ := json.Marshal(model("good"))
st.rows["bad"] = store.StoredModel{Name: "bad", Definition: badJSON, CreatedAt: time.Now()}
st.rows["good"] = store.StoredModel{Name: "good", Definition: goodJSON, CreatedAt: time.Now().Add(time.Second)}
built, err := s.Load(t.Context())
if err != nil {
t.Fatalf("load failed: %v", err)
}
if _, ok := built.Resolve("good"); !ok {
t.Error("a valid stored model was dropped because another was invalid")
}
}
func TestScalingChangesTheCountAndSurvivesARestart(t *testing.T) {
s, st, rec := newService(t)
if err := s.Add(t.Context(), model("added"), "ilker"); err != nil {
t.Fatal(err)
}
got, err := s.Scale(t.Context(), "added", catalog.Scaling{Min: 3}, "ilker")
if err != nil {
t.Fatal(err)
}
// Max follows min when it is left out, and the answer reports what the
// catalog settled on rather than what was asked for — a caller echoing its
// own request would print a max of zero.
if got.Scaling.Min != 3 || got.Scaling.Max != 3 {
t.Errorf("expected 3/3 in force, got %d/%d", got.Scaling.Min, got.Scaling.Max)
}
if m, _ := rec.last.Resolve("added"); m.Scaling.Min != 3 {
t.Errorf("the applied catalog still says %d", m.Scaling.Min)
}
s2, _, _ := newService(t)
s2.store = st
built, err := s2.Load(t.Context())
if err != nil {
t.Fatal(err)
}
if m, _ := built.Resolve("added"); m.Scaling.Min != 3 {
t.Errorf("the new count did not survive a restart; got %d", m.Scaling.Min)
}
}
func TestScalingChangesNothingButTheCount(t *testing.T) {
s, _, rec := newService(t)
m := model("added")
m.Aliases = []string{"a"}
m.Profiles[0].Args = []string{"--ctx-size", "16384"}
if err := s.Add(t.Context(), m, "ilker"); err != nil {
t.Fatal(err)
}
if _, err := s.Scale(t.Context(), "added", catalog.Scaling{Min: 2}, "ilker"); err != nil {
t.Fatal(err)
}
// Changing a number must not quietly become a redefinition: the source and
// the engine arguments somebody tuned are read back and written out again.
got, _ := rec.last.Resolve("added")
if got.Source != "/models/added" {
t.Errorf("the source changed to %q", got.Source)
}
if len(got.Profiles) != 1 || strings.Join(got.Profiles[0].Args, " ") != "--ctx-size 16384" {
t.Errorf("the profile arguments were lost: %+v", got.Profiles)
}
if len(got.Aliases) != 1 || got.Aliases[0] != "a" {
t.Errorf("the aliases were lost: %v", got.Aliases)
}
}
func TestADeclaredModelCannotBeScaledHere(t *testing.T) {
s, _, _ := newService(t, model("declared"))
_, err := s.Scale(t.Context(), "declared", catalog.Scaling{Min: 5}, "ilker")
// The YAML is read on every start, so the number would hold until the next
// restart and then silently revert. Refusing and saying where to edit is
// the smaller surprise — the same call Remove makes.
if err == nil {
t.Fatal("expected scaling a declared model to be refused")
}
if !strings.Contains(err.Error(), "configuration") {
t.Errorf("the error should say where to change it; got %q", err)
}
}
func TestScalingAModelThatWasNeverAddedIsAnError(t *testing.T) {
s, _, _ := newService(t)
if _, err := s.Scale(t.Context(), "ghost", catalog.Scaling{Min: 1}, "ilker"); err == nil {
t.Error("expected an error for a model that is not there")
}
}
func TestAMaxBelowAMinIsRefusedBeforeAnythingIsWritten(t *testing.T) {
s, st, _ := newService(t)
if err := s.Add(t.Context(), model("added"), "ilker"); err != nil {
t.Fatal(err)
}
if _, err := s.Scale(t.Context(), "added", catalog.Scaling{Min: 3, Max: 1}, "ilker"); err == nil {
t.Fatal("expected a max below a min to be refused")
}
// Refused before the write, so the stored definition is untouched: a
// rejected change that had already been persisted would come back on the
// next restart as a catalog that does not build.
var stored catalog.Model
if err := json.Unmarshal(st.rows["added"].Definition, &stored); err != nil {
t.Fatal(err)
}
if stored.Scaling.Min != 1 {
t.Errorf("the stored definition was changed to %d despite the refusal", stored.Scaling.Min)
}
}
func TestScalingToZeroKeepsTheModelInTheCatalog(t *testing.T) {
s, _, rec := newService(t)
if err := s.Add(t.Context(), model("added"), "ilker"); err != nil {
t.Fatal(err)
}
got, err := s.Scale(t.Context(), "added", catalog.Scaling{Min: 0, Max: 0}, "ilker")
if err != nil {
t.Fatal(err)
}
// Configured and deliberately not running is a different and equally
// useful thing from removed: the entry stays so it can be brought back
// with one number.
if got.Scaling.OnDemand() {
t.Error("min 0 max 0 is not on-demand; it is off")
}
if _, ok := rec.last.Resolve("added"); !ok {
t.Error("scaling to zero must not remove the model")
}
}
func TestMinZeroWithAMaxIsOnDemand(t *testing.T) {
s, _, _ := newService(t)
if err := s.Add(t.Context(), model("added"), "ilker"); err != nil {
t.Fatal(err)
}
got, err := s.Scale(t.Context(), "added",
catalog.Scaling{Min: 0, Max: 2, IdleTimeout: 10 * time.Minute}, "ilker")
if err != nil {
t.Fatal(err)
}
if !got.Scaling.OnDemand() {
t.Error("min 0 with a max above it should be on-demand")
}
if got.Scaling.IdleTimeout != 10*time.Minute {
t.Errorf("the idle timeout was lost; got %v", got.Scaling.IdleTimeout)
}
}