Files
LocalAI/core/services/cluster/sessions.go
Ettore Di Giacinto 5759b4584b fix(distributed): let a worker's own refusal be evidence about its backend
A worker that refuses a stream has answered, and cluster.Dial keeps the three
tunnelproto sentinels out of the ErrNoRoute umbrella precisely so a consumer
can act on that. No consumer did. Since workers stopped listening, a backend
process that crashed on a healthy worker is no longer a dead listener's
codes.Unavailable: the worker refuses the stream with
ErrStreamTargetUnavailable, gRPC flattens it into Unavailable anyway, and
nodes.unroutable reported the whole thing as "this frontend has no route".
Every reap path then answered ProbeUnknown and left the row, so the replica
slot never freed and at the default MaxReplicasPerModel=1 the only cleanup
left was LRU eviction of models that were working.

isWorkerAnswer is exported as cluster.IsWorkerAnswer, so the errors the dialer
keeps out of the umbrella are by construction the errors the consumers treat
as the worker answering. nodes.unroutable and pkg/model's transportFailure
both use it; ConnectionEvictingClient, the site reached during inference, goes
through transportFailure rather than asking the transport directly. A reply
code this frontend does not recognise is still not an answer, so a newer
worker's vocabulary costs a retry and not a replica.

The reap guards keep the allow-list rather than requiring ErrNoRoute: an
unrecognised dial error must mean "no route", never "the backend is gone".

Also in this final pass over the branch:

- Docs: recommend upgrading FRONTENDS first, with the symptom of each order.
  Workers-first fails now that a 4xx registration is a verdict rather than an
  outage, so an old frontend's "address is required for backend workers" makes
  each restarted worker exit and drains the fleet a node per restart.
- Docs: LOCALAI_WORKER_TUNNEL=false is a fatal startup error, not a degraded
  mode, in both places that described it; and a frontend rollback needs every
  worker restarted, because re-registration force-clears the address columns.
- A replica with no advertised address now says so every five minutes and
  names the workers only it can reach, instead of one startup warning for a
  cost paid for the life of the process.
- callerRanOut's rule now holds at all three siblings, so an expired caller
  deadline stops reading as a broken tunnel; probeHealth's withdrawn reason
  for using the raw client is corrected; the dead DoOrCached is deleted and
  its coverage kept on DoOrCachedResult; sweepLeakedInFlight enumerates the
  outcomes that reach it.
- The peer route's self-declared id is recorded as a phase-3 deferral, in the
  handler, in the isolation claim it narrows, and in the operator docs.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
2026-09-20 03:05:34 +00:00

154 lines
5.4 KiB
Go

// SPDX-License-Identifier: MIT
package cluster
import (
"net"
"sync"
"github.com/libp2p/go-yamux/v5"
"github.com/mudler/xlog"
)
// SessionStore holds the peer links this replica has ACCEPTED, which is the
// mirror image of PeerPool: the pool owns the sessions this replica dialled,
// this owns the ones its peers dialled into it.
//
// Something has to own an accepted session. The HTTP handler cannot: it returns
// as soon as the upgrade is done, and the hijacked connection outlives it. And
// something has to accept the streams that arrive on it, because yamux only
// acknowledges a stream once the far side accepts it, so a session nobody
// accepts on does not fail a peer's Open, it hangs it.
type SessionStore struct {
// onStream handles one accepted stream and owns closing it. A nil handler
// closes the stream immediately, which is what a replica with no relay
// installed should do: refuse promptly rather than leave a peer parked.
//
// In distributed mode this is Relay.Stream, which splices the stream onto
// a worker tunnel this replica holds. Nil is reached only from specs, and
// from a caller that wants a store with no relay.
onStream func(peerID string, stream net.Conn)
mu sync.Mutex
sessions map[string]*yamux.Session
closed bool
}
// NewSessionStore returns a store whose accepted streams are handled by
// onStream. Pass nil to refuse every stream, closing it at once.
func NewSessionStore(onStream func(peerID string, stream net.Conn)) *SessionStore {
return &SessionStore{onStream: onStream, sessions: map[string]*yamux.Session{}}
}
// Accept takes ownership of a session a peer dialled in. It is the callback
// shape RegisterClusterRoutes wants, and it returns promptly: the serving loop
// runs on its own goroutine, because the handler's return is what completes the
// hijack.
func (s *SessionStore) Accept(peerID string, sess *yamux.Session) {
if sess == nil {
return
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
// Shutdown raced the dial. Leaving the session open would keep the peer
// believing it has a live link into a process that is going away.
_ = sess.Close()
return
}
previous := s.sessions[peerID]
s.sessions[peerID] = sess
s.mu.Unlock()
// A peer that dials again has lost its previous link, whether or not this
// side has noticed. Keeping both would leave a session nothing can ever be
// routed to, since the map holds one per peer.
if previous != nil {
xlog.Debug("cluster peer re-dialled, dropping its previous link", "peer", peerID)
_ = previous.Close()
}
go s.serve(peerID, sess)
}
// Get returns the session this replica accepted from peerID. The second result
// is false when no link from that peer is held, which a caller must not read as
// the peer being absent: it may be about to dial, or dialling this replica may
// simply not be its job.
//
// It has NO production caller, and that is stated rather than left to be
// discovered: nothing in the frontend routes by looking up an inbound link,
// because the relay is driven by the streams a peer opens on the session, not
// by this side going to find one. What Get exists for is the specs, which have
// no other way to observe which session this store holds, and holding exactly
// one session per peer is the property Accept's eviction is about. Deleting it
// would delete that observation with it. Anything tempted to route on it should
// read the paragraph above first: a missing entry is not an absent peer.
func (s *SessionStore) Get(peerID string) (*yamux.Session, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[peerID]
return sess, ok
}
// serve accepts streams until the session dies, then forgets it.
func (s *SessionStore) serve(peerID string, sess *yamux.Session) {
defer func() {
s.forget(peerID, sess)
_ = sess.Close()
}()
for {
stream, err := sess.AcceptStream()
if err != nil {
// A peer link ending is ordinary: a rolling update closes every
// session it holds. The error is the session's, not one stream's,
// so there is nothing to recover to.
xlog.Debug("cluster peer link ended", "peer", peerID, "error", err)
return
}
if s.onStream == nil {
// No relay installed. Closing is deliberate and is not the same as
// ignoring: a stream nobody answers parks the peer's request until
// its own deadline, and reports nothing about why.
xlog.Debug("cluster peer stream refused: no relay installed", "peer", peerID)
_ = stream.Close()
continue
}
// One goroutine per stream: the handler relays a whole request, and
// serving them from the accept loop would let one request stall every
// other stream on the link.
go s.onStream(peerID, stream)
}
}
// forget drops the entry only if it still names this session. A peer that
// re-dialled has already replaced it, and deleting blindly would evict the live
// link when the old one finally noticed it was dead.
func (s *SessionStore) forget(peerID string, sess *yamux.Session) {
s.mu.Lock()
defer s.mu.Unlock()
if s.sessions[peerID] == sess {
delete(s.sessions, peerID)
}
}
// CloseAll drops every held session. An Accept after it closes the session
// rather than storing it, so a dial racing shutdown cannot leak a link.
func (s *SessionStore) CloseAll() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
held := s.sessions
s.sessions = map[string]*yamux.Session{}
s.mu.Unlock()
for _, sess := range held {
_ = sess.Close()
}
}