mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-15 07:39:20 -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>
171 lines
7.6 KiB
Go
171 lines
7.6 KiB
Go
package prefixcache
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
// Sync wraps an Index, broadcasting new/extended observations to peers and
|
|
// applying peers' broadcasts. It is the cross-frontend coherence layer.
|
|
//
|
|
// It holds ONE carrier, and both directions use it: what this replica observes
|
|
// goes out on it, and what peers observe comes back in on it through
|
|
// SubscribeBroadcasts. Splitting those across two carriers would leave each
|
|
// frontend routing on nothing but its own history while every publish and every
|
|
// subscribe succeeded, so there is no way to spell that here.
|
|
type Sync struct {
|
|
idx Provider
|
|
// bus is messaging.Broadcaster, which is Publish plus Subscribe and nothing
|
|
// more. This package is reached from the routing hot path and a spec drives
|
|
// it with a two-method double; a wider parameter would hand the hot path
|
|
// request/reply it must never make.
|
|
bus messaging.Broadcaster
|
|
}
|
|
|
|
// NewSync wraps idx and puts its cross-frontend traffic on bus. A nil bus keeps
|
|
// the Sync local: it records and answers, and broadcasts nothing.
|
|
func NewSync(idx Provider, bus messaging.Broadcaster) *Sync { return &Sync{idx: idx, bus: bus} }
|
|
|
|
// SubscribeBroadcasts applies peers' observations and invalidations into the
|
|
// wrapped index. ApplyObserve and ApplyInvalidate update only the local index
|
|
// and never re-publish, so there is no broadcast loop.
|
|
//
|
|
// It takes no carrier argument on purpose. The carrier is the one this Sync
|
|
// publishes on, read from the same field, which is what makes "this replica
|
|
// hears what it would have said" a property of the type rather than of whoever
|
|
// wired it.
|
|
//
|
|
// Returns every subscription it opened; on a partial failure it releases what
|
|
// it already opened and returns nothing, so a caller cannot be left holding a
|
|
// half-subscribed Sync it believes is whole.
|
|
func (s *Sync) SubscribeBroadcasts() ([]messaging.Subscription, error) {
|
|
if s.bus == nil {
|
|
return nil, nil
|
|
}
|
|
var subs []messaging.Subscription
|
|
release := func() {
|
|
for _, sub := range subs {
|
|
if err := sub.Unsubscribe(); err != nil {
|
|
xlog.Warn("prefixcache: releasing a partial subscription", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
observeSub, err := messaging.SubscribeJSON(s.bus, messaging.SubjectPrefixCacheObserve, func(ev messaging.PrefixCacheObserveEvent) {
|
|
s.ApplyObserve(ev, time.Now())
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prefixcache: subscribing to %s: %w", messaging.SubjectPrefixCacheObserve, err)
|
|
}
|
|
subs = append(subs, observeSub)
|
|
|
|
invalidateSub, err := messaging.SubscribeJSON(s.bus, messaging.SubjectPrefixCacheInvalidate, func(ev messaging.PrefixCacheInvalidateEvent) {
|
|
s.ApplyInvalidate(ev)
|
|
})
|
|
if err != nil {
|
|
release()
|
|
return nil, fmt.Errorf("prefixcache: subscribing to %s: %w", messaging.SubjectPrefixCacheInvalidate, err)
|
|
}
|
|
subs = append(subs, invalidateSub)
|
|
return subs, nil
|
|
}
|
|
|
|
// Observe records locally and, if new/extended, broadcasts to peers. It returns
|
|
// whether the local index treated the assignment as new or extended, so Sync
|
|
// satisfies prefixcache.Provider.
|
|
//
|
|
// The LOCAL record happens first and is never conditional on the broadcast. A
|
|
// hint this replica could not share is still a fact this replica learned, and
|
|
// collapsing the two would cost the observing replica its own affinity for the
|
|
// very prompts it is already serving.
|
|
//
|
|
// The broadcast is an ordinary Publish, like every other family on the carrier.
|
|
// An observation is bounded by construction: ExtractChain caps a chain at
|
|
// Config.MaxDepth blocks, so the event a frontend publishes has a known worst
|
|
// case, and core/application refuses to start a deployment whose configured
|
|
// depth would push that worst case past what a notification can carry. Nothing
|
|
// here drops a message to stay under the cap: a drop would cost peers their
|
|
// affinity silently, and the startup refusal says so instead.
|
|
func (s *Sync) Observe(model string, chain []uint64, key ReplicaKey, now time.Time) bool {
|
|
changed := s.idx.Observe(model, chain, key, now)
|
|
if changed && s.bus != nil {
|
|
ev := messaging.PrefixCacheObserveEvent{Model: model, Chain: chain, NodeID: key.NodeID, Replica: key.Replica}
|
|
if err := s.bus.Publish(messaging.SubjectPrefixCacheObserve, ev); err != nil {
|
|
xlog.Debug("prefixcache: observe publish failed", "error", err)
|
|
}
|
|
}
|
|
return changed
|
|
}
|
|
|
|
// Invalidate drops the local entry for one replica and broadcasts to peers. The
|
|
// local drop is a no-op for models that were never cached (Index.Invalidate does
|
|
// not intern a tree). The broadcast is UNCONDITIONAL (when a carrier is
|
|
// configured): the registry chokepoint fires for every replica removal, and a
|
|
// peer frontend may hold a stale entry for the model even when THIS frontend
|
|
// never cached it, so gating the broadcast on local-tree existence would drop
|
|
// cross-frontend invalidations and leave peers routing to a removed replica
|
|
// until their TTL.
|
|
func (s *Sync) Invalidate(model string, key ReplicaKey) {
|
|
s.idx.Invalidate(model, key)
|
|
if s.bus != nil {
|
|
ev := messaging.PrefixCacheInvalidateEvent{Model: model, NodeID: key.NodeID, Replica: key.Replica}
|
|
if err := s.bus.Publish(messaging.SubjectPrefixCacheInvalidate, ev); err != nil {
|
|
xlog.Debug("prefixcache: invalidate publish failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// InvalidateNode drops the local entries for ALL replicas of node and broadcasts
|
|
// to peers. Like Invalidate the broadcast is unconditional for cross-frontend
|
|
// coherence. A negative Replica on the wire means "all replicas of the node".
|
|
func (s *Sync) InvalidateNode(model, node string) {
|
|
s.idx.InvalidateNode(model, node)
|
|
if s.bus != nil {
|
|
ev := messaging.PrefixCacheInvalidateEvent{Model: model, NodeID: node, Replica: -1}
|
|
if err := s.bus.Publish(messaging.SubjectPrefixCacheInvalidate, ev); err != nil {
|
|
xlog.Debug("prefixcache: invalidate-node publish failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// DropNode drops the local entries for ALL replicas of nodeID in every model.
|
|
//
|
|
// It does NOT broadcast, and that is the one place it parts company with its
|
|
// per-model siblings. A departure is decided by the health monitor, which runs
|
|
// under an advisory lock so exactly one replica reaches this; the peers keep
|
|
// their entries for a node that the same decision has just marked unhealthy in
|
|
// the database, and selection filters on healthy status in SQL, so those
|
|
// entries cannot route work to the departed node and expire on their own TTL.
|
|
// Inventing a node-wide invalidation event to carry this would add a wire
|
|
// meaning whose only reader is a set of entries that are already unreachable.
|
|
func (s *Sync) DropNode(nodeID string) {
|
|
s.idx.DropNode(nodeID)
|
|
}
|
|
|
|
// ApplyObserve applies a peer observe event locally (no re-broadcast).
|
|
func (s *Sync) ApplyObserve(ev messaging.PrefixCacheObserveEvent, now time.Time) {
|
|
s.idx.Observe(ev.Model, ev.Chain, ReplicaKey{NodeID: ev.NodeID, Replica: ev.Replica}, now)
|
|
}
|
|
|
|
// ApplyInvalidate applies a peer invalidate event locally (no re-broadcast). A
|
|
// negative Replica targets all replicas of the node.
|
|
func (s *Sync) ApplyInvalidate(ev messaging.PrefixCacheInvalidateEvent) {
|
|
if ev.Replica < 0 {
|
|
s.idx.InvalidateNode(ev.Model, ev.NodeID)
|
|
return
|
|
}
|
|
s.idx.Invalidate(ev.Model, ReplicaKey{NodeID: ev.NodeID, Replica: ev.Replica})
|
|
}
|
|
|
|
// Decide delegates to the wrapped index.
|
|
func (s *Sync) Decide(model string, chain []uint64, candidates []ReplicaKey, now time.Time) PrefixDecision {
|
|
return s.idx.Decide(model, chain, candidates, now)
|
|
}
|
|
|
|
// Evict delegates eviction of expired entries to the wrapped index. It does not
|
|
// broadcast: each frontend evicts its own copy on its own TTL clock.
|
|
func (s *Sync) Evict(now time.Time) { s.idx.Evict(now) }
|