Files
LocalAI/tests/e2e/distributed/control_workers_test.go
T
Ettore Di Giacinto 44f12b2adb feat(distributed): call the worker's control routes instead of the bus
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>
2026-09-02 19:43:53 +00:00

113 lines
3.7 KiB
Go

package distributed_test
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/workerctl"
)
// ControlWorkers is a fleet of fake workers serving the tunnelled control
// plane, which is where the frontend's backend and model lifecycle verbs go
// now that they have left the bus.
//
// It replaces the NATS SubscribeReply fakes these suites used to stand up. One
// HTTP server answers for every node and reads which node a request is for off
// the Host header, because that is what nodes.ControlClient puts there; a
// client addressing the wrong node would be visible rather than silently
// served.
//
// It is deliberately NOT a real worker: these suites are about what the
// frontend does with a worker's answer. The real handlers are exercised against
// the real client in core/services/worker.
type ControlWorkers struct {
mu sync.Mutex
srv *httptest.Server
handlers map[string]func(nodeID string, body []byte) any
}
// controlHandlerKey is the (node, verb) a handler is registered for. AnyNode
// registers one handler for every node, which is how a suite fakes a fleet
// whose ids it does not know up front.
const AnyNode = "*"
func controlHandlerKey(nodeID, path string) string { return nodeID + " " + path }
// NewControlWorkers starts the fleet and stops it when the spec ends.
func NewControlWorkers() *ControlWorkers {
c := &ControlWorkers{handlers: map[string]func(string, []byte) any{}}
mux := http.NewServeMux()
mux.HandleFunc(workerctl.Prefix, c.serve)
c.srv = httptest.NewServer(mux)
DeferCleanup(c.srv.Close)
return c
}
// Client returns a control client that reaches this fleet.
func (c *ControlWorkers) Client() *nodes.ControlClient {
addr := c.srv.Listener.Addr().String()
return nodes.NewControlClient(func(string) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "tcp", addr)
}
}, "")
}
// On registers what one node answers for one verb. Returning nil answers 204,
// which is the shape the fire-and-forget verbs take.
func (c *ControlWorkers) On(nodeID, path string, fn func(nodeID string, body []byte) any) {
c.mu.Lock()
defer c.mu.Unlock()
c.handlers[controlHandlerKey(nodeID, path)] = fn
}
func (c *ControlWorkers) serve(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
Expect(err).ToNot(HaveOccurred())
nodeID := strings.TrimSuffix(r.Host, ".worker.invalid:80")
c.mu.Lock()
fn, ok := c.handlers[controlHandlerKey(nodeID, r.URL.Path)]
if !ok {
fn, ok = c.handlers[controlHandlerKey(AnyNode, r.URL.Path)]
}
c.mu.Unlock()
if !ok {
// Loud, never plausible: a verb no spec scripted must be a red spec
// rather than a worker that looks absent.
http.Error(w, "no control handler registered for "+r.URL.Path+" on "+nodeID, http.StatusInternalServerError)
return
}
reply := fn(nodeID, body)
streaming := r.URL.Path == workerctl.PathBackendInstall || r.URL.Path == workerctl.PathBackendUpgrade
if !streaming {
if reply == nil {
w.WriteHeader(http.StatusNoContent)
return
}
w.Header().Set("Content-Type", "application/json")
Expect(json.NewEncoder(w).Encode(reply)).To(Succeed())
return
}
raw, err := json.Marshal(reply)
Expect(err).ToNot(HaveOccurred())
w.Header().Set("Content-Type", workerctl.ContentTypeStream)
w.WriteHeader(http.StatusOK)
// The reply line, and it is the last thing on the body by contract.
Expect(json.NewEncoder(w).Encode(workerctl.Envelope{Reply: raw})).To(Succeed())
}