fix(failover): emit chain.switched when leaving degraded in place

recomputeLocked only fired the event on an active-target change or on
entering degraded. When the active target itself recovered while every
target was down, the chain silently left degraded with no event, so
SSE/realtime consumers tracking chain.switched.state got stuck on
"degraded".

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-09-27 07:42:20 +00:00
1 parent 3de6257609
commit 560ffee182
2 files changed
+25

No files matched your search

+6
View File
@@ -305,7 +305,13 @@ func (m *Manager) recomputeLocked(ch *chainState, override Reason) {
ch.activeSince = now
m.emitLocked(Event{Type: EventChainSwitched, Chain: ch.name, From: ch.targets[prev], To: ch.targets[next], State: string(state), Reason: reason, At: now})
case state == ChainDegraded && ch.state != ChainDegraded:
// Entering degraded with no active-target change (every target is down).
m.emitLocked(Event{Type: EventChainSwitched, Chain: ch.name, From: ch.targets[prev], To: ch.targets[next], State: string(state), Reason: ReasonDegraded, At: now})
case state != ChainDegraded && ch.state == ChainDegraded:
// Leaving degraded with no active-target change (the active target
// itself recovered): SSE/realtime consumers watch chain.switched.state,
// so this must fire or they stay on "degraded" forever.
m.emitLocked(Event{Type: EventChainSwitched, Chain: ch.name, From: ch.targets[prev], To: ch.targets[next], State: string(state), Reason: ReasonRecovery, At: now})
}
ch.state = state
}
+19
View File
@@ -132,6 +132,25 @@ var _ = Describe("Manager", func() {
Expect(att.Fail(errBoom)).To(BeFalse())
})
It("emits chain.switched when leaving degraded without an active-target change", func() {
m.ReportFailure("a", errBoom) // active moves to b
m.ReportFailure("b", errBoom) // both down: degraded, active stays b
st, _ := m.ChainStatus("chain")
Expect(st.State).To(Equal(ChainDegraded))
Expect(st.Active).To(Equal("b"))
events, cancel := m.Subscribe(16)
defer cancel()
m.ReportSuccess("b") // b is cold local: one success recovers it in place
st, _ = m.ChainStatus("chain")
Expect(st.State).To(Equal(ChainFallback))
Expect(st.Active).To(Equal("b"), "the active target itself recovered, no switch needed")
sw := switched(drain(events))
Expect(sw).To(HaveLen(1), "leaving degraded must still notify chain.switched listeners")
Expect(sw[0]).To(MatchFields(IgnoreExtras, Fields{
"Chain": Equal("chain"), "State": Equal("fallback"), "Reason": Equal(ReasonRecovery),
}))
})
It("pins a target regardless of health", func() {
Expect(m.Pin("chain", "b")).To(Succeed())
att, _ := m.Plan("chain")