Skip to content

Commit 01b2b32

Browse files
committed
test(distributed): cover load info persistence and Bug-1 recovery
Adds Ginkgo specs that prove the persistence layer behaves correctly and that the reconciler actually recovers from the frontend-restart scenario that was failing in production: registry_test.go: - per-model row survives RemoveAllNodeModelReplicas (the bug repro) - ON CONFLICT (model_name) updates backend type + blob, last-write-wins - legacy NodeModel-blob fallback still works (rolling-upgrade transition) - GetModelLoadInfo returns ErrRecordNotFound when both sources are empty - UpsertModelLoadInfo rejects empty model names reconciler_test.go: - Bug-1 end-to-end: with min_replicas=2, no NodeModel rows, but a ModelLoadInfo row present, one reconcile tick fires two scheduler calls. Pre-fix this returned "no load info" and the scheduler never got called until a fresh inference request arrived. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7[1m]
1 parent 7395ac7 commit 01b2b32

2 files changed

Lines changed: 106 additions & 0 deletions

File tree

core/services/nodes/reconciler_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,47 @@ var _ = Describe("ReplicaReconciler", func() {
423423
Expect(cap).To(Equal(2))
424424
})
425425
})
426+
427+
Context("frontend-restart scenario (Bug-1)", func() {
428+
It("recovers replicas after every NodeModel row has been removed", func() {
429+
ctx := context.Background()
430+
431+
// One healthy node. UpsertModelLoadInfo records per-model metadata
432+
// independently of any NodeModel row, mirroring what
433+
// scheduleAndLoad does on a successful dispatch.
434+
node := registerNode("restart-node", "10.0.20.1:50051")
435+
setSchedulingConfig("restart-model", 2, 4, "")
436+
Expect(registry.UpsertModelLoadInfo(ctx, "restart-model", "llama-cpp", []byte("opts-from-pre-restart"))).To(Succeed())
437+
438+
// Simulate the bug: between frontend instances the NodeModel rows
439+
// are wiped (MarkOffline path, stale-heartbeat reaping). The
440+
// per-model load info row stays because it's not tied to any
441+
// (node, replica) slot.
442+
Expect(registry.RemoveAllNodeModelReplicas(ctx, node.ID, "restart-model")).To(Succeed())
443+
444+
// Pre-fix: GetModelLoadInfo returned ErrRecordNotFound here and
445+
// the reconciler logged "no load info" every 30s until a manual
446+
// inference request arrived.
447+
bt, blob, err := registry.GetModelLoadInfo(ctx, "restart-model")
448+
Expect(err).ToNot(HaveOccurred())
449+
Expect(bt).To(Equal("llama-cpp"))
450+
Expect(blob).To(Equal([]byte("opts-from-pre-restart")))
451+
452+
// And the reconciler tick should now call into the scheduler
453+
// for the missing replicas instead of bailing out.
454+
scheduler := &fakeScheduler{scheduleNode: node}
455+
reconciler := NewReplicaReconciler(ReplicaReconcilerOptions{
456+
Registry: registry,
457+
Scheduler: scheduler,
458+
DB: db,
459+
})
460+
reconciler.reconcile(ctx)
461+
462+
Expect(scheduler.scheduleCalls).ToNot(BeEmpty(),
463+
"reconciler must call the scheduler after a frontend restart that wiped NodeModel rows")
464+
Expect(scheduler.scheduleCalls[0].modelName).To(Equal("restart-model"))
465+
})
466+
})
426467
})
427468

428469
// fakeProber lets tests control whether a model's gRPC address "responds".

core/services/nodes/registry_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,4 +1100,69 @@ var _ = Describe("NodeRegistry", func() {
11001100
"reserved capacity must remove a node from VRAM-aware candidates")
11011101
})
11021102
})
1103+
1104+
Describe("ModelLoadInfo persistence (Bug-1)", func() {
1105+
It("survives every NodeModel row being removed", func() {
1106+
ctx := context.Background()
1107+
1108+
// One node with one loaded replica + per-replica blob (the legacy path).
1109+
node := makeNode("li-1", "10.0.1.1:50051", 8_000_000_000)
1110+
Expect(registry.Register(ctx, node, true)).To(Succeed())
1111+
Expect(registry.SetNodeModel(ctx, node.ID, "load-info-model", 0, "loaded", node.Address, 0)).To(Succeed())
1112+
Expect(registry.SetNodeModelLoadInfo(ctx, node.ID, "load-info-model", 0, "llama-cpp", []byte("opts-v1"))).To(Succeed())
1113+
1114+
// Persist per-model via the new path (the dispatch hook does this).
1115+
Expect(registry.UpsertModelLoadInfo(ctx, "load-info-model", "llama-cpp", []byte("opts-v1"))).To(Succeed())
1116+
1117+
// Simulate worker death + MarkOffline reaping: every NodeModel row gone.
1118+
Expect(registry.RemoveAllNodeModelReplicas(ctx, node.ID, "load-info-model")).To(Succeed())
1119+
1120+
bt, blob, err := registry.GetModelLoadInfo(ctx, "load-info-model")
1121+
Expect(err).ToNot(HaveOccurred(),
1122+
"per-model load info must survive every NodeModel row going away")
1123+
Expect(bt).To(Equal("llama-cpp"))
1124+
Expect(blob).To(Equal([]byte("opts-v1")))
1125+
})
1126+
1127+
It("ON CONFLICT updates backend type and opts (last-write-wins)", func() {
1128+
ctx := context.Background()
1129+
1130+
Expect(registry.UpsertModelLoadInfo(ctx, "lww", "llama-cpp", []byte("v1"))).To(Succeed())
1131+
Expect(registry.UpsertModelLoadInfo(ctx, "lww", "vllm", []byte("v2"))).To(Succeed())
1132+
1133+
bt, blob, err := registry.GetModelLoadInfo(ctx, "lww")
1134+
Expect(err).ToNot(HaveOccurred())
1135+
Expect(bt).To(Equal("vllm"))
1136+
Expect(blob).To(Equal([]byte("v2")))
1137+
})
1138+
1139+
It("falls back to legacy NodeModel blob when no per-model row exists", func() {
1140+
// Pre-fix rolling-upgrade path: a frontend that ran before the new
1141+
// table existed only wrote the per-replica blob. The new
1142+
// GetModelLoadInfo must still find it so an upgrade doesn't
1143+
// regress the reconciler for already-loaded models.
1144+
ctx := context.Background()
1145+
1146+
node := makeNode("li-legacy", "10.0.1.2:50051", 8_000_000_000)
1147+
Expect(registry.Register(ctx, node, true)).To(Succeed())
1148+
Expect(registry.SetNodeModel(ctx, node.ID, "legacy-model", 0, "loaded", node.Address, 0)).To(Succeed())
1149+
Expect(registry.SetNodeModelLoadInfo(ctx, node.ID, "legacy-model", 0, "llama-cpp", []byte("legacy-opts"))).To(Succeed())
1150+
1151+
bt, blob, err := registry.GetModelLoadInfo(ctx, "legacy-model")
1152+
Expect(err).ToNot(HaveOccurred())
1153+
Expect(bt).To(Equal("llama-cpp"))
1154+
Expect(blob).To(Equal([]byte("legacy-opts")))
1155+
})
1156+
1157+
It("returns ErrRecordNotFound when neither source has the model", func() {
1158+
ctx := context.Background()
1159+
_, _, err := registry.GetModelLoadInfo(ctx, "never-loaded")
1160+
Expect(err).To(MatchError(gorm.ErrRecordNotFound))
1161+
})
1162+
1163+
It("rejects empty model names", func() {
1164+
err := registry.UpsertModelLoadInfo(context.Background(), "", "llama-cpp", []byte("x"))
1165+
Expect(err).To(HaveOccurred())
1166+
})
1167+
})
11031168
})

0 commit comments

Comments
 (0)