feat(distributed): stop workers listening, and stop them advertising

A worker now opens no listener on a routable interface and states no endpoint at
registration. Backend processes and the file-transfer server bind loopback, and
the frontend reaches both through the tunnel the worker dials. The bind address
is built from loopbackHost, the same constant the tunnel's grpc tag dials, so
"the worker binds where its tunnel dials" is one fact in one place rather than
two literals that can drift.

All three advertisement sites are closed, not one: the registration body,
RegisterNodeRequest, and the per-backend address in the install reply.

That third one was hiding a live bug. stopModelExact refuses a stop whose
ExpectedAddress does not match what the worker recorded for the process. The
worker recorded 127.0.0.1:port; handleBackendInstall reported advertiseHost:port;
the router stored the reported one and sent it straight back. On any worker whose
advertise host was not 127.0.0.1, every acknowledged model stop failed with an
address mismatch. Nothing caught it because the e2e harness set
LOCALAI_ADVERTISE_ADDR=127.0.0.1, which made the rewrite a no-op. Removing the
rewrite makes the two strings the same by construction.

The brief was wrong about two of the four functions it called dead.
effectiveBasePort is the base of the backend port allocator and resolveHTTPAddr
is the file server's bind address; deleting them would have deleted the port
allocator and the file server. Only the two advertise* helpers were dead, and
addr_test.go is rewritten rather than deleted, because the port arithmetic it
pinned still needs pinning.

NodeModel.Address survives with a narrowed meaning and is renamed
WorkerLocalAddress, along with the install reply field that feeds it. The
frontend still has to say WHICH backend process on a worker it means, and the
port in this string is how it says it: it travels as a stream target and the
worker dials its own loopback. The gorm column and the json key stay "address",
so neither a migration nor an API break rides along. Every fall-back to the
node's address is gone. installBackendOnNode now errors when a worker reports
success without naming one, because substituting the now-always-empty node
address would name an empty target, and the worker refuses that as an invalid
stream, which is classified as the worker answering about its backend. That is
the "a present worker reads as something it is not" class this phase forbids.

DistributedModelStore.Range had the same shape and was already wrong: it built
each remote model's client from the node's base gRPC port, never the port a
backend process listens on, so Free and Status went to the wrong place. It uses
the replica's address now.

BackendNode.Address and HTTPAddress are kept but made provably inert: no writer,
no reader that acts on them, and Register force-clears both on re-registration so
an upgraded worker's stale advertisement does not outlive its own upgrade in the
API and the Nodes page. Dropping the columns is a ~90-site edit across the specs,
the e2e suite, the MCP dto and the UI; it is recorded as a follow-up rather than
folded in here.

A persistent tunnel 401 still does not trigger re-registration, and now for a
reason rather than a deferral. Register CLEARS the node's replica rows, so
re-registering on a 401 would delete a live worker's rows on every retry, and
under the name collision that causes the 401 the two workers would take turns
doing it forever: a credential failure causing model reclamation. It also cannot
fix the named cause, since a collision is indistinguishable from a restart. The
401 log now names both causes and says nothing can reach this worker, which is
true only now that it has no listener.

The container healthcheck did not break the way the brief expected, since the
listener still exists on loopback and the probe runs inside the container. It did
have a real #10987 defect that this change makes the common case: it read
LOCALAI_SERVE_ADDR only, while effectiveBasePort reads LOCALAI_ADDR first, so a
worker on a non-default base port was probed on 50050 and reported unhealthy
while working. It follows the same precedence now.

Docs, the compose file and the e2e harness are updated in step: no inbound rule
or published port is needed for a worker, the two advertise variables are gone,
the remaining address variables are read for their port only, the
firewall-the-file-transfer-port warning is narrowed to the LOCALAI_HTTP_ADDR
opt-out, and the upgrade-order note no longer claims the worker still listens.
The Nodes page showed node.address, which is now always blank, so it shows the
node id instead.

Eight mutations, all red on a named spec, including reverting the loopback bind,
re-adding the address to the registration body, restoring both node-address
fall-backs, dropping the force-clear, storing the endpoint's address again, and
un-fixing the healthcheck. One of them caught a defect in a spec I had just
written: it asserted 200 where the endpoint returns 201, which went unnoticed
because core/http/endpoints/localai is not on the task's verify list. It is run
here.

Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-09-01 18:47:37 +00:00
1 parent ed9a4b6b52
commit 1cf847f29e
50 files changed
+635 -357

No files matched your search

+11 -16
View File
@@ -77,10 +77,14 @@ func GetNodeEndpoint(registry *nodes.NodeRegistry) echo.HandlerFunc {
// RegisterNodeRequest is the request body for registering a new worker node.
type RegisterNodeRequest struct {
Name string `json:"name"`
NodeType string `json:"node_type,omitempty"` // "backend" (default) or "agent"
Address string `json:"address"`
HTTPAddress string `json:"http_address,omitempty"`
Name string `json:"name"`
NodeType string `json:"node_type,omitempty"` // "backend" (default) or "agent"
// No address and no http_address. A worker has no inbound endpoint to
// register: it holds one outbound tunnel to a frontend replica and every
// protocol the frontend speaks to it travels on that. An older worker still
// sends both keys and they are ignored, which is the intended outcome:
// storing them would put a dialable-looking endpoint back in the API for
// something nothing dials.
Token string `json:"token,omitempty"`
TotalVRAM uint64 `json:"total_vram,omitempty"`
AvailableVRAM uint64 `json:"available_vram,omitempty"`
@@ -142,22 +146,15 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
fmt.Sprintf("invalid node_type %q; must be %q or %q", nodeType, nodes.NodeTypeBackend, nodes.NodeTypeAgent)))
}
// Backend workers require address; agent workers don't serve gRPC
// A backend worker no longer has to state an address; the tunnel it
// dials is what makes it reachable, and requiring one here would refuse
// exactly the workers this design is for.
if req.Name == "" {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "name is required"))
}
if nodeType == nodes.NodeTypeBackend && req.Address == "" {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "address is required for backend workers"))
}
if len(req.Name) > 255 {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "name exceeds 255 characters"))
}
if len(req.Address) > 512 {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "address exceeds 512 characters"))
}
if len(req.HTTPAddress) > 512 {
return c.JSON(http.StatusBadRequest, nodeError(http.StatusBadRequest, "http_address exceeds 512 characters"))
}
// Hash the token for storage (if provided)
var tokenHash string
@@ -177,8 +174,6 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au
node := &nodes.BackendNode{
Name: req.Name,
NodeType: nodeType,
Address: req.Address,
HTTPAddress: req.HTTPAddress,
TokenHash: tokenHash,
TotalVRAM: req.TotalVRAM,
AvailableVRAM: req.AvailableVRAM,
+33 -7
View File
@@ -289,7 +289,11 @@ var _ = Describe("Node HTTP handlers", func() {
Expect(errObj["message"]).To(ContainSubstring("exceeds 255 characters"))
})
It("returns 400 when address is missing for backend node type", func() {
It("registers a backend worker that states no address", func() {
// This used to be a 400. It is the shape every worker now
// registers with: it has no inbound endpoint, it holds one outbound
// tunnel, and refusing it here would refuse exactly the workers the
// tunnel exists for.
e := echo.New()
body := `{"name":"worker-no-addr"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
@@ -299,13 +303,35 @@ var _ = Describe("Node HTTP handlers", func() {
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(rec.Code).To(Equal(http.StatusCreated))
var resp map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
errObj, ok := resp["error"].(map[string]any)
Expect(ok).To(BeTrue())
Expect(errObj["message"]).To(ContainSubstring("address is required"))
stored, err := registry.GetByName(context.Background(), "worker-no-addr")
Expect(err).ToNot(HaveOccurred())
Expect(stored.NodeType).To(Equal(nodes.NodeTypeBackend))
Expect(stored.Address).To(BeEmpty())
Expect(stored.HTTPAddress).To(BeEmpty())
})
It("stores no address even when a worker still sends one", func() {
// An older worker keeps sending both keys. Storing them would put a
// dialable-looking endpoint back into the API and the Nodes page for
// something nothing dials, and would leave a reader of either one
// unsure which workers are reached how.
e := echo.New()
body := `{"name":"worker-legacy-addr","address":"10.0.0.9:50051","http_address":"10.0.0.9:50050"}`
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
handler := RegisterNodeEndpoint(registry, "", true, nil, "", natsauth.Config{})
Expect(handler(c)).To(Succeed())
Expect(rec.Code).To(Equal(http.StatusCreated))
stored, err := registry.GetByName(context.Background(), "worker-legacy-addr")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Address).To(BeEmpty())
Expect(stored.HTTPAddress).To(BeEmpty())
})
It("returns 400 when node_type is invalid", func() {
@@ -19,7 +19,11 @@ export default function NodePanel({ node, models = [], onApprove, onDrain, onRes
<div className="node-panel__id">
<StatusPill status={node.status} />
<span className="node-panel__name">{node.name}</span>
<span className="cell-mono cell-muted">{node.address}</span>
{/* A worker has no address to show: it holds an outbound tunnel and
binds nothing routable. Its id is what identifies it in routing
logs, so that is what an operator needs here. Pre-tunnel nodes
may still carry an address until they re-register. */}
<span className="cell-mono cell-muted">{node.address || node.id}</span>
</div>
<div className="node-panel__actions" onClick={(e) => e.stopPropagation()}>
{node.status === 'pending' && (
+1 -1
View File
@@ -78,7 +78,7 @@ export default function NodeDetail() {
<PageHeader
eyebrow={<a onClick={() => navigate('/app/nodes')} className="link-plain"><i className="fas fa-arrow-left icon-before" aria-hidden="true" />Cluster</a>}
title={<><StatusPill status={node.status} /> {node.name}</>}
supporting={node.address}
supporting={node.address || node.id}
actions={
<>
{node.status === 'draining'
+12 -3
View File
@@ -193,9 +193,18 @@ type BackendInstallRequest struct {
// BackendInstallReply is the response from a backend.install NATS request.
type BackendInstallReply struct {
Success bool `json:"success"`
Address string `json:"address,omitempty"` // gRPC address of the backend process (host:port)
Error string `json:"error,omitempty"`
Success bool `json:"success"`
// WorkerLocalAddress is where the backend process listens ON THE WORKER,
// which is a loopback address. It is not dialable from the frontend and
// never was meant to be read that way: the frontend takes its PORT and
// names it as the target of a stream on that worker's tunnel, and the
// worker dials its own loopback there.
//
// The json tag stays "address" so a worker and a frontend from different
// releases still understand each other. An older worker sends its
// advertised host here; only the port is read, and the port is the same.
WorkerLocalAddress string `json:"address,omitempty"`
Error string `json:"error,omitempty"`
}
// SubjectNodeBackendUpgrade tells a worker node to force-reinstall a backend
+1 -1
View File
@@ -145,7 +145,7 @@ var _ = Describe("scheduling a model onto a cluster without disk headroom", func
reg.findIdleNode = &BackendNode{ID: "n1", Name: "nvidia-thor", Address: "10.0.0.1:50051"}
backend = &holdBackend{}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
router = NewSmartRouter(reg, SmartRouterOptions{
Unloader: unloader,
+10 -6
View File
@@ -91,10 +91,14 @@ func (s *DistributedModelStore) Range(fn func(string, *model.Model) bool) {
}
seen[nm.ModelName] = true
// Look up the node address
node, err := s.registry.Get(ctx, nm.NodeID)
if err != nil {
xlog.Warn("DistributedModelStore: failed to get node for model", "model", nm.ModelName, "nodeID", nm.NodeID, "error", err)
// The REPLICA's address, not the node's. This used to name the node,
// which was the worker's base gRPC port and never the port the backend
// process actually listens on, so Free and Status on a model reached
// from here went to the wrong place; with workers no longer advertising
// anything it would name nothing at all.
if nm.WorkerLocalAddress == "" {
xlog.Warn("DistributedModelStore: not listing a replica whose backend process is unnamed",
"model", nm.ModelName, "nodeID", nm.NodeID, "replica", nm.ReplicaIndex)
continue
}
@@ -103,13 +107,13 @@ func (s *DistributedModelStore) Range(fn func(string, *model.Model) bool) {
// anything calls GRPC() on it, which reaches a worker only while
// workers still listen on a routable address. Building the client here
// means the bypass has no path left rather than an unused one.
client, err := s.clientFor(nm.NodeID, node.Address)
client, err := s.clientFor(nm.NodeID, nm.WorkerLocalAddress)
if err != nil {
xlog.Error("DistributedModelStore: not listing a remote model it cannot reach",
"model", nm.ModelName, "nodeID", nm.NodeID, "error", err)
continue
}
m := model.NewModelWithClient(nm.ModelName, node.Address, client)
m := model.NewModelWithClient(nm.ModelName, nm.WorkerLocalAddress, client)
if !fn(nm.ModelName, m) {
return
}
+42 -9
View File
@@ -99,11 +99,11 @@ var _ = Describe("DistributedModelStore", func() {
local.Set("model-a", localModel)
// DB model (not in local)
dbNode := &BackendNode{ID: "node-2", Address: "10.0.0.3:50051"}
dbNode := &BackendNode{ID: "node-2"}
lookup.nodes["node-2"] = dbNode
lookup.allModels = []NodeModel{
{NodeID: "node-2", ModelName: "model-b"},
{NodeID: "node-2", ModelName: "model-a"}, // duplicate should be skipped
{NodeID: "node-2", ModelName: "model-b", WorkerLocalAddress: "127.0.0.1:50052"},
{NodeID: "node-2", ModelName: "model-a", WorkerLocalAddress: "127.0.0.1:50053"}, // duplicate, should be skipped
}
visited := make(map[string]bool)
@@ -124,9 +124,9 @@ var _ = Describe("DistributedModelStore", func() {
// bypasses the worker's tunnel completely. It is reached in
// production: ShutdownModel calls Free on it and the backend
// monitor calls Status.
dbNode := &BackendNode{ID: "node-2", Address: "10.0.0.3:50051"}
dbNode := &BackendNode{ID: "node-2"}
lookup.nodes["node-2"] = dbNode
lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model"}}
lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model", WorkerLocalAddress: "127.0.0.1:50052"}}
var got *model.Model
store.Range(func(id string, m *model.Model) bool {
@@ -142,15 +142,48 @@ var _ = Describe("DistributedModelStore", func() {
Expect(clients.nodesSeen()).To(ContainElement("node-2"))
})
It("names the replica's own backend process, not the node", func() {
// This used to pass the NODE's address, which was the worker's base
// gRPC port and never the port a backend process listens on, so
// Free and Status on a model listed here went to the wrong process.
// A node has no address at all now, so the same code would name the
// empty string and the worker would refuse the stream as invalid, a
// refusal that reads as the backend answering about itself.
lookup.nodes["node-2"] = &BackendNode{ID: "node-2"}
lookup.allModels = []NodeModel{{
NodeID: "node-2", ModelName: "remote-model", ReplicaIndex: 1,
WorkerLocalAddress: "127.0.0.1:50057",
}}
store.Range(func(string, *model.Model) bool { return true })
Expect(clients.addressesSeen()).To(ConsistOf("127.0.0.1:50057"))
})
It("skips a replica row that names no backend process", func() {
// Nothing can be routed to it, and handing back a model whose
// client targets an empty address turns every Free and Status on it
// into an invalid-stream refusal from the worker.
lookup.nodes["node-2"] = &BackendNode{ID: "node-2"}
lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "unnamed-model"}}
visited := map[string]bool{}
store.Range(func(id string, _ *model.Model) bool {
visited[id] = true
return true
})
Expect(visited).ToNot(HaveKey("unnamed-model"))
Expect(clients.addressesSeen()).To(BeEmpty())
})
It("refuses to list a remote model it has no way to reach", func() {
// Loudly, not by falling back. A model handed back here with a
// direct-dialling client works on a single-host developer setup and
// fails against every worker with no inbound port, which is the
// worst way for this defect to behave.
clients.refuseForNode = errors.New("no tunnel for you")
dbNode := &BackendNode{ID: "node-2", Address: "10.0.0.3:50051"}
dbNode := &BackendNode{ID: "node-2"}
lookup.nodes["node-2"] = dbNode
lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model"}}
lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model", WorkerLocalAddress: "127.0.0.1:50052"}}
visited := map[string]bool{}
store.Range(func(id string, _ *model.Model) bool {
@@ -162,9 +195,9 @@ var _ = Describe("DistributedModelStore", func() {
It("refuses when no client factory was wired at all", func() {
bare := NewDistributedModelStore(local, lookup, nil)
dbNode := &BackendNode{ID: "node-2", Address: "10.0.0.3:50051"}
dbNode := &BackendNode{ID: "node-2"}
lookup.nodes["node-2"] = dbNode
lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model"}}
lookup.allModels = []NodeModel{{NodeID: "node-2", ModelName: "remote-model", WorkerLocalAddress: "127.0.0.1:50052"}}
visited := map[string]bool{}
bare.Range(func(id string, _ *model.Model) bool {
+11 -5
View File
@@ -185,16 +185,22 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
if hm.perModelHealthCheck {
models, _ := hm.registry.GetNodeModels(ctx, node.ID)
for _, m := range models {
if m.Address == "" || m.Address == node.Address {
// A row with no address names no backend process, so there is
// nothing to probe. The old second arm of this test skipped a
// replica whose address equalled the NODE's; a node has no
// address any more, so that comparison could only ever be true
// for two empty strings and has been dropped rather than left
// to read as a live rule.
if m.WorkerLocalAddress == "" {
continue
}
// Through the node's tunnel, never a direct dial to m.Address:
// Through the node's tunnel, never a direct dial to m.WorkerLocalAddress:
// that address is a port inside the worker. A worker this
// replica cannot reach is not evidence that its backend died,
// so the miss counter is left alone and the row survives;
// counting it as a miss would reap live models across the whole
// fleet the moment the tunnel wiring was wrong.
mClient, err := hm.clientFactory.NewClientForNode(node.ID, m.Address, false)
mClient, err := hm.clientFactory.NewClientForNode(node.ID, m.WorkerLocalAddress, false)
if err != nil {
xlog.Error("Skipping model health probe: no way to reach the worker",
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex, "error", err)
@@ -236,12 +242,12 @@ func (hm *HealthMonitor) doCheckAll(ctx context.Context) {
if misses < perModelMissThreshold {
xlog.Debug("Model backend probe failed, awaiting threshold before removal",
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex,
"address", m.Address, "misses", misses, "threshold", perModelMissThreshold)
"address", m.WorkerLocalAddress, "misses", misses, "threshold", perModelMissThreshold)
continue
}
xlog.Warn("Model backend unhealthy after consecutive misses, removing from registry",
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex,
"address", m.Address, "misses", misses)
"address", m.WorkerLocalAddress, "misses", misses)
if err := hm.registry.RemoveNodeModel(ctx, node.ID, m.ModelName, m.ReplicaIndex); err != nil {
xlog.Warn("Failed to remove unhealthy model from registry",
"node", node.ID, "model", m.ModelName, "replica", m.ReplicaIndex, "error", err)
+12
View File
@@ -311,6 +311,10 @@ type fakeBackendClientFactory struct {
defaultClient *fakeBackendClient
// forNode records every node id NewClientForNode was asked for.
forNode []string
// forNodeAddr records the ADDRESS asked for alongside each node id, so a
// spec can pin which of the two addresses a caller reached for: a replica
// row's own, or the node's, the second of which is now always empty.
forNodeAddr []string
// refuseForNode makes NewClientForNode fail, standing in for a deployment
// with no way to reach the worker. Set before the code under test runs.
refuseForNode error
@@ -346,12 +350,20 @@ func (f *fakeBackendClientFactory) nodesSeen() []string {
return append([]string(nil), f.forNode...)
}
// addressesSeen records the addresses passed alongside those node ids.
func (f *fakeBackendClientFactory) addressesSeen() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.forNodeAddr...)
}
func (f *fakeBackendClientFactory) NewClientForNode(nodeID, address string, parallel bool) (grpc.Backend, error) {
if f.refuseForNode != nil {
return nil, f.refuseForNode
}
f.mu.Lock()
f.forNode = append(f.forNode, nodeID)
f.forNodeAddr = append(f.forNodeAddr, address)
f.mu.Unlock()
return f.NewClient(address, parallel), nil
}
+7 -7
View File
@@ -245,7 +245,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
// node should remain healthy because heartbeat is fresh
node := makeTestNode("node-crash", "crash-worker", "10.0.0.9:50051", StatusHealthy, freshTime())
store.addNode(node)
store.addNodeModel("node-crash", NodeModel{NodeID: "node-crash", ModelName: "piper-model", Address: "10.0.0.9:50053"})
store.addNodeModel("node-crash", NodeModel{NodeID: "node-crash", ModelName: "piper-model", WorkerLocalAddress: "10.0.0.9:50053"})
// gRPC backend is dead — but health is heartbeat-based, not gRPC-based
factory.setClient("10.0.0.9:50051", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
@@ -265,7 +265,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
node := makeTestNode("node-model", "model-worker", "10.0.0.10:50051", StatusHealthy, freshTime())
store.addNode(node)
store.addNodeModel("node-model", NodeModel{NodeID: "node-model", ModelName: "piper-model", Address: "10.0.0.10:50053"})
store.addNodeModel("node-model", NodeModel{NodeID: "node-model", ModelName: "piper-model", WorkerLocalAddress: "10.0.0.10:50053"})
// Model backend is dead
factory.setClient("10.0.0.10:50053", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
@@ -299,7 +299,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
node := makeTestNode("node-tun", "tun-worker", "10.0.0.20:50051", StatusHealthy, freshTime())
store.addNode(node)
store.addNodeModel("node-tun", NodeModel{NodeID: "node-tun", ModelName: "m", Address: "10.0.0.20:50053"})
store.addNodeModel("node-tun", NodeModel{NodeID: "node-tun", ModelName: "m", WorkerLocalAddress: "10.0.0.20:50053"})
hm.doCheckAll(context.Background())
Expect(factory.nodesSeen()).To(ContainElement("node-tun"))
@@ -318,7 +318,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
node := makeTestNode("node-cut", "cut-worker", "10.0.0.21:50051", StatusHealthy, freshTime())
store.addNode(node)
store.addNodeModel("node-cut", NodeModel{NodeID: "node-cut", ModelName: "m", Address: "10.0.0.21:50053"})
store.addNodeModel("node-cut", NodeModel{NodeID: "node-cut", ModelName: "m", WorkerLocalAddress: "10.0.0.21:50053"})
for i := 0; i < perModelMissThreshold+1; i++ {
hm.doCheckAll(context.Background())
@@ -340,7 +340,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
node := makeTestNode("node-blip", "blip-worker", "10.0.0.22:50051", StatusHealthy, freshTime())
store.addNode(node)
store.addNodeModel("node-blip", NodeModel{NodeID: "node-blip", ModelName: "m", Address: "10.0.0.22:50053"})
store.addNodeModel("node-blip", NodeModel{NodeID: "node-blip", ModelName: "m", WorkerLocalAddress: "10.0.0.22:50053"})
factory.setClient("10.0.0.22:50053", &fakeBackendClient{
healthy: false,
err: fmt.Errorf("connection error"),
@@ -364,7 +364,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
node := makeTestNode("node-dead", "dead-worker", "10.0.0.23:50051", StatusHealthy, freshTime())
store.addNode(node)
store.addNodeModel("node-dead", NodeModel{NodeID: "node-dead", ModelName: "m", Address: "10.0.0.23:50053"})
store.addNodeModel("node-dead", NodeModel{NodeID: "node-dead", ModelName: "m", WorkerLocalAddress: "10.0.0.23:50053"})
// No dialErr: the transport was fine.
factory.setClient("10.0.0.23:50053", &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")})
@@ -382,7 +382,7 @@ var _ = Describe("HealthMonitor (mock-based)", func() {
node := makeTestNode("node-flap", "flap-worker", "10.0.0.11:50051", StatusHealthy, freshTime())
store.addNode(node)
store.addNodeModel("node-flap", NodeModel{NodeID: "node-flap", ModelName: "piper-model", Address: "10.0.0.11:50053"})
store.addNodeModel("node-flap", NodeModel{NodeID: "node-flap", ModelName: "piper-model", WorkerLocalAddress: "10.0.0.11:50053"})
deadClient := &fakeBackendClient{healthy: false, err: fmt.Errorf("connection refused")}
liveClient := &fakeBackendClient{healthy: true}
@@ -386,9 +386,9 @@ var _ = Describe("DistributedBackendManager", func() {
n2 := registerHealthyBackend("worker-b", "10.0.0.2:50051")
mc.scriptReply(messaging.SubjectNodeBackendInstall(n1.ID),
messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"})
messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
mc.scriptReply(messaging.SubjectNodeBackendInstall(n2.ID),
messaging.BackendInstallReply{Success: true, Address: "10.0.0.2:50100"})
messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.2:50100"})
Expect(mgr.InstallBackend(ctx, op("vllm-development"), nil)).To(Succeed())
})
@@ -420,7 +420,7 @@ var _ = Describe("DistributedBackendManager", func() {
bad := registerHealthyBackend("worker-bad", "10.0.0.2:50051")
mc.scriptReply(messaging.SubjectNodeBackendInstall(ok.ID),
messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"})
messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
mc.scriptReply(messaging.SubjectNodeBackendInstall(bad.ID),
messaging.BackendInstallReply{Success: false, Error: "out of memory"})
@@ -459,7 +459,7 @@ var _ = Describe("DistributedBackendManager", func() {
other := registerHealthyBackend("worker-other", "10.0.0.2:50051")
mc.scriptReply(messaging.SubjectNodeBackendInstall(target.ID),
messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"})
messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
// No reply scripted for `other`: if InstallBackend fans out
// to it, the fakeNoRespondersErr default would surface and
// the test would fail.
@@ -615,7 +615,7 @@ var _ = Describe("DistributedBackendManager", func() {
It("invokes progressCb once per worker-published progress event", func() {
node := registerHealthyBackend("worker-prog", "10.0.0.7:50051")
mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID), messaging.BackendInstallReply{Success: true, Address: "10.0.0.7:50051"})
mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID), messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.7:50051"})
mc.scheduleProgressPublish(node.ID, "op-prog-1", []messaging.BackendInstallProgressEvent{
{OpID: "op-prog-1", NodeID: node.ID, Backend: "vllm", FileName: "vllm.tar", Current: "100 MB", Total: "1 GB", Percentage: 10},
{OpID: "op-prog-1", NodeID: node.ID, Backend: "vllm", FileName: "vllm.tar", Current: "1 GB", Total: "1 GB", Percentage: 100},
@@ -659,7 +659,7 @@ var _ = Describe("DistributedBackendManager", func() {
Context("InstallBackend tolerates silent (pre-Phase-2) workers", func() {
It("completes successfully even when no progress events are ever published", func() {
node := registerHealthyBackend("worker-silent", "10.0.0.8:50051")
mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID), messaging.BackendInstallReply{Success: true, Address: "10.0.0.8:50051"})
mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID), messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.8:50051"})
// NO scheduleProgressPublish call - silent worker.
var ticks int
@@ -702,7 +702,7 @@ var _ = Describe("DistributedBackendManager", func() {
It("emits a success entry for each healthy node visited", func() {
node := registerHealthyBackend("worker-ok", "10.0.0.9:50051")
mc.scriptReply(messaging.SubjectNodeBackendInstall(node.ID),
messaging.BackendInstallReply{Success: true, Address: "10.0.0.9:50051"})
messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.9:50051"})
opVal := op("vllm")
opVal.ID = "op-node-success"
@@ -929,7 +929,7 @@ var _ = Describe("DistributedBackendManager", func() {
// Fallback re-fires legacy backend.install with Force=true.
mc.scriptReplyMatching(messaging.SubjectNodeBackendInstall(n.ID),
func(req messaging.BackendInstallRequest) bool { return req.Force },
messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"})
messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"})
Expect(mgr.UpgradeBackend(ctx, upgradeOp("vllm-development"), nil)).To(Succeed())
})
+2 -2
View File
@@ -68,7 +68,7 @@ func (a *ModelRouterAdapter) Route(ctx context.Context, backend, modelID, modelN
// If file staging is configured, it's already wrapped with FileStagingClient
// by SmartRouter. Use NewModelWithClient so the wrapper is preserved when
// the ModelLoader returns this model on subsequent requests.
m := model.NewModelWithClient(modelID, result.Node.Address, result.Client)
m := model.NewModelWithClient(modelID, result.WorkerLocalAddress, result.Client)
// Publish the picked node ID into the per-request holder attached to
// ctx (by middleware.ExposeNodeHeader). No-op when the holder is
@@ -80,7 +80,7 @@ func (a *ModelRouterAdapter) Route(ctx context.Context, backend, modelID, modelN
// concurrently to different replicas.
distributedhdr.Stamp(ctx, result.Node.ID)
xlog.Info("Model routed to remote node", "model", modelName, "node", result.Node.Name, "address", result.Node.Address)
xlog.Info("Model routed to remote node", "model", modelName, "node", result.Node.Name, "address", result.WorkerLocalAddress)
return m, nil
}
+8 -6
View File
@@ -199,13 +199,15 @@ var _ = Describe("ModelRouterAdapter", func() {
Describe("Route", func() {
It("delegates to SmartRouter and stores release func", func() {
fakeNode := &BackendNode{
ID: "node-1",
Name: "test-node",
Address: "10.0.0.1:50051",
ID: "node-1",
Name: "test-node",
}
// The replica row carries the address now; the node has none. A row
// without one is not routable and the warm path declines it.
fakeNM := &NodeModel{
NodeID: "node-1",
ModelName: "test-model",
NodeID: "node-1",
ModelName: "test-model",
WorkerLocalAddress: "127.0.0.1:50052",
}
fakeReg := newFakeModelRouterForSmartRouter()
@@ -214,7 +216,7 @@ var _ = Describe("ModelRouterAdapter", func() {
// The fake gRPC client that SmartRouter will use for health check
factory := newFakeBackendClientFactory()
factory.setClient("10.0.0.1:50051", &fakeBackendClient{healthy: true})
factory.setClient("127.0.0.1:50052", &fakeBackendClient{healthy: true})
sr := NewSmartRouter(fakeReg, SmartRouterOptions{
ClientFactory: factory,
+6 -6
View File
@@ -522,14 +522,14 @@ func (rc *ReplicaReconciler) probeLoadedModels(ctx context.Context) {
return
}
seen[m.ID] = struct{}{}
switch rc.prober.Probe(ctx, m.NodeID, m.Address) {
switch rc.prober.Probe(ctx, m.NodeID, m.WorkerLocalAddress) {
case ProbeUnknown:
// This frontend could not reach the worker to ask. The streak is
// left exactly as it was: neither cleared, which would forgive a
// backend that really is dead, nor advanced, which would reap every
// model in the fleet the moment the tunnel wiring broke.
xlog.Warn("Reconciler: could not probe a model, leaving its row alone",
"node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.Address)
"node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.WorkerLocalAddress)
continue
case ProbeAlive:
rc.clearProbeFailures(m.ID)
@@ -541,14 +541,14 @@ func (rc *ReplicaReconciler) probeLoadedModels(ctx context.Context) {
// Reachable but mid-request. Proof of life, so clear the streak.
rc.clearProbeFailures(m.ID)
xlog.Debug("Reconciler: model busy, skipping liveness reap",
"node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.Address)
"node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.WorkerLocalAddress)
continue
}
failures := rc.recordProbeFailure(m.ID)
if failures < probeFailuresBeforeReap {
xlog.Debug("Reconciler: model unreachable, waiting for more misses before reaping",
"node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.Address,
"node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.WorkerLocalAddress,
"failures", failures, "threshold", probeFailuresBeforeReap)
continue
}
@@ -558,7 +558,7 @@ func (rc *ReplicaReconciler) probeLoadedModels(ctx context.Context) {
}
rc.clearProbeFailures(m.ID)
xlog.Warn("Reconciler: model unreachable, removed from registry",
"node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.Address,
"node", m.NodeID, "model", m.ModelName, "replica", m.ReplicaIndex, "address", m.WorkerLocalAddress,
"failures", failures)
}
rc.pruneProbeFailures(seen)
@@ -613,7 +613,7 @@ func (rc *ReplicaReconciler) sweepLeakedInFlight(ctx context.Context) {
return
}
seen[m.ID] = struct{}{}
if rc.prober.Probe(ctx, m.NodeID, m.Address) != ProbeAlive {
if rc.prober.Probe(ctx, m.NodeID, m.WorkerLocalAddress) != ProbeAlive {
// Busy or unreachable. Busy means the counter may well be real;
// unreachable is the reaper's business, not the sweeper's.
rc.clearInFlightIdle(m.ID)
@@ -48,13 +48,13 @@ var _ = Describe("ReplicaReconciler — probe reaper vs busy backends", func() {
// seed inserts a stale loaded row so the probe pass picks it up.
seed := func(id string, inFlight int) {
Expect(db.Create(&NodeModel{
ID: id,
NodeID: node.ID,
ModelName: id,
Address: addr,
State: "loaded",
InFlight: inFlight,
UpdatedAt: time.Now().Add(-5 * time.Minute),
ID: id,
NodeID: node.ID,
ModelName: id,
WorkerLocalAddress: addr,
State: "loaded",
InFlight: inFlight,
UpdatedAt: time.Now().Add(-5 * time.Minute),
}).Error).To(Succeed())
}
@@ -46,14 +46,14 @@ var _ = Describe("ReplicaReconciler — leaked in_flight sweeper", func() {
seed := func(id string, inFlight int, idleFor time.Duration) {
Expect(db.Create(&NodeModel{
ID: id,
NodeID: node.ID,
ModelName: id,
Address: addr,
State: "loaded",
InFlight: inFlight,
LastUsed: time.Now().Add(-idleFor),
UpdatedAt: time.Now(),
ID: id,
NodeID: node.ID,
ModelName: id,
WorkerLocalAddress: addr,
State: "loaded",
InFlight: inFlight,
LastUsed: time.Now().Add(-idleFor),
UpdatedAt: time.Now(),
}).Error).To(Succeed())
}
+18 -18
View File
@@ -770,20 +770,20 @@ var _ = Describe("ReplicaReconciler — state reconciliation", func() {
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Two loaded models — one stale (will probe), one fresh (skipped).
stale := &NodeModel{
ID: "stale-1",
NodeID: node.ID,
ModelName: "stale-model",
Address: "10.0.0.1:12345",
State: "loaded",
UpdatedAt: time.Now().Add(-5 * time.Minute),
ID: "stale-1",
NodeID: node.ID,
ModelName: "stale-model",
WorkerLocalAddress: "10.0.0.1:12345",
State: "loaded",
UpdatedAt: time.Now().Add(-5 * time.Minute),
}
fresh := &NodeModel{
ID: "fresh-1",
NodeID: node.ID,
ModelName: "fresh-model",
Address: "10.0.0.1:54321",
State: "loaded",
UpdatedAt: time.Now(), // within probeStaleAfter
ID: "fresh-1",
NodeID: node.ID,
ModelName: "fresh-model",
WorkerLocalAddress: "10.0.0.1:54321",
State: "loaded",
UpdatedAt: time.Now(), // within probeStaleAfter
}
Expect(db.Create(stale).Error).To(Succeed())
Expect(db.Create(fresh).Error).To(Succeed())
@@ -815,12 +815,12 @@ var _ = Describe("ReplicaReconciler — state reconciliation", func() {
node := &BackendNode{Name: "n1", NodeType: NodeTypeBackend, Address: "10.0.0.1:50051"}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
stale := &NodeModel{
ID: "stale-2",
NodeID: node.ID,
ModelName: "alive-model",
Address: "10.0.0.1:12345",
State: "loaded",
UpdatedAt: time.Now().Add(-5 * time.Minute),
ID: "stale-2",
NodeID: node.ID,
ModelName: "alive-model",
WorkerLocalAddress: "10.0.0.1:12345",
State: "loaded",
UpdatedAt: time.Now().Add(-5 * time.Minute),
}
Expect(db.Create(stale).Error).To(Succeed())
@@ -54,13 +54,13 @@ var _ = Describe("ReplicaReconciler — reconcile against worker processes", fun
seed := func(id, modelName string, replica int, age time.Duration) {
Expect(db.Create(&NodeModel{
ID: id,
NodeID: node.ID,
ModelName: modelName,
ReplicaIndex: replica,
Address: "10.0.0.1:12345",
State: "loaded",
UpdatedAt: time.Now().Add(-age),
ID: id,
NodeID: node.ID,
ModelName: modelName,
ReplicaIndex: replica,
WorkerLocalAddress: "10.0.0.1:12345",
State: "loaded",
UpdatedAt: time.Now().Add(-age),
}).Error).To(Succeed())
}
+50 -14
View File
@@ -21,11 +21,24 @@ import (
// Workers are generic — they don't have a fixed backend type.
// The SmartRouter dynamically installs backends via NATS backend.install events.
type BackendNode struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
Name string `gorm:"uniqueIndex;size:255" json:"name"`
NodeType string `gorm:"size:32;default:backend" json:"node_type"` // backend, agent
Address string `gorm:"size:255" json:"address"` // host:port for gRPC
HTTPAddress string `gorm:"size:255" json:"http_address"` // host:port for HTTP file transfer
ID string `gorm:"primaryKey;size:36" json:"id"`
Name string `gorm:"uniqueIndex;size:255" json:"name"`
NodeType string `gorm:"size:32;default:backend" json:"node_type"` // backend, agent
// Address and HTTPAddress are what a PRE-TUNNEL worker advertised as its
// inbound gRPC and HTTP endpoints. Nothing dials them and nothing reads
// them to make a decision: a worker holds one outbound tunnel and every
// protocol the frontend speaks to it travels on that.
//
// A worker running this release sends neither, and Register force-clears
// both ON RE-REGISTRATION so an upgraded worker's stale advertisement does
// not survive its own upgrade and keep showing in the API and the UI. A
// first registration writes what it was given, which is how a spec that
// builds a node with an address still gets one. They are kept as
// columns rather than dropped only because dropping them is a wide,
// mechanical change across the fleet of specs that build a BackendNode,
// and they are inert either way.
Address string `gorm:"size:255" json:"address"`
HTTPAddress string `gorm:"size:255" json:"http_address"`
Status string `gorm:"size:32;default:registering" json:"status"` // registering, healthy, unhealthy, draining, pending
TokenHash string `gorm:"size:64" json:"-"` // SHA-256 of registration token
// TunnelTokenHash is the SHA-256 of this node's OWN tunnel credential, the
@@ -133,14 +146,28 @@ const (
//
// Multiple replicas of the same model on the same node are allowed; each
// replica has its own ReplicaIndex (0..MaxReplicasPerModel-1), its own
// gRPC Address (each replica is a separate worker process on its own port),
// and its own InFlight counter.
// WorkerLocalAddress (each replica is a separate worker process on its own
// port), and its own InFlight counter.
type NodeModel struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
NodeID string `gorm:"index;size:36" json:"node_id"`
ModelName string `gorm:"index;size:255" json:"model_name"`
ReplicaIndex int `gorm:"column:replica_index;default:0;index" json:"replica_index"`
Address string `gorm:"size:255" json:"address"` // gRPC address for this replica's backend process
ID string `gorm:"primaryKey;size:36" json:"id"`
NodeID string `gorm:"index;size:36" json:"node_id"`
ModelName string `gorm:"index;size:255" json:"model_name"`
ReplicaIndex int `gorm:"column:replica_index;default:0;index" json:"replica_index"`
// WorkerLocalAddress is where this replica's backend process listens ON
// ITS WORKER. It is a loopback address and it is not dialable from here.
//
// It survived the removal of every worker address for one reason: the
// frontend still has to say WHICH backend process on a worker it means,
// and the port in this string is how it says it. It travels as the target
// of a stream on that worker's tunnel; the worker reads the port, checks
// it against its own allocator range, and dials its own loopback. Nothing
// in the frontend may treat it as a dial target, which is why it is not
// called Address any more: the old name is what a reader had to already
// know the design to interpret correctly.
//
// The column and the json key stay "address" so no migration and no API
// break rides along with the rename.
WorkerLocalAddress string `gorm:"column:address;size:255" json:"address"`
State string `gorm:"size:32;default:idle" json:"state"` // staging, loading, loaded, unloading, idle
InFlight int `json:"in_flight"` // number of active requests on this replica
LastUsed time.Time `json:"last_used"`
@@ -595,6 +622,15 @@ func (r *NodeRegistry) Register(ctx context.Context, node *BackendNode, autoAppr
return fmt.Errorf("clearing worker VRAM budget for node %s: %w", node.Name, err)
}
}
// Force-clear the advertised addresses. Updates(struct) zero-skips, so a
// node that registered before workers stopped advertising would keep the
// host:port it reported then for the rest of its life, and the API and
// the Nodes page would keep showing an endpoint that nothing dials and
// that may not even exist any more.
if err := r.db.WithContext(ctx).Model(&BackendNode{}).Where("id = ?", node.ID).
Updates(map[string]any{"address": node.Address, "http_address": node.HTTPAddress}).Error; err != nil {
return fmt.Errorf("clearing the advertised addresses for node %s: %w", node.Name, err)
}
// Force-write the disk columns. Updates(struct) above zero-skips, and a
// worker whose models filesystem is 100% full re-registers with
// available_disk == 0 — the single most important reading there is.
@@ -657,7 +693,7 @@ func (r *NodeRegistry) Register(ctx context.Context, node *BackendNode, autoAppr
return fmt.Errorf("looking up node %s: %w", node.Name, err)
}
xlog.Info("Node registered", "name", node.Name, "address", node.Address, "status", node.Status)
xlog.Info("Node registered", "name", node.Name, "id", node.ID, "status", node.Status)
// Cluster capacity may have changed: a new healthy node, a returning
// node, or one with different MaxReplicasPerModel. Wake any configs the
// reconciler put in cooldown — the next tick will re-flag if still
@@ -1452,7 +1488,7 @@ func (r *NodeRegistry) ClaimModelCleanupRetries(ctx context.Context, now, leaseU
func (r *NodeRegistry) RemoveClaimedModelCleanup(ctx context.Context, replica NodeModel) (bool, error) {
result := r.db.WithContext(ctx).
Where("id = ? AND node_id = ? AND model_name = ? AND replica_index = ? AND state = ? AND address = ? AND config_revision = ?",
replica.ID, replica.NodeID, replica.ModelName, replica.ReplicaIndex, "unloading", replica.Address, replica.ConfigRevision).
replica.ID, replica.NodeID, replica.ModelName, replica.ReplicaIndex, "unloading", replica.WorkerLocalAddress, replica.ConfigRevision).
Delete(&NodeModel{})
if result.Error != nil {
return false, result.Error
+31 -5
View File
@@ -56,6 +56,32 @@ var _ = Describe("NodeRegistry", func() {
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
Expect(node.Status).To(Equal(StatusHealthy))
})
It("clears the advertised addresses a pre-tunnel worker left behind", func() {
// The struct update zero-skips, so an upgraded worker that stops
// sending an address would otherwise keep the one it reported before
// the upgrade for the rest of the row's life, and the API and the
// Nodes page would keep offering an endpoint nothing dials and that
// may not exist any more.
ctx := context.Background()
legacy := makeNode("worker-upgraded", "10.0.0.8:50051", 8_000_000_000)
legacy.HTTPAddress = "10.0.0.8:50050"
Expect(registry.Register(ctx, legacy, true)).To(Succeed())
stored, err := registry.GetByName(ctx, "worker-upgraded")
Expect(err).ToNot(HaveOccurred())
Expect(stored.Address).To(Equal("10.0.0.8:50051"), "precondition: the old row carries the advertisement")
// Same name, no address: what the upgraded worker sends.
upgraded := makeNode("worker-upgraded", "", 8_000_000_000)
Expect(registry.Register(ctx, upgraded, true)).To(Succeed())
stored, err = registry.GetByName(ctx, "worker-upgraded")
Expect(err).ToNot(HaveOccurred())
Expect(stored.ID).To(Equal(legacy.ID), "precondition: this is the same row, not a new one")
Expect(stored.Address).To(BeEmpty())
Expect(stored.HTTPAddress).To(BeEmpty())
})
})
Describe("Re-registration", func() {
@@ -167,7 +193,7 @@ var _ = Describe("NodeRegistry", func() {
Expect(err).ToNot(HaveOccurred())
Expect(nm2.ID).To(Equal(nm1.ID), "ID should remain stable across SetNodeModel calls")
Expect(nm2.Address).To(Equal("10.0.0.99:50053"), "Address should be updated")
Expect(nm2.WorkerLocalAddress).To(Equal("10.0.0.99:50053"), "Address should be updated")
})
})
@@ -983,8 +1009,8 @@ var _ = Describe("NodeRegistry", func() {
for _, m := range models {
byIdx[m.ReplicaIndex] = m
}
Expect(byIdx[0].Address).To(Equal("127.0.0.1:50100"))
Expect(byIdx[1].Address).To(Equal("127.0.0.1:50101"))
Expect(byIdx[0].WorkerLocalAddress).To(Equal("127.0.0.1:50100"))
Expect(byIdx[1].WorkerLocalAddress).To(Equal("127.0.0.1:50101"))
Expect(byIdx[0].ID).ToNot(Equal(byIdx[1].ID))
})
@@ -1001,7 +1027,7 @@ var _ = Describe("NodeRegistry", func() {
survivor, err := registry.GetNodeModel(context.Background(), node.ID, "kept-model", 1)
Expect(err).ToNot(HaveOccurred())
Expect(survivor).ToNot(BeNil())
Expect(survivor.Address).To(Equal("127.0.0.1:50111"))
Expect(survivor.WorkerLocalAddress).To(Equal("127.0.0.1:50111"))
// Replica 0 is gone
_, err = registry.GetNodeModel(context.Background(), node.ID, "kept-model", 0)
@@ -1744,7 +1770,7 @@ var _ = Describe("NodeRegistry", func() {
Expect(err).ToNot(HaveOccurred())
Expect(models).To(ConsistOf(And(
HaveField("ConfigRevision", "rev-new"),
HaveField("Address", "10.0.2.20:7001"),
HaveField("WorkerLocalAddress", "10.0.2.20:7001"),
HaveField("State", "loaded"),
)))
})
@@ -54,7 +54,7 @@ var _ = Describe("revision eligibility consumers", func() {
}
Expect(db.Create(&NodeModel{
ID: kind, NodeID: node.ID, ModelName: modelName, ReplicaIndex: i,
Address: kind, State: state, ConfigRevision: revision, LastUsed: time.Now().Add(time.Duration(i) * time.Minute),
WorkerLocalAddress: kind, State: state, ConfigRevision: revision, LastUsed: time.Now().Add(time.Duration(i) * time.Minute),
UpdatedAt: time.Now().Add(-time.Hour),
}).Error).To(Succeed())
}
@@ -148,7 +148,7 @@ var _ = Describe("revision eligibility consumers", func() {
Expect(db.Model(&NodeModel{}).Where("id = ?", "mismatch").Update("replica_index", 9).Error).To(Succeed())
Expect(db.Create(&NodeModel{
ID: "current-extra", NodeID: nodes["current"].ID, ModelName: modelName,
ReplicaIndex: 4, Address: "current-extra", State: "loaded",
ReplicaIndex: 4, WorkerLocalAddress: "current-extra", State: "loaded",
ConfigRevision: "current", LastUsed: time.Now().Add(-time.Hour),
}).Error).To(Succeed())
@@ -214,7 +214,7 @@ var _ = Describe("revision eligibility consumers", func() {
// the minimum and the oldest eligible current row may be selected.
Expect(db.Create(&NodeModel{
ID: "current-extra", NodeID: nodes["current"].ID, ModelName: modelName,
ReplicaIndex: 4, Address: "current-extra", State: "loaded",
ReplicaIndex: 4, WorkerLocalAddress: "current-extra", State: "loaded",
ConfigRevision: "current", LastUsed: time.Now().Add(time.Minute),
}).Error).To(Succeed())
unloader := &fakeUnloader{}
+40 -18
View File
@@ -483,7 +483,7 @@ func (r *SmartRouter) cleanupStaleLoad(ctx context.Context, node *BackendNode, m
}
replica, err := r.registry.GetNodeModel(context.WithoutCancel(ctx), node.ID, modelName, replicaIndex)
if err != nil {
replica = &NodeModel{NodeID: node.ID, ModelName: modelName, ReplicaIndex: replicaIndex, Address: address, State: "unloading", ConfigRevision: revision, EffectiveOptionsHash: hash}
replica = &NodeModel{NodeID: node.ID, ModelName: modelName, ReplicaIndex: replicaIndex, WorkerLocalAddress: address, State: "unloading", ConfigRevision: revision, EffectiveOptionsHash: hash}
}
r.modelCleanup.Cleanup(context.WithoutCancel(ctx), []NodeModel{*replica}, false)
}
@@ -592,9 +592,13 @@ func (r *SmartRouter) ScheduleAndLoadModel(ctx context.Context, modelName string
// RouteResult contains the routing decision.
type RouteResult struct {
Node *BackendNode
Client grpc.Backend
Release func() // Must be called when the request is done (decrements in-flight)
Node *BackendNode
Client grpc.Backend
// WorkerLocalAddress is where the routed replica's backend process listens
// on its worker. Carried so callers that record or log where a model went
// name the process rather than the node, which has no address.
WorkerLocalAddress string
Release func() // Must be called when the request is done (decrements in-flight)
}
// Route finds the best node for the given model and backend type.
@@ -717,12 +721,25 @@ func (r *SmartRouter) tryWarmPath(ctx context.Context, att *routeAttempt) *Route
if err != nil || node == nil {
return nil
}
modelAddr := node.Address
if nm.Address != "" {
modelAddr = nm.Address
}
modelAddr := nm.WorkerLocalAddress
replicaIdx := nm.ReplicaIndex
// A replica row that does not name its backend process cannot be routed
// to. There is no node address left to stand in for it, and naming an
// empty target would make the worker refuse the stream as invalid: a
// refusal that reads as the worker answering about its backend, which is
// evidence this row is not entitled to produce. Fall through to a cold
// load, which either replaces the row or reports a real failure.
if modelAddr == "" {
if err := r.registry.DecrementInFlight(ctx, node.ID, att.trackingKey, replicaIdx); err != nil {
xlog.Warn("Failed to release a reservation for an unnamed replica",
"node", node.ID, "model", att.trackingKey, "replica", replicaIdx, "error", err)
}
xlog.Warn("Loaded replica row names no backend process; cold-loading instead",
"node", node.ID, "model", att.trackingKey, "replica", replicaIdx)
return nil
}
// Verify the backend process is still alive via gRPC health check
alive, probed := r.probeHealth(ctx, node, modelAddr)
if !probed {
@@ -784,7 +801,7 @@ func (r *SmartRouter) tryWarmPath(ctx context.Context, att *routeAttempt) *Route
return nil
}
tracked := NewInFlightTrackingClient(grpcClient, r.registry, node.ID, att.trackingKey, replicaIdx)
return r.newRouteResult(node, att.trackingKey, replicaIdx, grpcClient, tracked)
return r.newRouteResult(node, modelAddr, att.trackingKey, replicaIdx, grpcClient, tracked)
}
// coldLoad schedules the model onto a node and loads it, returning a route to
@@ -801,7 +818,7 @@ func (r *SmartRouter) coldLoad(ctx context.Context, att *routeAttempt, initialIn
r.observePrefix(att.trackingKey, att.observeChain, prefixcache.ReplicaKey{NodeID: result.Node.ID, Replica: result.ReplicaIndex})
tracked := NewInFlightTrackingClient(result.Client, r.registry, result.Node.ID, att.trackingKey, result.ReplicaIndex)
return r.newRouteResult(result.Node, att.trackingKey, result.ReplicaIndex, result.Client, tracked), nil
return r.newRouteResult(result.Node, result.BackendAddr, att.trackingKey, result.ReplicaIndex, result.Client, tracked), nil
}
// newColdLoadContext builds the detached, progress-extended context a cold load
@@ -1354,12 +1371,16 @@ func (r *SmartRouter) installBackendOnNode(ctx context.Context, node *BackendNod
if !reply.Success {
return "", fmt.Errorf("worker replied with error: %s", reply.Error)
}
// Return the backend's gRPC address (per-replica port from worker)
addr := reply.Address
if addr == "" {
addr = node.Address // fallback to node base address
// Where the backend process listens on that worker. There is no node
// address to fall back to any more, and there should not be: a worker
// that reports success without naming the port it started the process
// on has told us nothing routable, and inventing a target would surface
// later as the worker refusing an invalid stream, which reads as
// evidence about the backend rather than about this install.
if reply.WorkerLocalAddress == "" {
return "", fmt.Errorf("worker %s reported backend %q installed but named no address for the process", node.ID, backendType)
}
return addr, nil
return reply.WorkerLocalAddress, nil
})
select {
case <-ctx.Done():
@@ -1992,7 +2013,7 @@ func (r *SmartRouter) probeHealth(ctx context.Context, node *BackendNode, addr s
// disconnect, handler error, validation failure after load) previously left
// in_flight pinned at 1 forever, and every eviction query requires
// in_flight = 0, so that replica's VRAM could never be reclaimed.
func (r *SmartRouter) newRouteResult(node *BackendNode, trackingKey string, replicaIdx int, raw grpc.Backend, tracked *InFlightTrackingClient) *RouteResult {
func (r *SmartRouter) newRouteResult(node *BackendNode, workerLocalAddr, trackingKey string, replicaIdx int, raw grpc.Backend, tracked *InFlightTrackingClient) *RouteResult {
var once sync.Once
release := func() {
once.Do(func() {
@@ -2006,8 +2027,9 @@ func (r *SmartRouter) newRouteResult(node *BackendNode, trackingKey string, repl
}
tracked.OnFirstComplete(release)
return &RouteResult{
Node: node,
Client: tracked,
Node: node,
Client: tracked,
WorkerLocalAddress: workerLocalAddr,
Release: func() {
release()
closeClient(raw)
@@ -52,7 +52,7 @@ var _ = Describe("Eviction against an alias-keyed replica floor", func() {
rowID++
Expect(db.Create(&NodeModel{
ID: fmt.Sprintf("alias-row-%d", rowID), NodeID: node.ID, ModelName: model,
Address: node.Address, State: "loaded", InFlight: 0,
WorkerLocalAddress: node.Address, State: "loaded", InFlight: 0,
LastUsed: time.Now().Add(-idleFor), UpdatedAt: time.Now(),
}).Error).To(Succeed())
}
@@ -52,7 +52,7 @@ var _ = Describe("Eviction under a node selector", func() {
rowID++
Expect(db.Create(&NodeModel{
ID: fmt.Sprintf("row-%d", rowID), NodeID: node.ID, ModelName: model,
Address: node.Address, State: "loaded", InFlight: inFlight,
WorkerLocalAddress: node.Address, State: "loaded", InFlight: inFlight,
LastUsed: time.Now().Add(-idleFor), UpdatedAt: time.Now(),
}).Error).To(Succeed())
}
@@ -112,7 +112,7 @@ var _ = Describe("size-derived remote LoadModel budget", func() {
backend = &holdBackend{}
factory = &holdClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
dir = GinkgoT().TempDir()
})
+1 -1
View File
@@ -51,7 +51,7 @@ var _ = Describe("Route cold-load jobs", func() {
backend = &stubBackend{healthResult: true, loadResult: &pb.Result{Success: true}}
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
})
@@ -71,7 +71,7 @@ var _ = Describe("remote LoadModel deadline", func() {
backend = &deadlineBackend{}
factory = &deadlineClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
})
+1 -1
View File
@@ -73,7 +73,7 @@ var _ = Describe("reaping an abandoned remote load", func() {
reg = &replicaSlotRouter{fakeModelRouter: base, replica: 2}
backend = &failingLoadBackend{}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
})
@@ -50,7 +50,7 @@ var _ = Describe("SmartRouter routing reservation", func() {
newResult := func() *RouteResult {
raw := &stubBackend{}
tracked := NewInFlightTrackingClient(raw, registry, node.ID, "m", 0)
return router.newRouteResult(node, "m", 0, raw, tracked)
return router.newRouteResult(node, "127.0.0.1:50052", "m", 0, raw, tracked)
}
It("releases the reservation when the route is torn down without any inference", func() {
@@ -56,7 +56,7 @@ var _ = Describe("revision-bound load publication", func() {
node = &BackendNode{Name: "revision-worker", NodeType: NodeTypeBackend, Address: "10.0.0.1:50051", TotalVRAM: 64_000_000_000, AvailableVRAM: 64_000_000_000}
Expect(registry.Register(ctx, node, true)).To(Succeed())
backend = &stubBackend{healthResult: true, loadResult: &pb.Result{Success: true}}
unloader = &fakeUnloader{installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"}}
unloader = &fakeUnloader{installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"}}
})
It("quarantines and exactly stops a load that finishes after its revision changes", func() {
@@ -52,8 +52,8 @@ var _ = Describe("Route cold-load staging context", func() {
backend := &stubBackend{loadResult: &pb.Result{Success: true}}
factory := &stubClientFactory{client: backend}
unloader := &fakeUnloader{installReply: &messaging.BackendInstallReply{
Success: true,
Address: "10.0.0.1:9001",
Success: true,
WorkerLocalAddress: "10.0.0.1:9001",
}}
stager := &cancelOnStageStager{}
@@ -97,8 +97,8 @@ var _ = Describe("cold-load staging deadline", func() {
}
factory = &stubClientFactory{client: &stubBackend{loadResult: &pb.Result{Success: true}}}
unloader = &fakeUnloader{installReply: &messaging.BackendInstallReply{
Success: true,
Address: "10.0.0.1:9001",
Success: true,
WorkerLocalAddress: "10.0.0.1:9001",
}}
modelDir = GinkgoT().TempDir()
})
+54 -16
View File
@@ -599,8 +599,8 @@ var _ = Describe("SmartRouter", func() {
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{
Success: true,
Address: "10.0.0.1:9001",
Success: true,
WorkerLocalAddress: "10.0.0.1:9001",
},
}
})
@@ -608,7 +608,7 @@ var _ = Describe("SmartRouter", func() {
Context("model already loaded on a healthy node", func() {
It("returns the client and a release function", func() {
node := &BackendNode{ID: "n1", Name: "node-1", Address: "10.0.0.1:50051"}
nm := &NodeModel{NodeID: "n1", ModelName: "my-model", Address: "10.0.0.1:9001"}
nm := &NodeModel{NodeID: "n1", ModelName: "my-model", WorkerLocalAddress: "10.0.0.1:9001"}
reg.findAndLockNode = node
reg.findAndLockNM = nm
backend.healthResult = true
@@ -750,8 +750,8 @@ var _ = Describe("SmartRouter", func() {
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{
Success: true,
Address: "10.0.0.1:9001",
Success: true,
WorkerLocalAddress: "10.0.0.1:9001",
},
}
})
@@ -912,8 +912,8 @@ var _ = Describe("SmartRouter", func() {
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{
Success: true,
Address: "10.0.0.1:9001",
Success: true,
WorkerLocalAddress: "10.0.0.1:9001",
},
}
})
@@ -1009,15 +1009,15 @@ var _ = Describe("SmartRouter", func() {
factory := &stubClientFactory{client: backend}
unloader := &fakeUnloader{
installReply: &messaging.BackendInstallReply{
Success: true,
Address: "10.0.0.71:9001",
Success: true,
WorkerLocalAddress: "10.0.0.71:9001",
},
}
reg := &fakeModelRouter{
// Step 1: cached model found on old node
findAndLockNode: cachedNode,
findAndLockNM: &NodeModel{NodeID: "n-old", ModelName: "sel-model", Address: "10.0.0.70:9001"},
findAndLockNM: &NodeModel{NodeID: "n-old", ModelName: "sel-model", WorkerLocalAddress: "10.0.0.70:9001"},
// Scheduling config with selector that old node does NOT match
getModelScheduling: &ModelSchedulingConfig{
ModelName: "sel-model",
@@ -1278,7 +1278,7 @@ var _ = Describe("SmartRouter", func() {
started := make(chan struct{}, 5)
release := make(chan struct{})
unloader := &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"},
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"},
}
unloader.installHook = func() {
started <- struct{}{}
@@ -1317,7 +1317,7 @@ var _ = Describe("SmartRouter", func() {
It("does NOT coalesce installs for different (modelID, replica) keys", func() {
node := &BackendNode{ID: "n1", Name: "node-1", Address: "10.0.0.1:50051"}
unloader := &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:50100"},
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:50100"},
}
router := NewSmartRouter(&fakeModelRouter{}, SmartRouterOptions{
Unloader: unloader,
@@ -1332,6 +1332,44 @@ var _ = Describe("SmartRouter", func() {
Expect(err3).ToNot(HaveOccurred())
Expect(unloader.installCalls).To(HaveLen(3))
})
It("returns the address the worker named for the backend process", func() {
node := &BackendNode{ID: "n1", Name: "node-1"}
unloader := &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "127.0.0.1:50100"},
}
router := NewSmartRouter(&fakeModelRouter{}, SmartRouterOptions{
Unloader: unloader,
ClientFactory: &stubClientFactory{client: &stubBackend{}},
})
addr, err := router.installBackendOnNode(context.Background(), node, "llama-cpp", "model-A", 0)
Expect(err).ToNot(HaveOccurred())
Expect(addr).To(Equal("127.0.0.1:50100"))
})
It("fails when the worker reports success but names no address", func() {
// There is no node address left to stand in for it. Substituting one
// used to be the behaviour here, and with workers no longer
// advertising it would substitute the empty string: the frontend
// would then open a stream naming an empty target, the worker would
// refuse it as invalid, and that refusal reads as the WORKER
// answering about its backend rather than as this install having
// produced nothing routable.
node := &BackendNode{ID: "n1", Name: "node-1"}
unloader := &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true},
}
router := NewSmartRouter(&fakeModelRouter{}, SmartRouterOptions{
Unloader: unloader,
ClientFactory: &stubClientFactory{client: &stubBackend{}},
})
addr, err := router.installBackendOnNode(context.Background(), node, "llama-cpp", "model-A", 0)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("named no address"))
Expect(addr).To(BeEmpty())
})
})
})
@@ -1391,7 +1429,7 @@ var _ = Describe("SmartRouter prefix-cache routing", func() {
backend = &stubBackend{healthResult: true}
factory = &stubClientFactory{client: backend}
unloader = &fakeUnloader{
installReply: &messaging.BackendInstallReply{Success: true, Address: "10.0.0.1:9001"},
installReply: &messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "10.0.0.1:9001"},
}
})
@@ -1399,7 +1437,7 @@ var _ = Describe("SmartRouter prefix-cache routing", func() {
// "m" on node "X", plus matching replica stats so buildPreference can run.
loadedReg := func() *fakeModelRouter {
node := &BackendNode{ID: "X", Name: "node-x", Address: "10.0.0.1:50051"}
nm := &NodeModel{NodeID: "X", ModelName: "m", Address: "10.0.0.1:9001"}
nm := &NodeModel{NodeID: "X", ModelName: "m", WorkerLocalAddress: "10.0.0.1:9001"}
return &fakeModelRouter{
findAndLockNode: node,
findAndLockNM: nm,
@@ -1490,7 +1528,7 @@ var _ = Describe("SmartRouter prefix-cache routing", func() {
// node. This is the replica-granular regression this change fixes.
idx := prefixcache.NewIndex(prefixcache.DefaultConfig())
node := &BackendNode{ID: "X", Name: "node-x", Address: "10.0.0.1:50051"}
nm := &NodeModel{NodeID: "X", ModelName: "m", ReplicaIndex: 0, Address: "10.0.0.1:9001"}
nm := &NodeModel{NodeID: "X", ModelName: "m", ReplicaIndex: 0, WorkerLocalAddress: "10.0.0.1:9001"}
reg := &fakeModelRouter{
findAndLockNode: node,
findAndLockNM: nm,
@@ -1569,7 +1607,7 @@ var _ = Describe("SmartRouter prefix-cache routing", func() {
// forced-disturb signal. findAndLockNode returns Y so Route succeeds.
disturbReg := func() *fakeModelRouter {
nodeY := &BackendNode{ID: "Y", Name: "node-y", Address: "10.0.0.2:50051"}
nm := &NodeModel{NodeID: "Y", ModelName: "m", Address: "10.0.0.2:9001"}
nm := &NodeModel{NodeID: "Y", ModelName: "m", WorkerLocalAddress: "10.0.0.2:9001"}
return &fakeModelRouter{
findAndLockNode: nodeY,
findAndLockNM: nm,
@@ -47,7 +47,7 @@ var _ = Describe("routing when the worker cannot be reached at all", func() {
// cold-loaded somewhere else.
loadedReg := func() *fakeModelRouter {
node := &BackendNode{ID: "X", Name: "node-x", Address: "10.0.0.1:50051"}
nm := &NodeModel{NodeID: "X", ModelName: "m", Address: "10.0.0.1:9001"}
nm := &NodeModel{NodeID: "X", ModelName: "m", WorkerLocalAddress: "10.0.0.1:9001"}
return &fakeModelRouter{
findAndLockNode: node,
findAndLockNM: nm,
@@ -96,7 +96,7 @@ var _ = Describe("routing when the worker's tunnel dial fails", func() {
// process and the replica row is deleted after ONE miss.
loadedReg := func() *fakeModelRouter {
node := &BackendNode{ID: "X", Name: "node-x", Address: "10.0.0.1:50051"}
nm := &NodeModel{NodeID: "X", ModelName: "m", Address: "10.0.0.1:9001"}
nm := &NodeModel{NodeID: "X", ModelName: "m", WorkerLocalAddress: "10.0.0.1:9001"}
return &fakeModelRouter{
findAndLockNode: node,
findAndLockNM: nm,
+1 -1
View File
@@ -109,7 +109,7 @@ func (a *RemoteUnloaderAdapter) StopModelReplica(ctx context.Context, nodeID str
reply, err := messaging.RequestJSON[messaging.ModelStopRequest, messaging.ModelStopReply](a.nats, messaging.SubjectNodeModelStop(nodeID), messaging.ModelStopRequest{
ModelName: replica.ModelName,
ProcessKey: model.BackendProcessKey(replica.ModelName, replica.ReplicaIndex),
ExpectedAddress: replica.Address,
ExpectedAddress: replica.WorkerLocalAddress,
Force: force,
ConfigRevision: replica.ConfigRevision,
}, exactModelStopTimeout)
+3 -3
View File
@@ -250,7 +250,7 @@ var _ = Describe("RemoteUnloaderAdapter", func() {
Describe("StopModelReplica", func() {
It("requests an acknowledged stop for the exact process", func() {
mc.requestReply, _ = json.Marshal(messaging.ModelStopReply{Matched: true, Terminated: true, ProcessKey: "llama#2"})
replica := NodeModel{ModelName: "llama", ReplicaIndex: 2, Address: "127.0.0.1:5002", ConfigRevision: "rev-1"}
replica := NodeModel{ModelName: "llama", ReplicaIndex: 2, WorkerLocalAddress: "127.0.0.1:5002", ConfigRevision: "rev-1"}
reply, err := adapter.StopModelReplica(context.Background(), "node-1", replica, true)
Expect(err).NotTo(HaveOccurred())
@@ -344,7 +344,7 @@ func (f *failOnceMessagingClient) Close() {}
var _ = Describe("RemoteUnloaderAdapter timeout configuration", func() {
It("passes the configured install timeout to the messaging client", func() {
mc := newScriptedMessagingClient()
mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true, Address: "127.0.0.1:0"})
mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "127.0.0.1:0"})
adapter := NewRemoteUnloaderAdapter(nil, mc, 7*time.Minute, 11*time.Minute)
_, err := adapter.InstallBackend("n1", "llama-cpp", "", "[]", "", "", "", 0, "", nil)
@@ -394,7 +394,7 @@ var _ = Describe("RemoteUnloaderAdapter NATS timeout handling", func() {
var _ = Describe("RemoteUnloaderAdapter install progress streaming", func() {
It("forwards BackendInstallProgressEvent values into the onProgress callback when the worker publishes them", func() {
mc := newScriptedMessagingClient()
mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true, Address: "127.0.0.1:0"})
mc.scriptReply(messaging.SubjectNodeBackendInstall("n1"), messaging.BackendInstallReply{Success: true, WorkerLocalAddress: "127.0.0.1:0"})
mc.scheduleProgressPublish("n1", "op-abc", []messaging.BackendInstallProgressEvent{
{OpID: "op-abc", NodeID: "n1", Backend: "vllm", FileName: "vllm.tar.zst", Current: "100 MB", Total: "1 GB", Percentage: 10},
{OpID: "op-abc", NodeID: "n1", Backend: "vllm", FileName: "vllm.tar.zst", Current: "500 MB", Total: "1 GB", Percentage: 50},
+55 -48
View File
@@ -1,14 +1,19 @@
package worker
import (
"os"
"strings"
"net"
"strconv"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Worker address resolution", func() {
// advertiseAddr and advertiseHTTPAddr used to be specced here. They are
// gone with the addresses they resolved: a worker advertises nothing. What
// they pinned that still matters is below: the port arithmetic they shared
// with the two functions that survived, and the fact that neither of those
// resolves to anything but this host.
Describe("effectiveBasePort", func() {
DescribeTable("returns the correct port",
func(addr, serve string, want int) {
@@ -25,61 +30,63 @@ var _ = Describe("Worker address resolution", func() {
)
})
Describe("advertiseAddr", func() {
It("returns AdvertiseAddr when set", func() {
cfg := &Config{
AdvertiseAddr: "public.example.com:50051",
Addr: "10.0.0.5:60000",
}
Expect(cfg.advertiseAddr()).To(Equal("public.example.com:50051"))
})
It("returns Addr when set", func() {
cfg := &Config{Addr: "worker1.example.com:60000"}
Expect(cfg.advertiseAddr()).To(Equal("worker1.example.com:60000"))
})
It("falls back to hostname:basePort", func() {
cfg := &Config{ServeAddr: "0.0.0.0:50051"}
got := cfg.advertiseAddr()
_, port, _ := strings.Cut(got, ":")
Expect(port).To(Equal("50051"))
hostname, _ := os.Hostname()
if hostname != "" {
host, _, _ := strings.Cut(got, ":")
Expect(host).To(Equal(hostname))
}
})
})
Describe("resolveHTTPAddr", func() {
DescribeTable("returns the correct address",
func(httpAddr, addr, serve, want string) {
cfg := &Config{HTTPAddr: httpAddr, Addr: addr, ServeAddr: serve}
Expect(cfg.resolveHTTPAddr()).To(Equal(want))
},
// An explicit HTTPAddr is bound exactly as written, wildcard
// included: an operator who asks for a routable bind gets one, and
// the tunnel still reaches it because the http tag ignores the
// target and dials whatever this returned.
Entry("HTTPAddr takes priority", "0.0.0.0:8080", "", "", "0.0.0.0:8080"),
Entry("derives from Addr port minus 1", "", "worker1:60000", "0.0.0.0:50051", "0.0.0.0:59999"),
Entry("derives from ServeAddr port minus 1", "", "", "0.0.0.0:50051", "0.0.0.0:50050"),
Entry("default when nothing set", "", "", "", "0.0.0.0:50050"),
Entry("derives from Addr port minus 1", "", "worker1:60000", "0.0.0.0:50051", "127.0.0.1:59999"),
Entry("derives from ServeAddr port minus 1", "", "", "0.0.0.0:50051", "127.0.0.1:50050"),
Entry("default when nothing set", "", "", "", "127.0.0.1:50050"),
)
It("takes only the port from Addr, never its host", func() {
// The host half of Addr names an interface nothing binds any more.
// A default bind that carried it forward would put the
// file-transfer server back on a routable address.
cfg := &Config{Addr: "0.0.0.0:60000"}
Expect(cfg.resolveHTTPAddr()).To(Equal("127.0.0.1:59999"))
})
})
Describe("advertiseHTTPAddr", func() {
DescribeTable("returns the correct address",
func(advertiseHTTP, advertise, addr, serve, want string) {
cfg := &Config{
AdvertiseHTTPAddr: advertiseHTTP,
AdvertiseAddr: advertise,
Addr: addr,
ServeAddr: serve,
}
Expect(cfg.advertiseHTTPAddr()).To(Equal(want))
},
Entry("AdvertiseHTTPAddr takes priority", "public.example.com:8080", "", "", "", "public.example.com:8080"),
Entry("derives from advertiseAddr host + basePort-1", "", "", "worker1.example.com:60000", "", "worker1.example.com:59999"),
Entry("uses AdvertiseAddr host with basePort-1", "", "public.example.com:60000", "10.0.0.5:60000", "", "public.example.com:59999"),
)
Describe("backendListenAddr", func() {
It("binds a backend process on the host the tunnel dials", func() {
// Not a literal on either side: this asserts the bind is built from
// the same constant the grpc stream tag dials, which is what makes
// "the worker binds where its tunnel dials" true rather than
// coincidental.
Expect(backendListenAddr(50052)).To(Equal(net.JoinHostPort(loopbackHost, strconv.Itoa(50052))))
})
It("binds no wildcard", func() {
// Stated separately from the equality above so a change to
// loopbackHost itself cannot make both pass while publishing every
// backend process on every interface.
host, _, err := net.SplitHostPort(backendListenAddr(50052))
Expect(err).ToNot(HaveOccurred())
ip := net.ParseIP(host)
Expect(ip).ToNot(BeNil(), "the backend bind address must be an IP, not a name that could resolve anywhere")
Expect(ip.IsLoopback()).To(BeTrue(), "backend processes must bind loopback only")
})
})
Describe("registrationBody", func() {
It("advertises no address at all", func() {
// The registration body is one of the three places this worker used
// to state where it could be reached. A key here is not inert: the
// frontend stores it, the API returns it, and the Nodes page shows
// it as an endpoint.
cfg := &Config{NodeName: "w1", Addr: "0.0.0.0:50051", ModelsPath: GinkgoT().TempDir()}
body := cfg.registrationBody()
Expect(body).To(HaveKeyWithValue("name", "w1"))
Expect(body).ToNot(HaveKey("address"))
Expect(body).ToNot(HaveKey("http_address"))
})
})
})
+13 -9
View File
@@ -16,11 +16,16 @@ package worker
//
// Model loading (LoadModel) is always via direct gRPC — no NATS needed for that.
type Config struct {
// Primary address — the reachable address of this worker.
// Host is used for advertise, port is the base for gRPC backends.
// HTTP file transfer runs on port-1.
Addr string `env:"LOCALAI_ADDR" help:"Address where this worker is reachable (host:port). Port is base for gRPC backends, port-1 for HTTP." group:"server"`
ServeAddr string `env:"LOCALAI_SERVE_ADDR" default:"0.0.0.0:50051" help:"(Advanced) gRPC base port bind address" group:"server" hidden:""`
// Addr and ServeAddr are read for their PORT only. A worker binds nothing
// on a routable interface: backend processes and the file-transfer server
// both listen on loopback and are reached through this worker's outbound
// tunnel. The port still matters because it is the base of the backend
// port range (and port-1 is the HTTP server), so an operator who needs a
// different range sets it here. The host half is ignored, and is kept
// accepted rather than rejected so an upgraded worker starts on the
// environment it already had.
Addr string `env:"LOCALAI_ADDR" help:"Base port for this worker, as host:port; only the port is used. Backends take ports upward from it, the HTTP file-transfer server takes port-1. Nothing binds a routable interface." group:"server"`
ServeAddr string `env:"LOCALAI_SERVE_ADDR" default:"0.0.0.0:50051" help:"(Advanced) gRPC base port; only the port is used" group:"server" hidden:""`
// GRPCMaxPort bounds the dynamic gRPC port allocator at [basePort, this].
// The width of that range is how many backend processes this worker can run
@@ -46,12 +51,11 @@ type Config struct {
// anyway; the master can still push the file on demand (existing behaviour).
PrefetchModels []string `env:"LOCALAI_PREFETCH_MODELS,PREFETCH_MODELS" help:"Comma-separated gallery model IDs to download from LOCALAI_GALLERIES at worker boot (e.g. 'llama-3.2-1b-instruct,phi-3-mini-4k'). Skipped if already on disk and SHA matches." group:"server"`
// HTTP file transfer
HTTPAddr string `env:"LOCALAI_HTTP_ADDR" default:"" help:"HTTP file transfer server address (default: gRPC port + 1)" group:"server" hidden:""`
AdvertiseHTTPAddr string `env:"LOCALAI_ADVERTISE_HTTP_ADDR" help:"HTTP address the frontend uses to reach this node for file transfer" group:"server" hidden:""`
// HTTPAddr binds the HTTP file-transfer server. Default is loopback on
// basePort-1; an explicit value is bound exactly as given.
HTTPAddr string `env:"LOCALAI_HTTP_ADDR" default:"" help:"HTTP file transfer server bind address (default: loopback on the gRPC base port - 1)" group:"server" hidden:""`
// Registration (required)
AdvertiseAddr string `env:"LOCALAI_ADVERTISE_ADDR" help:"Address the frontend uses to reach this node (defaults to hostname:port from Addr)" group:"registration" hidden:""`
RegisterTo string `env:"LOCALAI_REGISTER_TO" required:"" help:"Frontend URL for registration" group:"registration"`
NodeName string `env:"LOCALAI_NODE_NAME" help:"Node name for registration (defaults to hostname)" group:"registration"`
RegistrationToken string `env:"LOCALAI_REGISTRATION_TOKEN" help:"Token for authenticating with the frontend" group:"registration"`
+11 -15
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"maps"
"net"
"slices"
"syscall"
@@ -97,20 +96,17 @@ func (s *backendSupervisor) handleBackendInstall(data []byte, reply func([]byte)
return
}
advertiseAddr := addr
advAddr := s.cfg.advertiseAddr()
if advAddr != addr {
_, port, err := net.SplitHostPort(addr)
if err != nil {
xlog.Error("Failed to parse backend listen address; using it unchanged", "addr", addr, "error", err)
} else if advertiseHost, _, err := net.SplitHostPort(advAddr); err != nil {
xlog.Error("Failed to parse worker advertise address; using backend listen address", "addr", advAddr, "error", err)
} else {
advertiseAddr = net.JoinHostPort(advertiseHost, port)
}
}
resp := messaging.BackendInstallReply{Success: true, Address: advertiseAddr}
replyJSON(reply, resp)
// The address goes back exactly as the process listens on it. It used
// to be rewritten onto this worker's advertise host, which made the
// reply the worker's third advertisement site; the frontend now reads
// only the port out of it and dials nothing.
//
// The rewrite was also wrong in a way nothing caught: the worker
// records the loopback address and stopModelExact refuses a stop whose
// ExpectedAddress does not match it, so on any worker whose advertise
// host was not 127.0.0.1 every acknowledged model stop failed with an
// address mismatch.
replyJSON(reply, messaging.BackendInstallReply{Success: true, WorkerLocalAddress: addr})
}()
}
+16 -35
View File
@@ -1,7 +1,6 @@
package worker
import (
"cmp"
"fmt"
"net"
"os"
@@ -21,6 +20,10 @@ var (
// effectiveBasePort returns the port used as base for gRPC backend processes.
// Priority: Addr port → ServeAddr port → 50051
//
// Only the PORT of those settings is read. Their host halves name an interface
// this worker no longer binds: every backend listens on loopback and is reached
// through the tunnel.
func (cfg *Config) effectiveBasePort() int {
for _, addr := range []string{cfg.Addr, cfg.ServeAddr} {
if addr == "" {
@@ -70,45 +73,21 @@ func (cfg *Config) effectiveMaxPort(basePort int) int {
return cfg.GRPCMaxPort
}
// advertiseAddr returns the address the frontend should use to reach this node.
func (cfg *Config) advertiseAddr() string {
if cfg.AdvertiseAddr != "" {
return cfg.AdvertiseAddr
}
if cfg.Addr != "" {
return cfg.Addr
}
hostname, err := os.Hostname()
if err != nil {
xlog.Warn("Failed to determine worker hostname; advertising localhost", "error", err)
}
return fmt.Sprintf("%s:%d", cmp.Or(hostname, "localhost"), cfg.effectiveBasePort())
}
// resolveHTTPAddr returns the address to bind the HTTP file transfer server to.
// Uses basePort-1 so it doesn't conflict with dynamically allocated gRPC ports
// which grow upward from basePort.
//
// The default is loopback for the same reason backend processes are: the
// frontend reaches this server over the tunnel, whose http tag dials whatever
// address this returns. An operator who sets HTTPAddr explicitly still gets
// exactly that bind (see loopbackAddr, which rewrites only a wildcard), so a
// deployment that has some other local reason to expose the server can, and
// nothing in the frontend depends on it.
func (cfg *Config) resolveHTTPAddr() string {
if cfg.HTTPAddr != "" {
return cfg.HTTPAddr
}
return fmt.Sprintf("0.0.0.0:%d", cfg.effectiveBasePort()-1)
}
// advertiseHTTPAddr returns the HTTP address the frontend should use to reach
// this node for file transfer.
func (cfg *Config) advertiseHTTPAddr() string {
if cfg.AdvertiseHTTPAddr != "" {
return cfg.AdvertiseHTTPAddr
}
advertiseAddr := cfg.advertiseAddr()
advHost, _, err := net.SplitHostPort(advertiseAddr)
if err != nil {
xlog.Warn("Invalid worker advertise address; advertising file transfer on localhost", "addr", advertiseAddr, "error", err)
advHost = "localhost"
}
httpPort := cfg.effectiveBasePort() - 1
return net.JoinHostPort(advHost, strconv.Itoa(httpPort))
return net.JoinHostPort(loopbackHost, strconv.Itoa(cfg.effectiveBasePort()-1))
}
// registrationBody builds the JSON body for node registration.
@@ -151,10 +130,12 @@ func (cfg *Config) registrationBody() map[string]any {
if maxReplicas < 1 {
maxReplicas = 1
}
// No address and no http_address: this worker has nothing inbound to
// advertise. It holds one outbound tunnel and the frontend reaches every
// service on it through that, so an address here would be a value that
// looks dialable, is stored, is shown, and is never dialled.
body := map[string]any{
"name": nodeName,
"address": cfg.advertiseAddr(),
"http_address": cfg.advertiseHTTPAddr(),
"total_vram": totalVRAM,
"available_vram": totalVRAM, // initially all VRAM is available
"gpu_vendor": gpuVendor,
+28 -11
View File
@@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"maps"
"net"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"time"
@@ -22,10 +24,26 @@ import (
"github.com/mudler/xlog"
)
// backendListenAddr is where a backend process binds, which is also the only
// address anything ever reaches it on.
//
// It is built from loopbackHost, the constant the tunnel's grpc tag dials, so
// "the worker binds where its tunnel dials" is one fact in one place rather
// than two literals that can drift. A backend is reached only over this
// worker's tunnel; a wildcard bind would publish every backend process on every
// interface to serve a route nothing takes, and on a worker with a public
// interface that is an unauthenticated inference server.
func backendListenAddr(port int) string {
return net.JoinHostPort(loopbackHost, strconv.Itoa(port))
}
// backendProcess represents a single gRPC backend process.
type backendProcess struct {
proc *process.Process
addr string // gRPC address (host:port)
proc *process.Process
// addr is where this process listens, and it is worker-local: see
// backendListenAddr. The frontend is told this string and reads only its
// port out of it.
addr string
port int
stopping bool
// backendName is the gallery backend this process was started for (e.g.
@@ -452,10 +470,9 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
s.mu.Unlock()
return "", fmt.Errorf("allocating gRPC port for backend %s: %w", backend, err)
}
bindAddr := fmt.Sprintf("0.0.0.0:%d", port)
clientAddr := fmt.Sprintf("127.0.0.1:%d", port)
procAddr := backendListenAddr(port)
proc, err := s.ml.StartProcess(backendPath, backend, bindAddr)
proc, err := s.ml.StartProcess(backendPath, backend, procAddr)
if err != nil {
s.releasePortForKey(backend, port)
s.mu.Unlock()
@@ -476,13 +493,13 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
s.processes[backend] = &backendProcess{
proc: proc,
addr: clientAddr,
addr: procAddr,
port: port,
backendName: backendName,
backendDir: backendDir,
backendDirID: dirInfo,
}
xlog.Info("Backend process started", "backend", backend, "addr", clientAddr)
xlog.Info("Backend process started", "backend", backend, "addr", procAddr)
// Capture reference before unlocking for race-safe health check.
// Another goroutine could stopBackend and recycle the port while we poll.
@@ -495,7 +512,7 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
// 4s window made the worker reply Success on a not-yet-listening port,
// which manifested upstream as "connect: connection refused" on the
// frontend's first LoadModel dial.
client := grpc.NewClientWithToken(clientAddr, false, nil, false, s.cfg.RegistrationToken)
client := grpc.NewClientWithToken(procAddr, false, nil, false, s.cfg.RegistrationToken)
const (
readinessPollInterval = 200 * time.Millisecond
readinessTimeout = 30 * time.Second
@@ -514,8 +531,8 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
if !s.backendStartStillValid(backend, bp) {
return "", fmt.Errorf("backend %s was stopped during startup", backend)
}
xlog.Debug("Backend gRPC server is ready", "backend", backend, "addr", clientAddr)
return clientAddr, nil
xlog.Debug("Backend gRPC server is ready", "backend", backend, "addr", procAddr)
return procAddr, nil
}
if healthErr != nil {
lastHealthErr = healthErr
@@ -537,7 +554,7 @@ func (s *backendSupervisor) startBackend(backend, backendName, backendPath strin
// real cause). Stop the half-started process, recycle the port, and
// surface the failure to the caller with the backend's stderr tail.
stderrTail := readLastLinesFromFile(proc.StderrPath(), 20)
xlog.Error("Backend gRPC server not ready before deadline; aborting install", "backend", backend, "addr", clientAddr, "timeout", readinessTimeout, "healthError", lastHealthErr, "stderr", stderrTail)
xlog.Error("Backend gRPC server not ready before deadline; aborting install", "backend", backend, "addr", procAddr, "timeout", readinessTimeout, "healthError", lastHealthErr, "stderr", stderrTail)
if killErr := proc.Stop(); killErr != nil {
xlog.Warn("Failed to stop unready backend process", "backend", backend, "error", killErr)
}
+8 -1
View File
@@ -483,7 +483,14 @@ func (t *Tunnel) logSessionEnded(err error, attempt int, delay time.Duration) {
if errors.As(err, &dialErr) {
switch dialErr.status {
case http.StatusUnauthorized:
xlog.Warn("Frontend rejected this worker's tunnel credential; re-registering will mint a fresh one",
// Named causes, because this worker cannot recover from either on
// its own and the two need different actions. It has no inbound
// listener and no advertised address, so a tunnel it cannot open is
// a worker nothing can reach: this is an outage, not a warning about
// a degraded path.
xlog.Warn("Frontend rejected this worker's tunnel credential, so nothing can reach this worker; "+
"either another worker registered under this node name and rotated the credential (check LOCALAI_NODE_NAME is unique), "+
"or the frontend's record of this node was replaced. Restarting this worker re-registers and mints a fresh credential",
"node", t.nodeID, "retry_in", delay)
case http.StatusForbidden:
xlog.Info("Worker tunnel refused: this node is awaiting admin approval",
+17 -8
View File
@@ -28,7 +28,7 @@ import (
// Run starts the distributed agent worker: registers with the frontend,
// subscribes to NATS lifecycle subjects, and blocks on signals.
func Run(ctx *cliContext.Context, cfg *Config) error {
xlog.Info("Starting worker", "advertise", cfg.advertiseAddr(), "basePort", cfg.effectiveBasePort())
xlog.Info("Starting worker", "basePort", cfg.effectiveBasePort())
// Fail fast (before prefetch/registration/NATS) when enforcement is on but no
// registration token is set: the worker's HTTP file-transfer server fails
@@ -113,14 +113,23 @@ func Run(ctx *cliContext.Context, cfg *Config) error {
// second worker registering under this node's name rotates the row's
// credential, and this worker then fails every tunnel dial with 401 for
// the life of the process. It logs that once per backoff and never
// recovers on its own; a restart fixes it, because startup re-registers
// unconditionally.
// recovers on its own; a restart fixes it only until the other worker
// registers again.
//
// Deliberately NOT fixed here. Re-registering after repeated tunnel
// 401s is a decision about the worker's lifecycle, and it belongs with
// the change that removes this worker's inbound listeners, when a
// worker that cannot tunnel is a worker that cannot be reached at all.
// Today it can still be reached at the addresses it advertises.
// Still deliberately not auto-re-registered, and now for a concrete
// reason rather than a deferral. Register CLEARS this node's NodeModel
// rows, on the assumption that a re-registering worker restarted with
// nothing loaded, so re-registering on a 401 would delete a live
// worker's replica rows on every retry, and under the name collision
// that produces the 401 the two workers would take turns doing it
// forever. That is a credential failure causing model reclamation,
// which is the one outcome this whole design exists to prevent.
//
// The fix belongs to whichever comes first: a re-auth path that mints a
// tunnel credential WITHOUT the rest of registration's side effects, or
// a worker identity that is not the operator-chosen name, which is what
// would make a collision detectable instead of silent. Until then the
// 401 is loud, names both causes, and the operator acts on it.
staticTunnelToken := res.TunnelToken
tunnelToken = func() string { return staticTunnelToken }
connectNats = func() (*messaging.Client, error) {
+14 -7
View File
@@ -104,15 +104,18 @@ services:
- BASE_IMAGE=ubuntu:24.04
command:
- worker
# No HEALTHCHECK_ENDPOINT override is needed: the image's healthcheck
# No published ports and no advertised address: the worker holds one
# outbound tunnel to the frontend and binds only loopback, so nothing has to
# reach into this container.
#
# No HEALTHCHECK_ENDPOINT override is needed either: the image's healthcheck
# detects worker mode and derives the port from LOCALAI_SERVE_ADDR below
# (gRPC base port - 1 = 50050). The worker's /readyz reports 503 while its
# NATS connection is down, so `unhealthy` here means the worker genuinely
# cannot receive work.
# (gRPC base port - 1 = 50050). It runs inside the container, so a loopback
# bind is enough for it. The worker's /readyz reports 503 while its NATS
# connection is down, so `unhealthy` here means the worker genuinely cannot
# receive work.
environment:
LOCALAI_SERVE_ADDR: "0.0.0.0:50051"
LOCALAI_ADVERTISE_ADDR: "worker-1:50051"
LOCALAI_ADVERTISE_HTTP_ADDR: "worker-1:50050"
DEBUG: "true"
LOCALAI_REGISTER_TO: "http://localai:8080"
LOCALAI_NODE_NAME: "worker-1"
@@ -175,7 +178,11 @@ services:
# Copy the worker-1 service above and change:
# - Service name (e.g., worker-2)
# - LOCALAI_NODE_NAME (must be unique)
# - LOCALAI_ADVERTISE_ADDR (must match service name)
#
# Nothing else. A worker has no address to make unique: it binds loopback
# inside its own container and dials out to the frontend. Note that
# LOCALAI_NODE_NAME really must differ: the registry upserts by name, so two
# workers sharing one steal each other's row and each other's tunnel credential.
#
# Workers are generic — no backend type needed. The SmartRouter
# will dynamically install the required backend via NATS when
+29 -24
View File
@@ -179,15 +179,24 @@ That distinction is the whole point rather than a nicety. A scheduler told that
#### There is no frontend-side fallback, and upgrade order matters
`LOCALAI_WORKER_TUNNEL=false` still stops a worker dialling its tunnel, but it no longer has a frontend counterpart: after this change **no frontend path dials a worker's advertised address**, so a worker with the tunnel off is a worker the frontend cannot reach. Setting it is not a rollback. The rollback is to run the previous frontend release.
`LOCALAI_WORKER_TUNNEL=false` still stops a worker dialling its tunnel, but it no longer has a frontend counterpart: **no frontend path dials a worker's advertised address**, and a worker on this release advertises none and listens on no routable interface. A worker with the tunnel off is a worker nothing can reach. Setting it is not a rollback. The rollback is to run the previous release on both sides.
That makes upgrade order matter, in one direction only:
- **Upgrade the workers first, then the frontends.** A worker on the new build dials its tunnel and is reachable by frontends of either version, because the old frontend still dials its advertised address and the worker still listens.
- **Upgrade the workers first, then the frontends.** A worker on this build dials its tunnel, and a frontend of either version reaches it through that. An old frontend that would have dialled its advertised address no longer gets one, so this order is what keeps the fleet routable throughout.
- **Upgrading the frontends first** leaves every not-yet-restarted worker unroutable until it restarts. Those workers keep running their models and keep heartbeating, and the frontend reports them as unroutable rather than as gone: their `node_models` rows are left alone, nothing is rescheduled, and requests for those models fail loudly with "no route" until the worker reconnects. It is a degraded window, not an eviction, but it is a window, and doing it the other way round has none.
A worker that cannot reach its frontend retries with exponential backoff and never gives up, so restarting a worker is all that is needed to close the window.
#### Workers bind nothing routable
A worker on this release opens **no inbound listener on a routable interface**. Its backend gRPC processes and its HTTP file-transfer server all bind loopback, and the frontend reaches both through the tunnel. Concretely:
- **No inbound firewall rule, published port, Service or Ingress is needed for a worker.** A worker needs outbound access to the frontend URL (`LOCALAI_REGISTER_TO`) and to NATS (`LOCALAI_NATS_URL`), and nothing else.
- **`LOCALAI_ADVERTISE_ADDR` and `LOCALAI_ADVERTISE_HTTP_ADDR` are gone.** There is nothing to advertise. Both are ignored if still set; remove them.
- **`LOCALAI_ADDR` and `LOCALAI_SERVE_ADDR` are read for their port only.** The port is the base of the backend port range, and `port-1` is the HTTP file-transfer port. The host half names an interface nothing binds.
- The node's `address` and `http_address` fields in `GET /api/nodes` are empty, and are cleared for nodes that reported them before the upgrade.
### The model load deadline scales with the checkpoint
The `LoadModel` deadline starts *after* the backend is installed and the model files are staged, so it covers only the worker backend's own checkpoint read and pipeline init. That work is proportional to the bytes on disk, which makes any fixed deadline a model-size cliff rather than a timeout: a 70 GB video checkpoint on a Jetson Thor worker failed reproducibly against the old fixed 5m default (`rpc error: code = DeadlineExceeded` after 953.5s of wall clock, roughly 11m of which was backend install and staging), and simply raising the constant would only move the cliff to the next larger model while making a genuinely wedged *small* model hang for the whole inflated duration.
@@ -350,7 +359,9 @@ during installation as well as the committed snapshot.
{{% /notice %}}
{{% notice warning %}}
The worker HTTP file transfer server is authenticated by `LOCALAI_REGISTRATION_TOKEN`. If the token is **empty**, the server **fails open** - anyone who can reach the port gets read/write access to the worker's models/staging/data directories (a remote model-poisoning / exfiltration vector). The worker logs a loud warning at startup in this case. Always set `LOCALAI_REGISTRATION_TOKEN` in distributed mode, and set `LOCALAI_DISTRIBUTED_REQUIRE_AUTH=true` (frontend **and** workers) to make a missing token *or* missing NATS credentials a hard startup error rather than a silent fail-open. Firewall the file-transfer port (gRPC base 1) so only the frontend can reach it.
The worker HTTP file transfer server is authenticated by `LOCALAI_REGISTRATION_TOKEN`. If the token is **empty**, the server **fails open** - anyone who can reach the port gets read/write access to the worker's models/staging/data directories (a remote model-poisoning / exfiltration vector). The worker logs a loud warning at startup in this case. Always set `LOCALAI_REGISTRATION_TOKEN` in distributed mode, and set `LOCALAI_DISTRIBUTED_REQUIRE_AUTH=true` (frontend **and** workers) to make a missing token *or* missing NATS credentials a hard startup error rather than a silent fail-open.
By default the server binds loopback, so "anyone who can reach the port" means a process on the worker host, and no firewall rule is required. Setting `LOCALAI_HTTP_ADDR` to a routable address opts back out of that and puts the fail-open case back on the network - if you do it, firewall the port.
{{% /notice %}}
### Watching Backend Installs
@@ -400,11 +411,10 @@ local-ai worker \
| Flag | Env Var | Default | Description |
|------|---------|---------|-------------|
| `--addr` | `LOCALAI_SERVE_ADDR` | `0.0.0.0:50051` | gRPC listen address |
| `--addr` | `LOCALAI_ADDR` | *(unset)* | Base port for backend gRPC processes. Only the port is used; nothing binds the host |
| `--serve-addr` | `LOCALAI_SERVE_ADDR` | `0.0.0.0:50051` | Same, used when `--addr` is unset |
| `--grpc-max-port` | `LOCALAI_GRPC_MAX_PORT` | `65535` | Highest port the worker may assign to a backend gRPC process. Each backend gets its own port, allocated upward from the base port, so the width of `[base port, this]` caps how many backends this worker can run at once (see [Backend gRPC port range](#backend-grpc-port-range)) |
| `--advertise-addr` | `LOCALAI_ADVERTISE_ADDR` | *(auto)* | Address the frontend uses to reach this node (see below) |
| `--http-addr` | `LOCALAI_HTTP_ADDR` | gRPC port - 1 | HTTP file transfer server bind address |
| `--advertise-http-addr` | `LOCALAI_ADVERTISE_HTTP_ADDR` | *(auto)* | HTTP address the frontend uses for file transfer |
| `--http-addr` | `LOCALAI_HTTP_ADDR` | `127.0.0.1:{gRPC port - 1}` | HTTP file transfer server bind address |
| `--register-to` | `LOCALAI_REGISTER_TO` | *(required)* | Frontend URL for self-registration |
| `--node-name` | `LOCALAI_NODE_NAME` | hostname | Human-readable node name |
| `--registration-token` | `LOCALAI_REGISTRATION_TOKEN` | *(empty)* | Token to authenticate with the frontend |
@@ -424,14 +434,14 @@ local-ai worker \
| `--vram-budget` | `LOCALAI_VRAM_BUDGET` | *(empty)* | Cap the VRAM this node advertises for model placement, as a percentage (e.g. `80%`) or an absolute amount (e.g. `12GB`). Empty uses all detected VRAM. See [Per-node VRAM budget](#per-node-vram-budget). |
{{% notice tip %}}
**Advertise address:** The `--addr` flag is the local bind address for gRPC. The `--advertise-addr` is the address the frontend stores and uses to reach the worker via gRPC. If not set, the worker auto-derives it by replacing `0.0.0.0` with the OS hostname (which in Docker is the container ID, resolvable via Docker DNS). Set `--advertise-addr` explicitly when the auto-detected hostname is not routable from the frontend (e.g., in Kubernetes, use the pod's service DNS name).
**There is no advertise address.** A worker states no endpoint at registration and binds nothing routable; the frontend reaches it through the tunnel it dials. `--advertise-addr` and `--advertise-http-addr` no longer exist. `--addr` and `--http-addr` remain, and set where the worker listens **locally**: only the port of `--addr` is used, and `--http-addr` binds loopback by default.
**HTTP file transfer:** Each worker also runs a small HTTP server for file transfer (model files, configs). By default it listens on the gRPC base port - 1 (e.g., if gRPC base is 50051, HTTP is on 50050). gRPC ports grow upward from the base port as additional models are loaded. Set `--advertise-http-addr` if the auto-detected address is not routable from the frontend.
**HTTP file transfer:** Each worker also runs a small HTTP server for file transfer (model files, configs). It listens on loopback at the gRPC base port - 1 (e.g., if gRPC base is 50051, HTTP is on 50050). gRPC ports grow upward from the base port as additional models are loaded.
{{% /notice %}}
### Worker Health Probes
The worker's HTTP server (base port - 1, default 50050) exposes two unauthenticated probes:
The worker's HTTP server (loopback, base port - 1, default 50050) exposes two unauthenticated probes. They are reachable from the worker host - which is where a container healthcheck runs - and not from the network:
| Endpoint | Meaning |
|----------|---------|
@@ -440,34 +450,29 @@ The worker's HTTP server (base port - 1, default 50050) exposes two unauthentica
`/readyz` reports something the frontend cannot see on its own. The node registry's `status` and `last_heartbeat` are driven by an HTTP heartbeat to the frontend, which is a different network path from NATS — a worker can keep heartbeating while its NATS link is dead, and so appear `healthy` in the registry while being unable to receive any work. The local probe closes that gap.
The container image's `HEALTHCHECK` detects worker mode and probes this endpoint automatically; no `HEALTHCHECK_ENDPOINT` override is needed. Set `HEALTHCHECK_ENDPOINT` only to pin an explicit URL.
The container image's `HEALTHCHECK` detects worker mode and probes this endpoint automatically, deriving the port from `LOCALAI_HTTP_ADDR`, else `LOCALAI_ADDR`, else `LOCALAI_SERVE_ADDR`, minus one - the same order the worker itself uses. No `HEALTHCHECK_ENDPOINT` override is needed. Set `HEALTHCHECK_ENDPOINT` only when the bind address is passed as a CLI flag rather than an environment variable, or to pin an explicit URL.
### Worker Address Configuration
### Worker Port Configuration
The simplest way to configure a worker's network address is with a single variable:
A worker needs no address configuration at all. It binds only loopback and reaches the frontend outbound, so the defaults work behind NAT, in another cluster, or on a laptop:
| Variable | Description |
|----------|-------------|
| `LOCALAI_ADDR` | Reachable address of this worker (`host:port`). The port is used as the base for gRPC backend processes, and `port-1` for the HTTP file transfer server. |
**Example:**
```yaml
environment:
LOCALAI_ADDR: "192.168.1.100:50051"
LOCALAI_NATS_URL: "nats://frontend:4222"
LOCALAI_REGISTER_TO: "http://frontend:8080"
LOCALAI_REGISTRATION_TOKEN: "my-secret"
```
For advanced networking scenarios (NAT, load balancers, separate gRPC/HTTP ports), the following override variables are available:
Set the variables below only to move the worker's **local** port range - for example when two workers share a host, or when the default range collides with something else. Only the port of each is used; the host half names an interface nothing binds.
| Variable | Description | Default |
|----------|-------------|---------|
| `LOCALAI_SERVE_ADDR` | gRPC base port bind address | `0.0.0.0:50051` |
| `LOCALAI_ADDR` | Base port for backend gRPC processes, as `host:port`. `port-1` is the HTTP file-transfer port | *(unset; falls back to `LOCALAI_SERVE_ADDR`)* |
| `LOCALAI_SERVE_ADDR` | Base port, as above, when `LOCALAI_ADDR` is unset | `0.0.0.0:50051` |
| `LOCALAI_GRPC_MAX_PORT` | Highest port assignable to a backend gRPC process | `65535` |
| `LOCALAI_HTTP_ADDR` | HTTP file transfer bind address | `0.0.0.0:{gRPC port - 1}` |
| `LOCALAI_ADVERTISE_ADDR` | Public gRPC address (if different from `LOCALAI_ADDR`) | Derived from `LOCALAI_ADDR` |
| `LOCALAI_ADVERTISE_HTTP_ADDR` | Public HTTP address (if different from gRPC host) | Derived from advertise host + HTTP port |
| `LOCALAI_HTTP_ADDR` | HTTP file transfer bind address. Bound exactly as given, so this is also the way to expose that server deliberately | `127.0.0.1:{base port - 1}` |
`LOCALAI_ADVERTISE_ADDR` and `LOCALAI_ADVERTISE_HTTP_ADDR` no longer exist. They named the endpoint the frontend dialled; nothing dials a worker any more. Remove them.
### Backend gRPC port range
+19 -4
View File
@@ -19,9 +19,9 @@
# 3. The frontend endpoint, when the mode cannot be determined.
#
# Ports are read from environment variables only, which is how containers are
# configured in practice (compose/k8s set LOCALAI_ADDRESS, LOCALAI_SERVE_ADDR,
# ...). If you instead pass the bind address as a CLI flag, set
# HEALTHCHECK_ENDPOINT to match.
# configured in practice (compose/k8s set LOCALAI_ADDRESS, LOCALAI_ADDR,
# LOCALAI_SERVE_ADDR, ...). If you instead pass the bind address as a CLI flag,
# set HEALTHCHECK_ENDPOINT to match.
set -u
# Detect the arguments local-ai was started with. PID 1 is the usual case
@@ -99,9 +99,24 @@ if [ -z "$endpoint" ]; then
# The worker's file-transfer server (which also serves /readyz and
# /healthz) binds LOCALAI_HTTP_ADDR when set, otherwise the gRPC
# base port minus one. See Config.resolveHTTPAddr.
#
# The base port comes from LOCALAI_ADDR first and LOCALAI_SERVE_ADDR
# second, which is Config.effectiveBasePort's own order. Reading
# only the second one meant a worker configured with LOCALAI_ADDR
# (the documented knob; LOCALAI_SERVE_ADDR is marked hidden) was
# probed on the default 50050 while its server sat on a different
# port. That is #10987 again: a working worker reporting
# `unhealthy` forever because the probe went somewhere nothing
# binds.
#
# The worker binds loopback, which is where this probe runs: it runs
# inside the container, so no inbound port is needed for it to work.
port=$(port_of "${LOCALAI_HTTP_ADDR:-}")
if [ -z "$port" ]; then
base=$(port_of "${LOCALAI_SERVE_ADDR:-}")
base=$(port_of "${LOCALAI_ADDR:-}")
if [ -z "$base" ]; then
base=$(port_of "${LOCALAI_SERVE_ADDR:-}")
fi
port=$(( ${base:-50051} - 1 ))
fi
endpoint="http://localhost:${port}/readyz"
+15
View File
@@ -101,6 +101,21 @@ echo "== worker derives the port from LOCALAI_SERVE_ADDR"
run_hc 0 "local-ai worker" LOCALAI_SERVE_ADDR="0.0.0.0:60000"
expect_url "http://localhost:59999/readyz"
echo "== worker derives the port from LOCALAI_ADDR"
# LOCALAI_ADDR is the worker's documented base-port knob (LOCALAI_SERVE_ADDR is
# hidden), and Config.effectiveBasePort reads it FIRST. A probe that ignored it
# went to 50050 while the server sat elsewhere, which is #10987's symptom.
run_hc 0 "local-ai worker" LOCALAI_ADDR="0.0.0.0:60000"
expect_url "http://localhost:59999/readyz"
echo "== LOCALAI_ADDR outranks LOCALAI_SERVE_ADDR, as effectiveBasePort does"
run_hc 0 "local-ai worker" LOCALAI_ADDR="0.0.0.0:60000" LOCALAI_SERVE_ADDR="0.0.0.0:50051"
expect_url "http://localhost:59999/readyz"
echo "== an explicit LOCALAI_HTTP_ADDR still outranks both"
run_hc 0 "local-ai worker" LOCALAI_ADDR="0.0.0.0:60000" LOCALAI_HTTP_ADDR="0.0.0.0:18081"
expect_url "http://localhost:18081/readyz"
echo "== worker honours an explicit LOCALAI_HTTP_ADDR"
run_hc 0 "local-ai worker" LOCALAI_HTTP_ADDR="0.0.0.0:18080"
expect_url "http://localhost:18080/readyz"
+4 -2
View File
@@ -263,10 +263,12 @@ func (c *Cluster) startWorker(i int) (*Process, error) {
"--backends-path", backends,
)
cmd.Env = append(cmd.Environ(),
// Ports only. A worker advertises nothing, so there is no advertise
// address to set; these two exist to keep concurrently running workers
// off each other's ports, not to make anything reachable. Both binds
// are loopback whatever is set here.
fmt.Sprintf("LOCALAI_SERVE_ADDR=127.0.0.1:%d", grpcPort),
fmt.Sprintf("LOCALAI_ADVERTISE_ADDR=127.0.0.1:%d", grpcPort),
fmt.Sprintf("LOCALAI_HTTP_ADDR=127.0.0.1:%d", httpPort),
fmt.Sprintf("LOCALAI_ADVERTISE_HTTP_ADDR=127.0.0.1:%d", httpPort),
// Workers register with frontend 0 ONLY unless the caller opts into
// SpreadWorkerRegistrations, and the cross-replica session specs depend
// on that default. They prove a session minted at frontend 0 resolves at
@@ -33,7 +33,7 @@ func (s *revisionCleanupStopper) StopModelReplica(_ context.Context, nodeID stri
Matched: true,
Terminated: true,
ProcessKey: replica.ModelName,
Address: replica.Address,
Address: replica.WorkerLocalAddress,
}, nil
}