Files
llmgw/internal/control/modelsvc/modelsvc_test.go
T
Ilker ManapandClaude Opus 5 277add5ccb Keep runtime models, so an added model outlives a restart
The catalog can change while the cluster runs; this is where a change goes
so it is still there tomorrow. Models declared in configuration and models
added at runtime are two sources on purpose: config-as-code stays
declarative and under review, and the database holds what someone added to
try it.

Configuration wins every conflict, and the direction matters. A request for
a reviewed production model must never be answered by something added from
a laptop, so a stored model colliding with a declared name is refused when
it is added. A declared model cannot be removed at runtime either — it
would return on the next restart, leaving the cluster disagreeing with its
own config file until then, which is a worse answer than refusing and
saying where to edit.

The whole catalog is built before anything is written. A model naming a
missing fallback, or colliding, or malformed in any of the ways catalog.New
refuses, is rejected rather than persisted and then skipped forever after —
a model that looks added and never appears is the worst outcome available.
Removal is checked the same way, because another model may fall back to the
one being removed, and a fallback pointing at nothing is discovered during
the outage it was meant to cover.

At startup each stored model is validated on its own against what is
already accepted, so one bad row is skipped and named rather than taking
the cluster with it. The declared models are precisely the ones that must
survive that.

Catalog types now carry json tags mirroring their koanf ones, so a stored
definition is the same shape as the YAML someone writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:26:23 +02:00

197 lines
6.0 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")
}
}