Files
LocalAI/core/services/failover/schedule.go
T
Ettore Di Giacinto 5d2b90b848 fix(failover): never load a warm target inside a probe
A warm target's liveness probe called ModelLoader.Load, which blocked
until the model finished loading (while the warm preload loaded it
too). Tick waited for every probe, so all probing froze, and the probe
then ran HealthCheck on an expired context and tripped the target at
every startup.

The prober now takes a function that returns the running backend
without loading it. A target that is not loaded passes liveness; its
recovery is neither confirmed nor failed and it returns to healthy
after min_dwell, like a cold target. Tick no longer waits for probes:
each probe applies its own result and a target whose probe is running
is skipped.

Assisted-by: Claude:claude-opus-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-27 07:42:20 +00:00

182 lines
4.4 KiB
Go

package failover
import (
"context"
"errors"
"time"
"github.com/mudler/LocalAI/core/config"
)
// Run drives probes and dwell-based fail-back until ctx ends. Only this
// scheduler goroutine calls Sync: onWarm callbacks run after the manager's
// lock is released, and a concurrent Sync from elsewhere could reorder them.
func (m *Manager) Run(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
m.Tick(ctx)
for {
select {
case <-ctx.Done():
m.close()
return
case <-ticker.C:
m.Tick(ctx)
}
}
}
// Tick runs one pass: sync configs, start due probes, recompute chains. It is
// exported so tests can drive the manager without a real ticker. Like Run, it
// must only be called from the scheduler goroutine (see Run's comment on Sync).
//
// Tick does not wait for the probes it starts: one slow target (a probe that
// hangs until its timeout) must not delay probing and fail-back of every
// other chain. Each probe applies its own result, and a target whose probe is
// still running is skipped until it ends.
func (m *Manager) Tick(ctx context.Context) {
m.Sync()
for _, j := range m.dueProbes() {
m.probes.Add(1)
go func(j probeJob) {
defer m.probes.Done()
m.runProbe(ctx, j)
}(j)
}
m.Reevaluate()
}
// waitProbes waits for the probes started so far. Tests use it to see a
// tick's results; the scheduler never waits.
func (m *Manager) waitProbes() { m.probes.Wait() }
type probeJob struct {
target string
cfg config.ModelConfig
kind Kind
warm bool
inference bool
timeout time.Duration
}
func (m *Manager) dueProbes() []probeJob {
m.mu.Lock()
defer m.mu.Unlock()
now := m.clock.Now()
var jobs []probeJob
for _, ts := range m.targets {
if ts.probing {
continue
}
interval := ts.params.ProbeInterval()
inference := false
switch ts.state {
case StateMissing:
continue
case StateHealthy:
// A served request is as good as a liveness probe.
if now.Sub(ts.lastActivity) < interval || now.Sub(ts.lastProbe) < interval {
continue
}
case StateDown:
if ts.cold() {
// Loading a cold model only to probe it could evict others.
if now.Sub(ts.downSince) >= ts.params.MinDwell() {
m.setTargetLocked(ts, StateHealthy, ReasonRecovery, "")
m.recomputeForLocked(ts.name)
}
continue
}
if now.Sub(ts.lastProbe) < interval {
continue
}
case StateRecovering:
if ts.cold() || now.Sub(ts.lastProbe) < interval {
continue
}
inference = true
}
if m.prober == nil {
continue
}
cfg, ok := m.lookupTarget(ts.name)
if !ok {
continue
}
ts.lastProbe = now
ts.probing = true
jobs = append(jobs, probeJob{
target: ts.name, cfg: cfg, kind: ts.kind, warm: ts.warm,
inference: inference, timeout: ts.params.ProbeTimeout(),
})
}
return jobs
}
func (m *Manager) runProbe(ctx context.Context, j probeJob) {
pctx, cancel := context.WithTimeout(ctx, j.timeout)
defer cancel()
var err error
if j.inference {
err = m.prober.Inference(pctx, j.cfg, j.kind, j.warm)
} else {
err = m.prober.Liveness(pctx, j.cfg, j.kind, j.warm)
}
if ctx.Err() != nil {
m.endProbe(j.target)
return // shutting down: a cancelled probe says nothing about the target
}
m.applyProbe(j, err)
}
func (m *Manager) endProbe(target string) {
m.mu.Lock()
defer m.mu.Unlock()
if ts := m.targets[target]; ts != nil {
ts.probing = false
}
}
func (m *Manager) applyProbe(j probeJob, err error) {
m.mu.Lock()
defer m.mu.Unlock()
ts := m.targets[j.target]
if ts == nil {
return
}
ts.probing = false
if ts.state == StateMissing {
return
}
if errors.Is(err, ErrNotLoaded) {
// Nothing running to confirm recovery against: judge the target like
// a cold one, by real requests once min_dwell has passed.
if ts.state == StateRecovering && m.clock.Now().Sub(ts.downSince) >= ts.params.MinDwell() {
m.setTargetLocked(ts, StateHealthy, ReasonRecovery, "")
m.recomputeForLocked(ts.name)
}
return
}
if err != nil {
if ts.state == StateDown {
ts.lastError = err.Error()
} else {
m.recordFailureLocked(ts, err.Error())
}
m.recomputeForLocked(ts.name)
return
}
switch ts.state {
case StateHealthy:
ts.lastActivity = m.clock.Now()
ts.failures = nil
case StateDown:
m.setTargetLocked(ts, StateRecovering, ReasonRecovery, "")
case StateRecovering:
if j.inference {
m.recordPassLocked(ts)
}
}
m.recomputeForLocked(ts.name)
}