mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-30 01:54:31 -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>
146 lines
4.9 KiB
Go
146 lines
4.9 KiB
Go
package prefixcache
|
|
|
|
import (
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/pkg/radixtree"
|
|
)
|
|
|
|
// Index is the guessed (routing-history) Provider backed by per-model radix
|
|
// trees keyed by ReplicaKey. Affinity is per replica, so the same prefix served
|
|
// by two replicas of one node resolves back to the exact replica that served it.
|
|
// Safe for concurrent use.
|
|
type Index struct {
|
|
cfg Config
|
|
mu sync.RWMutex
|
|
trees map[string]*radixtree.Tree[ReplicaKey]
|
|
}
|
|
|
|
func NewIndex(cfg Config) *Index {
|
|
return &Index{cfg: cfg, trees: map[string]*radixtree.Tree[ReplicaKey]{}}
|
|
}
|
|
|
|
// existingTree returns the tree for model without creating one. The bool
|
|
// reports whether a tree already existed.
|
|
func (ix *Index) existingTree(model string) (*radixtree.Tree[ReplicaKey], bool) {
|
|
ix.mu.RLock()
|
|
defer ix.mu.RUnlock()
|
|
t, ok := ix.trees[model]
|
|
return t, ok
|
|
}
|
|
|
|
func (ix *Index) tree(model string) *radixtree.Tree[ReplicaKey] {
|
|
ix.mu.RLock()
|
|
t, ok := ix.trees[model]
|
|
ix.mu.RUnlock()
|
|
if ok {
|
|
return t
|
|
}
|
|
ix.mu.Lock()
|
|
defer ix.mu.Unlock()
|
|
if t, ok = ix.trees[model]; ok {
|
|
return t
|
|
}
|
|
t = radixtree.New[ReplicaKey](radixtree.Options{TTL: ix.cfg.TTL, HalfLife: ix.cfg.HalfLife})
|
|
ix.trees[model] = t
|
|
return t
|
|
}
|
|
|
|
func (ix *Index) Decide(model string, chain []uint64, candidates []ReplicaKey, now time.Time) PrefixDecision {
|
|
t := ix.tree(model)
|
|
var d PrefixDecision
|
|
// WeightsFor computes every candidate weight in a single tree walk and
|
|
// returns a map pre-populated with an entry (weight 0 by default) for every
|
|
// requested candidate. Candidacy is therefore exactly "is a key in weights",
|
|
// so we derive the hot-match membership check from it rather than building a
|
|
// second set.
|
|
weights := t.WeightsFor(candidates, now)
|
|
if len(chain) > 0 {
|
|
if key, depth, ok := t.LongestMatch(chain, now); ok {
|
|
// LongestMatch searches the whole tree, so the deepest match can be
|
|
// a replica that is offline / unloaded / not in the candidate set.
|
|
// Treating that as a hot match produces a false forced-disturb signal
|
|
// upstream (the warm replica was absent, not load-saturated). Only honor
|
|
// the match when the matched replica is an actual candidate; otherwise
|
|
// fall back to cold placement.
|
|
if _, ok := weights[key]; ok {
|
|
d.Hot = key
|
|
d.HasHot = true
|
|
d.MatchRatio = float64(depth) / float64(len(chain))
|
|
}
|
|
}
|
|
}
|
|
// Cold order: candidates ascending by cacheWeight, tie-break by NodeID then
|
|
// Replica. The sort comparator reads precomputed weights instead of triggering
|
|
// an O(tree size) Weight call per comparison. With at most one candidate the
|
|
// input order is already the cold order, so skip the sort.
|
|
order := make([]ReplicaKey, len(candidates))
|
|
copy(order, candidates)
|
|
if len(order) > 1 {
|
|
sort.Slice(order, func(i, j int) bool {
|
|
if weights[order[i]] != weights[order[j]] {
|
|
return weights[order[i]] < weights[order[j]]
|
|
}
|
|
return order[i].less(order[j])
|
|
})
|
|
}
|
|
d.ColdOrder = order
|
|
return d
|
|
}
|
|
|
|
func (ix *Index) Observe(model string, chain []uint64, key ReplicaKey, now time.Time) bool {
|
|
if len(chain) == 0 || key.NodeID == "" {
|
|
return false
|
|
}
|
|
t := ix.tree(model)
|
|
// New/extended iff the current deepest match for this exact chain is not
|
|
// already this replica at full depth.
|
|
cur, depth, ok := t.LongestMatch(chain, now)
|
|
t.Insert(chain, key, now)
|
|
return !ok || depth < len(chain) || cur != key
|
|
}
|
|
|
|
// Invalidate drops all entries for ONE replica. It never interns an empty tree
|
|
// (a registry chokepoint fires Invalidate for every replica removal of every
|
|
// model, including round-robin models that never used the prefix cache, so
|
|
// lazily creating a tree here would grow the trees map unboundedly).
|
|
func (ix *Index) Invalidate(model string, key ReplicaKey) {
|
|
if t, ok := ix.existingTree(model); ok {
|
|
t.RemoveFunc(func(k ReplicaKey) bool { return k == key })
|
|
}
|
|
}
|
|
|
|
// InvalidateNode drops entries for ALL replicas of nodeID. Like Invalidate it
|
|
// does not intern an empty tree.
|
|
func (ix *Index) InvalidateNode(model, nodeID string) {
|
|
if t, ok := ix.existingTree(model); ok {
|
|
t.RemoveFunc(func(k ReplicaKey) bool { return k.NodeID == nodeID })
|
|
}
|
|
}
|
|
|
|
// DropNode drops entries for ALL replicas of nodeID in EVERY model.
|
|
//
|
|
// InvalidateNode's sibling, and the difference is the caller. InvalidateNode
|
|
// serves the registry chokepoint, which fires per model because a replica row
|
|
// IS per model. A departure names no model at all: the node is gone from every
|
|
// model it ever served, and asking the caller to enumerate those would make the
|
|
// eviction depend on a registry read that a departed node's rows may already
|
|
// have left. Like its sibling it never interns a tree.
|
|
func (ix *Index) DropNode(nodeID string) {
|
|
ix.mu.RLock()
|
|
defer ix.mu.RUnlock()
|
|
for _, t := range ix.trees {
|
|
t.RemoveFunc(func(k ReplicaKey) bool { return k.NodeID == nodeID })
|
|
}
|
|
}
|
|
|
|
func (ix *Index) Evict(now time.Time) {
|
|
ix.mu.RLock()
|
|
defer ix.mu.RUnlock()
|
|
for _, t := range ix.trees {
|
|
t.Evict(now)
|
|
}
|
|
}
|