Files
LocalAI/tests/e2e/distributed/router_tracking_test.go
Ettore Di Giacinto fffb4d9aec feat(distributed): stop a backend on one route, whatever the worker is
nodes.<id>.backend.stop was the last worker-facing NATS subject, and it existed
only because ONE publisher had not moved. An agent worker already mounted
workerctl.PathBackendStop on the tunnel it holds, and a backend worker already
took its stop there, so RemoteUnloaderAdapter branched on NodeType to pick a
carrier for a verb both kinds of worker served the same way.

The branch is gone, and with it nodeTypeOf and its NodeTypeBackend default,
which removes one of the ten NodeType branches left to sweep. The adapter loses
its messaging.MessagingClient outright rather than keeping an unused field: it
now holds no publisher, so re-routing any verb back onto the bus is a change to
the struct and to every caller of the constructor, and does not compile until
all of them agree. messaging.SubjectNodeBackendStop and subjectNodePrefix are
deleted, the agent worker's subscription with them.

pkg/natsauth drops the per-node backend.stop grant from the agent SUB list. That
is a narrowing of eleven entries to ten, never to nothing: NATS reads an EMPTY
allow list as unrestricted, so the coverage spec asserts both that the retired
subject is no longer covered and that the queue subjects an agent worker lives
on still are. The e2e half proves it against a real enforcing server: one spec
subscribes successfully on an agent-minted JWT, the next is refused the retired
subject on a JWT minted the same way.

Both halves of the old split were pinned, so both pins are re-aimed rather than
deleted, and the two node types are asserted separately rather than as one
parameterised case, because only two cases can show that the two used to differ.
Three assertions that the adapter published nothing are deleted instead: with no
publisher to hold, no change could ever redden them.

The CLI's handler set moves into agentWorkerControlHandlers so a spec can stand
it up and post to it. That wiring was a bare literal no spec pinned, and
deleting the subscription made it the ONLY carrier for backend.stop: a dropped
field would have been a 404 the frontend reads as a worker too old to serve the
verb, and nothing in the repo would have noticed.

Mutations: the agent branch restored off the control route reddens two specs;
the backend branch restored, separately, reddens five; PathBackendDelete in
place of PathBackendStop reddens nine across both node types; dropping the CLI
wiring line reddens the new wiring table; re-adding the allow-list entry reddens
the unit spec and the JWT e2e spec; and restoring the publisher for real does
not compile.

Four comments this change falsified are fixed, in core/cli, pkg/model and the
distributed-mode docs, which now say both kinds of worker serve
POST /v1/control/backend/stop and what each does with it.

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

212 lines
7.2 KiB
Go

package distributed_test
import (
"context"
"time"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/pkg/grpc/base"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
grpcPkg "github.com/mudler/LocalAI/pkg/grpc"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
pgdriver "gorm.io/driver/postgres"
gormDB "gorm.io/gorm"
"gorm.io/gorm/logger"
)
// trackingTestLLM is a minimal gRPC backend for router tracking tests.
type trackingTestLLM struct {
base.Base
loaded bool
}
func (t *trackingTestLLM) Load(opts *pb.ModelOptions) error {
t.loaded = true
return nil
}
func (t *trackingTestLLM) Predict(opts *pb.PredictOptions) (string, error) {
return "ok", nil
}
var _ = Describe("SmartRouter trackingKey", Label("Distributed"), func() {
var (
infra *TestInfra
db *gormDB.DB
registry *nodes.NodeRegistry
router *nodes.SmartRouter
grpcCleanup func()
grpcAddr string
nodeID string
)
BeforeEach(func() {
infra = SetupInfra("localai_tracking_test")
var err error
db, err = gormDB.Open(pgdriver.Open(infra.PGURL), &gormDB.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
Expect(err).ToNot(HaveOccurred())
registry, err = nodes.NewNodeRegistry(db)
Expect(err).ToNot(HaveOccurred())
// Mock control plane. The install reply names where the backend process
// listens on that worker, which is what the frontend routes to now that
// a worker advertises no address of its own.
workers := NewControlWorkers()
workers.ServeBackendLifecycle(registry)
// Start a mock gRPC backend using the same helper as full flow tests
llm := &trackingTestLLM{}
grpcAddr, grpcCleanup, err = startTestGRPCServer(grpcPkg.AIModel(llm))
Expect(err).ToNot(HaveOccurred())
// Register a node whose backend process is the mock server above. The
// address is the spec's own record of where that process listens; the
// fake worker reports it back on install, and nothing in production
// reads this column any more.
node := &nodes.BackendNode{
Name: "tracking-node", Address: grpcAddr,
}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
nodeID = node.ID
unloader := nodes.NewRemoteUnloaderAdapter(registry, workers.Client(), 3*time.Minute, 15*time.Minute)
router = nodes.NewSmartRouter(registry, nodes.SmartRouterOptions{
Unloader: unloader,
// Without a worker dialer the default factory refuses every
// request (nodes.ErrNoWorkerDialer), which is a boot-time
// misconfiguration rather than a routing outcome any of these
// specs is about.
ClientFactory: tunnelBackendClients(),
})
})
AfterEach(func() {
if grpcCleanup != nil {
grpcCleanup()
}
})
It("records model under modelID when modelID is provided", func() {
result, err := router.Route(infra.Ctx, "my-model-id", "path/to/model.gguf", "llama-cpp", "",
&pb.ModelOptions{ModelFile: "path/to/model.gguf"}, false)
Expect(err).ToNot(HaveOccurred())
defer result.Release()
// The DB should have the model tracked under "my-model-id"
nodesWithModel, err := registry.FindNodesWithModel(context.Background(), "my-model-id")
Expect(err).ToNot(HaveOccurred())
Expect(nodesWithModel).To(HaveLen(1))
Expect(nodesWithModel[0].ID).To(Equal(nodeID))
})
It("records model under modelName when modelID is empty (backward compat)", func() {
result, err := router.Route(infra.Ctx, "", "legacy/model.bin", "llama-cpp", "",
&pb.ModelOptions{ModelFile: "legacy/model.bin"}, false)
Expect(err).ToNot(HaveOccurred())
defer result.Release()
// The DB should have the model tracked under the modelName
nodesWithModel, err := registry.FindNodesWithModel(context.Background(), "legacy/model.bin")
Expect(err).ToNot(HaveOccurred())
Expect(nodesWithModel).To(HaveLen(1))
})
It("FindNodesWithModel(modelID) finds node; FindNodesWithModel(modelName) does not", func() {
result, err := router.Route(infra.Ctx, "distinct-id", "distinct/path.gguf", "llama-cpp", "",
&pb.ModelOptions{ModelFile: "distinct/path.gguf"}, false)
Expect(err).ToNot(HaveOccurred())
defer result.Release()
// Should find by modelID
found, err := registry.FindNodesWithModel(context.Background(), "distinct-id")
Expect(err).ToNot(HaveOccurred())
Expect(found).To(HaveLen(1))
// Should NOT find by modelName (different from modelID)
notFound, err := registry.FindNodesWithModel(context.Background(), "distinct/path.gguf")
Expect(err).ToNot(HaveOccurred())
Expect(notFound).To(BeEmpty())
})
It("InFlight tracking increments and decrements via registry", func() {
// Route to establish model record
result, err := router.Route(infra.Ctx, "release-model", "release/path.gguf", "llama-cpp", "",
&pb.ModelOptions{ModelFile: "release/path.gguf"}, false)
Expect(err).ToNot(HaveOccurred())
defer result.Release()
// Read the baseline in-flight count (Route sets initialInFlight=1, decremented after first inference)
models, err := registry.GetNodeModels(context.Background(), nodeID)
Expect(err).ToNot(HaveOccurred())
var baseline int
for _, m := range models {
if m.ModelName == "release-model" {
baseline = m.InFlight
}
}
// Manually increment in-flight (simulates what InFlightTrackingClient.track() does during inference)
Expect(registry.IncrementInFlight(context.Background(), nodeID, "release-model", 0)).To(Succeed())
// Check in-flight increased
models, err = registry.GetNodeModels(context.Background(), nodeID)
Expect(err).ToNot(HaveOccurred())
var inflight int
for _, m := range models {
if m.ModelName == "release-model" {
inflight = m.InFlight
}
}
Expect(inflight).To(Equal(baseline + 1))
// Decrement and check in-flight goes back to baseline
Expect(registry.DecrementInFlight(context.Background(), nodeID, "release-model", 0)).To(Succeed())
models, err = registry.GetNodeModels(context.Background(), nodeID)
Expect(err).ToNot(HaveOccurred())
for _, m := range models {
if m.ModelName == "release-model" {
Expect(m.InFlight).To(Equal(baseline))
}
}
})
It("clears stale model record when node is unreachable", func() {
// First route to establish the model record
result, err := router.Route(infra.Ctx, "stale-check", "stale/path.gguf", "llama-cpp", "",
&pb.ModelOptions{ModelFile: "stale/path.gguf"}, false)
Expect(err).ToNot(HaveOccurred())
result.Release()
// Model should be in DB
found, err := registry.FindNodesWithModel(context.Background(), "stale-check")
Expect(err).ToNot(HaveOccurred())
Expect(found).To(HaveLen(1))
// Stop the gRPC server to make the node unreachable
grpcCleanup()
grpcCleanup = nil
// Route again — should detect unreachable node and clear stale record
// (it will fall through to FindLeastLoadedNode + backend.install which succeeds,
// but the LoadModel gRPC call will fail since the server is down)
_, err = router.Route(infra.Ctx, "stale-check", "stale/path.gguf", "llama-cpp", "",
&pb.ModelOptions{ModelFile: "stale/path.gguf"}, false)
// Expect an error since the only node is down (LoadModel fails)
Expect(err).To(HaveOccurred())
// The stale model record should have been cleared
found, err = registry.FindNodesWithModel(context.Background(), "stale-check")
Expect(err).ToNot(HaveOccurred())
Expect(found).To(BeEmpty())
})
})