Files
LocalAI/core/services/cluster/peerlink.go
T
Ettore Di Giacinto 778c97aaf7 fix(cluster): stop blaming a peer for the caller's own expired deadline
Review round 1 on the end-to-end proof. Zero blocking items, eleven
non-blocking, and three of them turned out to be production defects rather
than notes on the report.

The one that matters is a misclassification the phase is built to prevent. A
dial carries the caller's deadline down to the socket, so when the budget runs
out the socket's timer fires and the error travels back up through the
WebSocket handshake and the multiplexer. The context's cancellation is a
separate timer whose func the scheduler has to run before ctx.Err() stops
returning nil, and nothing orders the two. Under contention the socket's error
is back in PeerPool.Open first, ctx.Err() reads nil, and a peer that is
listening and healthy is reported as ErrPeerUnreachable to a caller that simply
ran out of time. An unreachable peer is a fact a caller may act on and an
expired deadline is not, and core/services/nodes routes around a replica it is
told is unreachable.

callerRanOut answers that question in one place: ctx.Err() when it is set, and
otherwise the wall clock against the caller's own deadline. That is sound
because it is the same instant the socket compared itself against, so if the
socket's timer fired this comparison is past it too. The ambiguous instant
resolves towards the caller, which is the direction that never blames a peer.

The spec that caught it, peerlink_test.go's "blames the caller's deadline",
was red in three of seven -race runs and had been since Task 5, which is often
enough to read as noise and is why single-run verification never saw it. Rather
than leave the proof to a coin flip, a second spec makes the window
deterministic: Open is handed a context whose deadline has passed and whose
cancellation has not been delivered, against an address nothing is listening
on, so the dial fails for real. It reddens without the fix.

The peer link's yamux windows were applied to one end only. A receive window is
advertised by the side that RECEIVES, so configuring the dialler alone tunes
exactly one direction, and the direction left on the 256 KiB default is the one
that carries a relayed model artifact INTO the replica that owns the worker's
tunnel. That is the largest thing the link ever moves and it is the direction
the load measurement exercises: the review read it as flowing toward the
dialler and it does not. PeerLinkConfig is now exported and used on both ends.
Measured, same box, 128 MiB staged through the relay against the same transfer
without one: the relayed path cost 1.6x to 2.0x the direct path's transfer
window before, and 1.06x to 1.25x after.

The SSRF reachability spec could be fooled into reporting an SSRF that did not
happen. It bound the victim on 127.0.0.2 at an ephemeral port and required
127.0.0.1 at the same port to refuse, so any other spec in the run holding that
number made the dial succeed; red one run in seven, green five of five in
isolation. It now picks from below the kernel's ephemeral range, the same fix
the harness got for the adjacent-port collision.

The rest are the specs and the report saying what they mean.

Scenario 1's advertisement assertion could not tell "the worker advertises
nothing" from "the JSON key moved", which matters because removing the
advertisement is the change it covers. It was green against a renamed key. The
roster now keeps the raw key set beside the decoded fields and the spec
requires both keys present before reading them as empty.

Scenario 4's refusal-body check was a four-way disjunction admitting bare
"tunnel", "not connected" and "unroutable". Those alternatives were inert and
each would be satisfied by refusals that say nothing about routing, in the one
assertion the whole negative control rests on. It is "no route" alone.

The head-of-line gate bounded the worst probe by the whole transfer window,
which admits about eightfold degradation and loosens as the box slows. It is
now half the window, plus a scale-free ratio against the worst probe under the
SAME cold load with nothing to transfer, which is the control that isolates the
transfer from the load. Not tighter than that, and the reason is measured
rather than cautious: under a concurrent -race suite the worst relayed probe
reached a fifth of its window, so a quarter-window gate would have had 1.2x of
margin, and a spec that fails one run in three is worse than no spec.

The report entry printed p90 and p99 off samples of twenty, where both land on
the same element and p99 often lands on the max, so one number appeared three
times under three names. A quantile is now printed only when the sample can
separate it.

Two claims in the report were wrong and are withdrawn rather than softened.
Scenario 2's race is closed by the trailing re-read of the owner, not by the
pre-assertion the report credited: a move to the non-owner mid-request would
serve directly and still return 200, and only the trailing read reddens on it.
And "the median request is unchanged" holds on this box and not on the
reviewer's, where the relayed median rises up to 82% and p99 up to 3.5x. What
survives on both is structural: the worst probe is a small fraction of the
window in which bytes are moving, so the session interleaves rather than
serialising. Sharing a session with a bulk transfer costs latency; it does not
cost service.

The disk footprint note undercounted, and the reviewer lost a run to a full
disk on this box, so it is worth having right: two bulk models seeded into two
frontends and staged to the worker is about 768 MiB, not 512 MiB.

Left alone deliberately: the worker's backend port allocator still hands out
ports without checking they are free, and its default range still overlaps the
kernel's ephemeral range. It is confirmed, it is out of scope here, and it is
being tracked as a named follow-up rather than fixed under an e2e task.

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

396 lines
15 KiB
Go

package cluster
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/libp2p/go-yamux/v5"
"github.com/mudler/xlog"
)
// PeerPath is the route a replica dials to open a peer link, and the route the
// HTTP layer registers the handler on. It lives here, with the dialler and the
// WebSocket adapter, so that core/services/cluster stays a leaf: the HTTP
// endpoints package imports this one, never the other way round.
//
// The literal is spelled out rather than derived from auth.ClusterPathPrefix
// because importing core/http/auth is exactly the dependency this package must
// not have. The two are kept from drifting apart by a spec in the endpoints
// package, which can see both.
const PeerPath = "/api/cluster/peer"
// ErrPeerUnreachable reports that a peer this deployment knows about could not
// be reached: the dial failed, the peer refused the credentials, or its
// multiplexer would not carry a stream.
//
// It is deliberately NOT a form of ErrInstanceNotFound, and the two must stay
// unmixable. A caller that sees absence is entitled to conclude a node is gone
// and reclaim what it was running; a caller that sees unreachability may only
// retry. Collapsing the two means a network hiccup between two healthy
// replicas evicts healthy workers. See unreachableError for how that is
// enforced rather than merely documented.
var ErrPeerUnreachable = errors.New("cluster: peer unreachable")
// unreachableError reports a peer that could not be reached, keeping the
// underlying cause in its message and out of its unwrap chain.
//
// Withholding the cause from errors.Is is the point. The dial path resolves a
// peer's address through the registry, so ErrInstanceNotFound is a cause this
// error can genuinely be built over: a row deleted between two attempts, for
// one. If the cause were unwrapped, that failure would satisfy both sentinels
// at once and every caller's absence check would fire on a transport problem.
// The guarantee therefore belongs to the type: no call site can leak absence
// through it, because there is no path by which absence gets out.
type unreachableError struct {
peerID string
cause error
}
func (e *unreachableError) Error() string {
return fmt.Sprintf("cluster: peer %q unreachable: %v", e.peerID, e.cause)
}
// Unwrap reports only ErrPeerUnreachable. The cause reaches a human through
// Error() and reaches no error-matching caller at all.
func (e *unreachableError) Unwrap() error { return ErrPeerUnreachable }
func unreachablePeer(peerID string, cause error) error {
return &unreachableError{peerID: peerID, cause: cause}
}
// ErrPoolClosed reports an Open on a pool that has been shut down. It is a
// third condition on purpose: the pool being closed is a fact about this
// process and says nothing about whether the peer exists or answers.
var ErrPoolClosed = errors.New("cluster: peer pool is closed")
const (
// peerLinkHandshakeTimeout bounds the WebSocket upgrade. It also bounds
// how long Close can wait behind an in-flight dial, since a dial holds the
// per-peer lock Close needs to reach the cached session.
peerLinkHandshakeTimeout = 10 * time.Second
// peerLinkInitialWindow is the per-stream receive window every stream on a
// peer link starts at, raised from yamux's 256 KiB default.
//
// yamux already bounds head-of-line blocking with MaxMessageSize (64 KiB
// by default), so one stream cannot monopolise the connection whatever the
// window is. What the small default costs is the ramp: a stream carrying a
// multi-megabyte gRPC message spends its first megabytes window-parked,
// paying a round trip per doubling (stream.go:229) before it reaches full
// rate. On a link that is also carrying token streams for other workers,
// that ramp is pure added latency on the bulk transfer for no benefit.
peerLinkInitialWindow = 4 * 1024 * 1024
// peerLinkMaxWindow is the ceiling the auto-tuner may grow a stream to,
// raised from yamux's 16 MiB default to cover the bandwidth-delay product
// of a fast cross-zone link (roughly 31 MiB at 10 Gbps and 25 ms).
//
// The window is a cap on data received but not yet read, so the worst case
// a peer can make this replica buffer is MaxIncomingStreams times this.
// At yamux's default MaxIncomingStreams of 1000 that ceiling goes from
// about 15.6 GiB to about 31 GiB per peer session, which is the figure to
// size a replica against; it is why MaxIncomingStreams is left at the
// default rather than raised alongside the window. Both are ceilings on
// unread data and not allocations: yamux grows a stream's receive buffer
// as data arrives.
peerLinkMaxWindow = 32 * 1024 * 1024
)
// PeerLinkConfig returns the yamux configuration for a replica-to-replica link.
//
// Exported because BOTH ENDS need it and only one of them lives here. A yamux
// receive window is advertised by the side that RECEIVES, so a link configured
// on the dialler alone is tuned in exactly one direction: bytes travelling from
// the accepting replica back to the dialler get these windows, and bytes
// travelling the other way get yamux's defaults. The other way is the one that
// carries a relayed model artifact to the replica that owns the worker's
// tunnel, which is the largest thing this link ever moves.
//
// A fresh config per call, never a shared one: yamux keeps the pointer for the
// life of the session, and two sessions sharing one struct would share whatever
// a future field on it comes to mean.
func PeerLinkConfig() *yamux.Config {
cfg := yamux.DefaultConfig()
cfg.InitialStreamWindowSize = peerLinkInitialWindow
cfg.MaxStreamWindowSize = peerLinkMaxWindow
return cfg
}
// PeerPool dials peer replicas and keeps one multiplexed session per peer.
//
// A peer link carries traffic for every worker that peer owns, so it is pooled
// rather than dialled per request: a dial per relayed request would add a
// WebSocket handshake to every inference.
//
// The pool needs no knowledge of yamux error shapes to keep its cache honest.
// The two conditions worth reacting to arrive as OpenStream failures and are
// handled by the same retry: a peer that shut down gracefully hands its
// session ErrRemoteGoAway and closes it, and a session whose transport died
// hands out its shutdown error. Conditions scoped to a single stream, such as
// a peer resetting one request, never reach the pool at all, which is right:
// dropping the session over one reset request would tear down every other
// worker's traffic on that link.
type PeerPool struct {
selfID string
token string
reg *Registry
dialer *websocket.Dialer
mu sync.Mutex
links map[string]*peerLink
closed bool
}
// peerLink is the cached session for one peer, plus the lock that serialises
// dialling it. The lock is per-peer so a slow or hanging dial to one peer does
// not hold up opens to any other.
type peerLink struct {
mu sync.Mutex
sess *yamux.Session
}
// NewPeerPool returns a pool that dials peers as selfID, authenticating with
// the deployment's cluster token.
func NewPeerPool(selfID, token string, reg *Registry) *PeerPool {
return &PeerPool{
selfID: selfID,
token: token,
reg: reg,
dialer: &websocket.Dialer{
HandshakeTimeout: peerLinkHandshakeTimeout,
// No Proxy: a peer link is replica-to-replica inside one
// deployment, and honouring HTTP_PROXY would route it through
// whatever egress proxy the environment happens to name.
},
links: map[string]*peerLink{},
}
}
// Open returns a stream to peerID, dialling and caching the session on first
// use.
//
// The errors are three distinct conditions and callers act differently on
// them: ErrInstanceNotFound means the peer is not part of this deployment,
// ErrPeerUnreachable means it is but will not answer, and ErrPoolClosed means
// this process is shutting down. Only the first is node absence.
func (p *PeerPool) Open(ctx context.Context, peerID string) (net.Conn, error) {
l, err := p.link(peerID)
if err != nil {
return nil, err
}
l.mu.Lock()
defer l.mu.Unlock()
if l.sess != nil {
st, err := l.sess.OpenStream(ctx)
if err == nil {
return st, nil
}
// A caller whose own budget expired must not cost every other worker
// its link: the session is fine, this request is not.
if ctxErr := callerRanOut(ctx); ctxErr != nil {
return nil, ctxErr
}
// A session that died between calls is the common case, not an
// exception, so this is a debug line and not a warning.
xlog.Debug("cluster peer link session unusable, re-dialling", "peer", peerID, "error", err)
_ = l.sess.Close()
l.sess = nil
}
sess, err := p.dial(ctx, peerID)
if err != nil {
// Same rule as above, on the path that has no cached session to
// protect: a dial that ran out of the caller's time says nothing about
// the peer, which may be listening and perfectly healthy. Blaming it
// would let one impatient client get a good replica routed around.
//
// This also swallows a genuine ErrInstanceNotFound when the budget
// happened to expire at the same moment, which is the safe direction:
// a timeout must never be able to manufacture absence.
if ctxErr := callerRanOut(ctx); ctxErr != nil {
return nil, ctxErr
}
return nil, err
}
st, err := sess.OpenStream(ctx)
if err != nil {
// The peer answered and completed a handshake but will not carry a
// stream, which is a transport condition and never absence.
_ = sess.Close()
if ctxErr := callerRanOut(ctx); ctxErr != nil {
return nil, ctxErr
}
return nil, unreachablePeer(peerID, err)
}
l.sess = sess
return st, nil
}
// callerRanOut reports whether the CALLER's budget is what ended an attempt,
// and is the one place that question is answered.
//
// ctx.Err() alone is not that question, and the difference is a real
// misclassification rather than a nicety. A dial carries the caller's deadline
// down to the socket, so when the budget runs out the socket's own timer fires
// and the error travels back up through the WebSocket handshake and the
// multiplexer. The context's cancellation is a SEPARATE timer whose func has to
// be run by the scheduler before ctx.Err() stops returning nil, and nothing
// orders the two. Under contention the socket's error can be back here first,
// ctx.Err() reads nil, and a peer that is listening and healthy is reported as
// ErrPeerUnreachable to a caller that simply ran out of time.
//
// That is the exact confusion this package refuses everywhere else: an
// unreachable peer is a fact about the peer that a caller may act on, and an
// expired deadline is a fact about the caller that it may not. The wall clock
// settles it without waiting for a goroutine, because the deadline is the same
// instant the socket compared itself against: if the socket's timer fired, this
// comparison is past it too.
//
// A context with no deadline falls through to ctx.Err(), which is the whole
// answer for cancellation: a Canceled context has already had its error set by
// the caller of cancel, with no timer in between.
func callerRanOut(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return err
}
// Not time.Now().After: a failure at exactly the deadline is the caller's
// too, and the ambiguous instant is resolved towards never blaming a peer.
if deadline, ok := ctx.Deadline(); ok && !time.Now().Before(deadline) {
return context.DeadlineExceeded
}
return nil
}
// link returns the per-peer entry, creating it on first use.
//
// Entries are never pruned: a peer id opened once keeps its entry, and any
// session cached on it, until Close. The cost is not the map entry. A peer that
// has left the deployment but is still listening keeps a live WebSocket and the
// two yamux loop goroutines behind it for as long as this process runs; a peer
// that is genuinely gone is reclaimed by the 30s keepalive default, so the real
// exposure is narrow. There is no Forget: the membership sweep DELETES departed
// replicas but reports only how many, so which ones they were would have to be
// surfaced before anything could be plumbed here. Until it is, an entry for a
// departed peer outlives it and only the keepalive reclaims what it holds.
func (p *PeerPool) link(peerID string) (*peerLink, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.closed {
return nil, ErrPoolClosed
}
l, ok := p.links[peerID]
if !ok {
l = &peerLink{}
p.links[peerID] = l
}
return l, nil
}
// dial resolves the peer's advertised address and brings up one yamux client
// session over an authenticated WebSocket.
//
// A registry miss is returned unchanged so ErrInstanceNotFound reaches the
// caller; everything after it is wrapped as unreachable.
//
// The address is only read here, so a peer that re-registers on a new address
// while its current session is still alive keeps being reached over that
// session until it dies. That is deliberate: an address change without a
// session break means the peer is still answering on the old one, and dropping
// a working link to chase a registry write would interrupt live requests for
// nothing. A replica that actually moved breaks its sessions in the process,
// and the re-dial above picks the new address up on the next Open.
func (p *PeerPool) dial(ctx context.Context, peerID string) (*yamux.Session, error) {
inst, err := p.reg.Get(ctx, peerID)
if err != nil {
return nil, err
}
if inst.AdvertisedAddr == "" {
// A registered replica with no address is reachable by nobody. It is
// present, so this is not absence.
return nil, unreachablePeer(peerID, errors.New("peer has no advertised address"))
}
endpoint := url.URL{
// Plain ws: replica-to-replica TLS is not part of this phase, and the
// link is authenticated by the cluster token rather than by transport.
Scheme: "ws",
Host: inst.AdvertisedAddr,
Path: PeerPath,
RawQuery: url.Values{"id": []string{p.selfID}}.Encode(),
}
header := http.Header{}
header.Set("Authorization", "Bearer "+p.token)
ws, resp, err := p.dialer.DialContext(ctx, endpoint.String(), header)
if resp != nil && resp.Body != nil {
// gorilla hands back the failed handshake's response so a caller can
// read the status; nothing here needs the body, but it has to be
// drained or the connection is not returned to the transport.
_ = resp.Body.Close()
}
if err != nil {
return nil, unreachablePeer(peerID, err)
}
// Client side of the mux: the dialling replica owns the odd stream IDs,
// matching the yamux.Server the peer handler puts on its end.
sess, err := yamux.Client(WebsocketConn(ws), PeerLinkConfig(), nil)
if err != nil {
_ = ws.Close()
return nil, unreachablePeer(peerID, err)
}
// Close raced this dial. Handing the session back would leak it, since
// Close has already walked the map.
p.mu.Lock()
closed := p.closed
p.mu.Unlock()
if closed {
_ = sess.Close()
return nil, ErrPoolClosed
}
xlog.Debug("cluster peer link dialled", "peer", peerID, "addr", inst.AdvertisedAddr)
return sess, nil
}
// Close closes every cached session. It is safe to call twice, and an Open
// after it reports ErrPoolClosed rather than anything a caller could read as
// node absence.
func (p *PeerPool) Close() {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return
}
p.closed = true
links := p.links
p.links = nil
p.mu.Unlock()
// Each session is closed under its own peer lock rather than under p.mu,
// so closing the pool cannot deadlock against an Open that is mid-dial and
// about to take p.mu to re-check p.closed.
for _, l := range links {
l.mu.Lock()
if l.sess != nil {
_ = l.sess.Close()
l.sess = nil
}
l.mu.Unlock()
}
}