mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-15 07:39:20 -04:00
The ten backend and model lifecycle verbs stop being NATS requests and become HTTP calls on the worker's own control routes, reached through that worker's tunnel on the `http` stream tag that already carries file staging. Nine subject builders and the per-op install-progress subject are deleted with their entries in the worker's NATS permissions; the request and reply DTOs are untouched, so a body on the wire is byte for byte what the subject carried. This closes the merge gate Task 3 left open, which was worse than lost commands. Once the worker stopped subscribing, PingNode was still asking nodes.<id>.backend.list and nodes.<id>.models.running, so EVERY healthy worker answered no-responders, nodeAnswersOnBus read it as absence and pickReachableNode demoted it on the scheduling path. PingNode is a control RPC now, and no control RPC can produce ErrNoResponders, which is the only error that exclusion acts on. Two specs drive pickReachableNode against a real adapter and a worker answering over its control plane, which is the only arrangement that can see the difference: the router's own double never touches a transport and stayed green for the whole window the defect was live. How a control RPC FAILS is the whole of this change, so it is decided in ONE function reading ONE table. A worker's answer passes through unwrapped, so cluster.IsWorkerAnswer still sees it and a reap guard may act on it; everything else is wrapped in ErrWorkerUnroutable so nothing can. There is no third branch, because a third branch is how the eight collapses on this branch happened: each was a site that decided for itself which errors were evidence. A 404 under the prefix is its own sentinel, because it is the worker stating a deployment fact about ITSELF rather than a verdict about a backend, and only the legacy upgrade fallback may act on it. The caller's budget is checked FIRST. A timeout is not a verdict: a refusal arriving in the instant a deadline expires would otherwise be reported as the worker's non-transient answer, which reaps a row, and nothing orders the two timers. A 5xx and an undecodable body are transport failures, not answers. An empty ModelsRunningReply means "this worker is running nothing", which the reconciler acts on, so it must never be manufactured from a body that would not parse. A stream that ends before its reply line is the same rule one layer up: a tunnel dying mid-install is not the worker saying the install failed. backend.stop is split by node type rather than moved. Agent workers hold no tunnel, so they have no control plane to serve, and they still subscribe to nodes.<id>.backend.stop to drop cached MCP sessions; that subject and its agent permission both survive. It is the honest intermediate state until agent workers hold tunnels too. A failed control RPC no longer demotes a node anywhere. ErrNoResponders meant "not on the bus"; a control failure means "this frontend could not route to it", which is equally what a healthy worker re-homing its tunnel between replicas produces. Absence is a fact read from the database, and the scheduler starts reading it in a later task. The rolling-update fallback re-fires a DESTRUCTIVE force-reinstall, so it runs only on the worker's own 404. Its negative direction was pinned at the admin call site and unpinned at the reconciler's, where widening the condition to any error left all 676 specs green: a background drain nobody is watching would then force-reinstall every queued backend the moment a replica lost its tunnels. Three specs cover it, arranged so the force install IS reachable in the negative case and a fallback that fired would show as a call and a drained row. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
308 lines
13 KiB
Go
308 lines
13 KiB
Go
package nodes
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mudler/xlog"
|
|
|
|
"github.com/mudler/LocalAI/core/services/cluster"
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/LocalAI/core/services/workerctl"
|
|
"github.com/mudler/LocalAI/pkg/httpclient"
|
|
)
|
|
|
|
// ErrWorkerControlUnsupported reports that the worker answered, and does not
|
|
// serve that control verb.
|
|
//
|
|
// It is a deployment fact and not a verdict about anything: the worker is
|
|
// running a build older than this frontend, so a path this frontend knows is a
|
|
// 404 there. The one caller that may act on it is the upgrade fallback, which
|
|
// re-issues the legacy force-install the older build does understand.
|
|
//
|
|
// It is a SPECIALISATION of ErrWorkerUnroutable for the same reason
|
|
// ErrNoWorkerDialer is: everything that DELETES a node_models row makes exactly
|
|
// one check, and a 404 must not pass it. The worker spoke, but it said nothing
|
|
// about any backend, and reaping on it would evict healthy work over a version
|
|
// skew. Callers that want the fallback match this sentinel specifically, which
|
|
// ErrWorkerUnroutable does not imply in that direction.
|
|
var ErrWorkerControlUnsupported = fmt.Errorf("%w: the worker does not serve that control verb", ErrWorkerUnroutable)
|
|
|
|
// ControlClient issues the frontend's control RPCs to a worker over that
|
|
// worker's tunnel.
|
|
//
|
|
// It is the frontend half of core/services/workerctl: the worker mounts those
|
|
// paths on the loopback HTTP server it already runs, and this reaches them
|
|
// through the `http` stream tag, so a control RPC to a worker another replica
|
|
// holds is relayed exactly like an inference request.
|
|
//
|
|
// There is one of these per frontend rather than one per verb, because the
|
|
// mapping from a failed RPC onto the four conditions this system must never
|
|
// confuse belongs in ONE place. See controlFailure.
|
|
type ControlClient struct {
|
|
// dialFor supplies the transport for one worker. It is per node because a
|
|
// worker is reached over ITS OWN tunnel and an http.Transport carries one
|
|
// DialContext, so a single shared transport could only ever reach one
|
|
// worker. nil means no tunnel dialer is wired and every call is refused;
|
|
// see ErrNoWorkerDialer for why that is not a fallback to a direct dial.
|
|
dialFor WorkerNetDialerFor
|
|
token string
|
|
|
|
// clients caches one *http.Client per node, so a verb issued twice in a
|
|
// row reuses the tunnel stream its transport already holds instead of
|
|
// opening a new one.
|
|
//
|
|
// Entries are never pruned, and that is judged rather than overlooked: the
|
|
// map is bounded by the number of distinct workers this frontend has ever
|
|
// commanded, which is bounded by the fleet, and a departed worker's entry
|
|
// holds a map slot plus a transport whose idle connections IdleConnTimeout
|
|
// reclaims. It is the same shape, and would need the same missing
|
|
// node-departure signal to fix, as HTTPFileStager.clients.
|
|
clientsMu sync.Mutex
|
|
clients map[string]*http.Client
|
|
}
|
|
|
|
// NewControlClient returns the control client for the workers dialFor can
|
|
// reach, authenticating with the deployment's registration token.
|
|
func NewControlClient(dialFor WorkerNetDialerFor, token string) *ControlClient {
|
|
return &ControlClient{dialFor: dialFor, token: token, clients: map[string]*http.Client{}}
|
|
}
|
|
|
|
// clientFor returns the HTTP client that reaches one worker, building it on
|
|
// first use.
|
|
//
|
|
// The transport mirrors HTTPFileStager.clientFor, which is the other consumer
|
|
// of a worker's own HTTP server, and differs only where control traffic
|
|
// differs. HTTP/2 stays OFF for the reason it always was: its flow control
|
|
// stalls large transfers, and the install verb streams for minutes. The
|
|
// stager's 256 KB socket buffers are dropped because a control body is a
|
|
// handful of kilobytes and two 256 KB buffers per worker would be paid for
|
|
// every node in the fleet.
|
|
//
|
|
// The net.Dialer's connect timeout and keepalive have nothing to act on here:
|
|
// there is no TCP connect to time out, and liveness on the link is the yamux
|
|
// session's keepalive rather than the socket's. No client.Timeout is set
|
|
// either, because it would bound the response BODY, and the install verb's
|
|
// body stays open for as long as the install runs. What bounds a call is the
|
|
// context its caller passes.
|
|
func (c *ControlClient) clientFor(nodeID string) (*http.Client, error) {
|
|
if c == nil || c.dialFor == nil {
|
|
return nil, fmt.Errorf("control rpc to node %q: %w", nodeID, ErrNoWorkerDialer)
|
|
}
|
|
c.clientsMu.Lock()
|
|
defer c.clientsMu.Unlock()
|
|
if cl, ok := c.clients[nodeID]; ok {
|
|
return cl, nil
|
|
}
|
|
dial := c.dialFor(nodeID)
|
|
if dial == nil {
|
|
return nil, fmt.Errorf("control rpc to node %q: %w", nodeID, ErrNoWorkerDialer)
|
|
}
|
|
transport := &http.Transport{
|
|
DialContext: dial,
|
|
ForceAttemptHTTP2: false, // HTTP/2 flow control can stall a long streaming verb
|
|
MaxIdleConns: 10,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
}
|
|
cl := httpclient.New(httpclient.WithTransport(transport))
|
|
c.clients[nodeID] = cl
|
|
return cl, nil
|
|
}
|
|
|
|
// Call issues one control RPC and decodes its reply. reply may be nil for the
|
|
// verbs that answer 204.
|
|
//
|
|
// A reply that arrives with its Error field set is NOT an error here: the
|
|
// worker answered, and reading its answer is the caller's job. Only a failure
|
|
// to reach the worker, or an answer this frontend cannot read, comes back as an
|
|
// error, and every one of those is mapped by controlFailure.
|
|
func (c *ControlClient) Call(ctx context.Context, nodeID, path string, req, reply any) error {
|
|
resp, err := c.do(ctx, nodeID, path, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
if resp.StatusCode == http.StatusNoContent || reply == nil {
|
|
// A verb that answers 204 sends no body at all; a caller that asked for
|
|
// no reply may still have been sent one, and draining it is what lets
|
|
// the transport keep the tunnel stream for the next verb instead of
|
|
// tearing it down.
|
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxControlErrorBodyBytes))
|
|
return nil
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(reply); err != nil {
|
|
// A body this frontend cannot read is not the worker's verdict about
|
|
// anything, so it must not be reported as one.
|
|
return controlFailure(ctx, nodeID, fmt.Errorf("decoding the %s reply: %w", path, err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CallStreaming issues a control RPC whose response is an NDJSON envelope
|
|
// stream, invoking onProgress for each progress line and decoding the single
|
|
// terminal reply line into reply. onProgress may be nil.
|
|
//
|
|
// onProgress runs SYNCHRONOUSLY, on this goroutine. The NATS carrier ran each
|
|
// progress callback on a goroutine of its own because a slow callback there
|
|
// stalled the one reader thread every worker's events arrived on; here the only
|
|
// thing a slow callback holds up is this request's own body, which is the
|
|
// caller's business. Dropping the guard is also what makes the events arrive in
|
|
// the order the worker sent them.
|
|
func (c *ControlClient) CallStreaming(ctx context.Context, nodeID, path string,
|
|
req, reply any, onProgress func(messaging.BackendInstallProgressEvent)) error {
|
|
resp, err := c.do(ctx, nodeID, path, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
dec := json.NewDecoder(resp.Body)
|
|
var terminal json.RawMessage
|
|
for terminal == nil {
|
|
var env workerctl.Envelope
|
|
decErr := dec.Decode(&env)
|
|
if decErr != nil {
|
|
// EOF before a reply line is the tunnel dying mid-verb, and reading
|
|
// it as "the install failed" is precisely the collapse this phase
|
|
// exists to prevent: nothing was learned about the backend, so this
|
|
// is unroutable and a caller may not act on it.
|
|
if errors.Is(decErr, io.EOF) {
|
|
decErr = fmt.Errorf("the %s stream ended before its reply line: %w", path, io.ErrUnexpectedEOF)
|
|
}
|
|
return controlFailure(ctx, nodeID, decErr)
|
|
}
|
|
if env.Reply != nil {
|
|
terminal = env.Reply
|
|
break
|
|
}
|
|
if env.Progress == nil || onProgress == nil {
|
|
continue
|
|
}
|
|
var ev messaging.BackendInstallProgressEvent
|
|
if err := json.Unmarshal(env.Progress, &ev); err != nil {
|
|
// Progress is transient by contract, so a line this frontend cannot
|
|
// read costs a tick and never the operation.
|
|
xlog.Debug("unreadable control progress line", "node", nodeID, "path", path, "error", err)
|
|
continue
|
|
}
|
|
onProgress(ev)
|
|
}
|
|
if reply == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(terminal, reply); err != nil {
|
|
return controlFailure(ctx, nodeID, fmt.Errorf("decoding the %s reply line: %w", path, err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// do issues the request and returns the response for any status this frontend
|
|
// can read a body from. Every other outcome is already mapped.
|
|
//
|
|
// The caller owns closing the body.
|
|
func (c *ControlClient) do(ctx context.Context, nodeID, path string, req any) (*http.Response, error) {
|
|
client, err := c.clientFor(nodeID)
|
|
if err != nil {
|
|
// ErrNoWorkerDialer already carries ErrWorkerUnroutable, so it must not
|
|
// go through controlFailure, which would wrap the umbrella twice.
|
|
return nil, err
|
|
}
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, controlFailure(ctx, nodeID, fmt.Errorf("encoding the %s request: %w", path, err))
|
|
}
|
|
|
|
// The host is a name that resolves nowhere. What carries the request is the
|
|
// transport's DialContext, which opens a stream on this worker's tunnel;
|
|
// the host exists because an http.Request needs one, and it names the node
|
|
// so a log line is diagnosable. See WorkerHTTPHost.
|
|
url := "http://" + WorkerHTTPHost(nodeID, "") + path
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, controlFailure(ctx, nodeID, fmt.Errorf("building the %s request: %w", path, err))
|
|
}
|
|
httpReq.Header.Set("Content-Type", "application/json")
|
|
if c.token != "" {
|
|
httpReq.Header.Set("Authorization", "Bearer "+c.token)
|
|
}
|
|
|
|
resp, err := client.Do(httpReq)
|
|
if err != nil {
|
|
return nil, controlFailure(ctx, nodeID, err)
|
|
}
|
|
|
|
switch {
|
|
case resp.StatusCode == http.StatusOK, resp.StatusCode == http.StatusNoContent:
|
|
return resp, nil
|
|
case resp.StatusCode == http.StatusNotFound:
|
|
// The worker answered, about ITSELF rather than about a backend: it is
|
|
// older than this frontend and serves no such verb. The catch-all under
|
|
// the control prefix is what makes this distinguishable from a proxy's
|
|
// bare 404.
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("control rpc %s to node %q: %w", path, nodeID, ErrWorkerControlUnsupported)
|
|
default:
|
|
// A verb's own failure arrives as 200 with Error set, so a non-2xx is
|
|
// the worker failing to serve the request rather than answering it: a
|
|
// body it could not read, a method it refuses, a handler that panicked.
|
|
// None of those is evidence about a backend.
|
|
detail := readErrorBody(resp)
|
|
_ = resp.Body.Close()
|
|
return nil, controlFailure(ctx, nodeID, fmt.Errorf("the %s verb answered HTTP %d: %s", path, resp.StatusCode, detail))
|
|
}
|
|
}
|
|
|
|
// maxControlErrorBodyBytes bounds how much of a non-2xx body reaches a log
|
|
// line. The body is written by the worker and lands in this frontend's logs, so
|
|
// it is bounded for the same reason the worker bounds the path it echoes.
|
|
const maxControlErrorBodyBytes = 512
|
|
|
|
// readErrorBody reads the diagnostic text off a non-2xx control response.
|
|
func readErrorBody(resp *http.Response) string {
|
|
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxControlErrorBodyBytes))
|
|
if err != nil {
|
|
return "<the body could not be read>"
|
|
}
|
|
return string(bytes.TrimSpace(raw))
|
|
}
|
|
|
|
// controlFailure maps a control RPC's failure onto exactly one of the four
|
|
// conditions this system must never confuse, and it is the only place that
|
|
// mapping is made.
|
|
//
|
|
// The rule it enforces, stated as the code enforces it rather than as advice: a
|
|
// WORKER'S ANSWER passes through unwrapped so cluster.IsWorkerAnswer still sees
|
|
// it and a reap guard may act on it, and EVERYTHING ELSE is wrapped in
|
|
// ErrWorkerUnroutable so nothing can. There is no third branch, because a third
|
|
// branch is how the eight collapses on this branch happened: each was a place
|
|
// that decided for itself which errors were evidence.
|
|
//
|
|
// The caller's spent budget is checked FIRST, and that ordering is the same one
|
|
// cluster.WorkerDialer.handshake takes for the same reason. A worker's refusal
|
|
// that arrives in the instant the deadline expires would otherwise be reported
|
|
// as the worker's non-transient verdict and reap a row, and nothing orders the
|
|
// two timers: an expiry is never evidence about a backend, so it is answered as
|
|
// the caller's own timeout rather than as an answer.
|
|
func controlFailure(ctx context.Context, nodeID string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if ctxErr := ctx.Err(); ctxErr != nil {
|
|
return fmt.Errorf("control rpc to node %q: %w: %w", nodeID, ErrWorkerUnroutable, ctxErr)
|
|
}
|
|
if cluster.IsWorkerAnswer(err) {
|
|
return err
|
|
}
|
|
return fmt.Errorf("control rpc to node %q: %w: %w", nodeID, ErrWorkerUnroutable, err)
|
|
}
|