mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-29 17:44:30 -04:00
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
182 lines
4.4 KiB
Go
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)
|
|
}
|