mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-14 15:18:03 -04:00
Task 4 gave agent workers tunnels and deliberately left the NodeType skip in HealthMonitor.tunnelDeparted, with a spec asserting that an agent node whose presence reader answers PresenceGone is NOT marked unhealthy. That spec was scaffolding. It was true while an agent worker took its jobs and its verbs over the message bus: a departure row for one said nothing about whether it could work, and an early bug in the new tunnel client could otherwise have demoted a fleet of healthy agent workers. There is no bus. An agent worker is reachable through its tunnel and through nothing else, so a departed agent tunnel means exactly what a departed backend tunnel means: no live replica holds it, the departure has outlived the reconnect grace, and that is a routing fact the scheduler and a reaper may act on. The skip would now hide the only symptom an unreachable agent worker has. This is the deliberate removal Task 4's M6 predicted, and task-4-report.md is where that mutation already stands recorded red against the spec this commit deletes. The skip existed at ONE site. router_liveness.go has none: its candidates come from queries that already filter node_type = 'backend'. The two skips in managers_distributed.go stay, because an agent worker still runs no backend processes, so it has no backend to list and no backend op to apply. Two node types can depart now, which is why the second half exists. Before this, one type could depart and every per-node cache a departure left stale was dropped from wherever its owner happened to notice, so a reader could not tell which caches a demotion invalidated by reading the demotion path. Departure gets ONE notification point. DepartureNotifier is edge triggered, because the monitor runs on a ticker and a departed node stays departed; its subscribers are NAMED, because what has to be caught is a forgotten cache and a count can say only that one of four is missing; and NewHealthMonitor takes it as a required positional argument, so a caller that does not pass one fails to compile. Four caches subscribe: prefix-cache affinity in every model, probe freshness at every address, in-flight staging operations, and the per-node breakdown of every open gallery operation. The prefix-cache one is registered only when prefix-cache routing is enabled, so --distributed-prefix-cache=false stays a true no-op. The notification carries the node's name as well as its id, because the staging tracker keys on the name and the other two key on the id, and a subscriber should not have to read the registry from inside an eviction hook. A departure notification is an act on absence, so it fires only on the routing fact. A tunnel lost inside the grace, a worker that never dialled, a presence query that failed and a stale heartbeat all announce nothing, asserted per node type. The stale-heartbeat branch is excluded on purpose: it already marks the node offline, which deletes its rows and runs the registry's replica-removed hooks, so firing there too would double-evict and make the notification mean two different things at its subscribers. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
136 lines
4.8 KiB
Go
136 lines
4.8 KiB
Go
package nodes
|
|
|
|
import (
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/sync/singleflight"
|
|
)
|
|
|
|
// probeCache memoizes recent successful gRPC HealthCheck results for
|
|
// (nodeID, addr) tuples so SmartRouter.probeHealth doesn't pay a round-trip
|
|
// on every inference request.
|
|
//
|
|
// Why this exists: with per-request routing (see pkg/model/loader.go), every
|
|
// inference call goes through SmartRouter.Route, which probes the backend
|
|
// before returning a client. Many gRPC backends (notably llama.cpp's server)
|
|
// serialize HealthCheck against active Predict on a shared goroutine, so a
|
|
// burst of new requests can stall behind a single long-running stream —
|
|
// exactly the "queue stalling" symptom observed in distributed clusters.
|
|
//
|
|
// The background HealthMonitor (perModelHealthCheck) is still the cluster-wide
|
|
// source of truth that reaps actually-dead backends within ~45s; this cache
|
|
// only saves the per-request hot path from re-asking when nothing has changed.
|
|
//
|
|
// TTL matches healthCheckTTL in pkg/model/model.go so the single-process
|
|
// IsRecentlyHealthy path and this distributed-mode path share the same
|
|
// staleness budget.
|
|
type probeCache struct {
|
|
ttl time.Duration
|
|
mu sync.Mutex
|
|
seen map[string]time.Time // key → last successful probe
|
|
flight singleflight.Group // coalesces concurrent probes for the same key
|
|
}
|
|
|
|
// newProbeCache returns a probeCache with the given TTL. Zero TTL disables
|
|
// caching: every call to DoOrCachedResult invokes the probe.
|
|
func newProbeCache(ttl time.Duration) *probeCache {
|
|
return &probeCache{
|
|
ttl: ttl,
|
|
seen: make(map[string]time.Time),
|
|
}
|
|
}
|
|
|
|
// IsFresh reports whether key was successfully probed within TTL.
|
|
func (c *probeCache) IsFresh(key string) bool {
|
|
if c.ttl <= 0 {
|
|
return false
|
|
}
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
last, ok := c.seen[key]
|
|
return ok && time.Since(last) < c.ttl
|
|
}
|
|
|
|
// markFresh records key as successfully probed at the current time.
|
|
func (c *probeCache) markFresh(key string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.seen[key] = time.Now()
|
|
}
|
|
|
|
// Invalidate drops any cached freshness for key. Used after a probe failure
|
|
// (or any other signal that the backend may not be alive) so the next call
|
|
// will re-probe instead of trusting stale state.
|
|
func (c *probeCache) Invalidate(key string) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
delete(c.seen, key)
|
|
}
|
|
|
|
// InvalidateNode drops every cached probe for nodeID.
|
|
//
|
|
// Keys are "<nodeID>|<addr>", so this is a prefix scan and not a lookup: a
|
|
// departed worker that comes back with recycled ports would otherwise serve one
|
|
// request per still-fresh address with no probe at all, which is the one moment
|
|
// the cache is asked about a process that certainly is not the one it saw.
|
|
func (c *probeCache) InvalidateNode(nodeID string) {
|
|
if nodeID == "" {
|
|
return
|
|
}
|
|
prefix := nodeID + "|"
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
for k := range c.seen {
|
|
if strings.HasPrefix(k, prefix) {
|
|
delete(c.seen, k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// DoOrCachedResult returns true if key is fresh; otherwise it runs probe
|
|
// (coalescing concurrent callers via singleflight) and caches a successful
|
|
// result. Failed probes invalidate the cache, so a transient miss does not pin
|
|
// every subsequent request to a re-probe.
|
|
//
|
|
// It is the ONLY entry point. A boolean-only sibling, DoOrCached, stood beside
|
|
// it until probeHealth stopped using it, after which it was production code
|
|
// held green by nothing but its own specs; the shim that reads it as a boolean
|
|
// now lives in probe_cache_test.go, where its one caller is.
|
|
//
|
|
// The second result is the reason the probe never reached the backend, or nil
|
|
// when it did.
|
|
//
|
|
// The second result travels through the SINGLEFLIGHT, which is the whole reason
|
|
// it is not simply a variable the caller closes over. A closed-over variable is
|
|
// only written by the goroutine that actually runs the probe; every other
|
|
// caller coalesced into that flight adopts the leader's boolean and sees its own
|
|
// unset variable, so the leader would correctly decline to reap while its
|
|
// joiners reaped on the very same observation. Carrying it in singleflight's
|
|
// error slot hands every joiner the leader's reason as well as its answer.
|
|
//
|
|
// A probe that could not reach the backend is NOT cached either way. Caching it
|
|
// as fresh would hide a genuinely dead backend behind a network blip, and
|
|
// caching it as a failure is what Invalidate already does.
|
|
func (c *probeCache) DoOrCachedResult(key string, probe func() (bool, error)) (bool, error) {
|
|
if c.IsFresh(key) {
|
|
return true, nil
|
|
}
|
|
v, unreached, _ := c.flight.Do(key, func() (any, error) {
|
|
// Double-check after potentially waiting: another caller in this
|
|
// flight may have just populated the cache.
|
|
if c.IsFresh(key) {
|
|
return true, nil
|
|
}
|
|
ok, unreached := probe()
|
|
if ok {
|
|
c.markFresh(key)
|
|
} else {
|
|
c.Invalidate(key)
|
|
}
|
|
return ok, unreached
|
|
})
|
|
return v.(bool), unreached
|
|
}
|