Files
LocalAI/tests/e2e/distributed/distributed_full_flow_test.go
T
Ettore Di Giacinto e29a4e5d15 feat(cluster): reach every worker through its tunnel, never its address
The tunnel, the fence, the registry and the relay were all built and none of
them carried a byte: every dial from the frontend still went to the address a
worker registered. This is where that stops. One WorkerDialer resolves where a
worker's tunnel is held, opens a stream on it locally or relays through the
owning replica, and hands back a conn past both handshakes; gRPC, the file
stager's HTTP client and the log-streaming WebSocket are all pointed at it.

A worker's address stops being somewhere to connect to and becomes the name of
which backend process a stream is for. It still appears in URLs, logs and
errors, because that is what identifies the process; what it no longer decides
is where the bytes go.

Nothing falls back to dialling it. BackendClientFactory now has exactly one
method, NewClientForNode, and returns an error where there is no way to reach
the worker. The direct-dial constructor was removed rather than kept beside it,
because leaving one on the interface keeps the bypass one word away from every
call site that holds an address, which is all of them.

The second construction path is closed too. DistributedModelStore built remote
models with a nil client, and pkg/model.Model.GRPC then dialled the raw address
lazily on first use - reached in production by ShutdownModel's Free and by the
backend monitor's Status. Those models now carry the tunnel-backed client, and
a model that cannot be given one is logged and not listed.

Four conditions stay unmixable, and one path produces absence: the dialer
answers ErrNoConnection only where Owner's liveness join did. A peer that will
not answer, a stale ownership row, a worker's own refusal and a missing relay
path are each reported as themselves. This matters because nodes ACTS on
absence, and the collapse would have it reclaim the models of a worker that is
connected and busy.

That is not hypothetical. Writing the mutation for it exposed the bug in this
change's own first draft: probeHealth returned bare false when it could not
build a client, and tryWarmPath deletes the replica row on a false probe. A
frontend whose dialer broke would have emptied node_models for the whole
deployment while every model kept running. probeHealth now returns alive and
probed separately, the reconciler gets a ProbeUnknown outcome that neither
advances nor clears a failure streak, and the health monitor skips rather than
counting a miss.

Task 5 left the relay's open timeout at a fixed 15s and said so: no operator
has the information to set it, because the number that matters is the original
client's remaining budget, which is invisible on the relay side. The dialer has
that budget, so it now states it in the relay request frame and the owner takes
the smaller of the two. It can only shorten - a patient client must not be able
to park a relay goroutine and a stream slot on a worker that stopped accepting.
Zero is written as no budget at all, since on the far side the number zero is a
caller with nothing left and would refuse healthy traffic.

Seven mutations, each reddening a named spec: peer-unreachable as absence; the
local-failure guard dropped; max instead of min on the budget; the nil-client
model restored; ProbeUnknown falling through to the reaper; OwnerRow instead of
Owner; probed collapsed into alive. The first budget spec passed for the wrong
reason - a handshake deadline, not the relay - and was replaced by three that
each assert one link, including one where the spec plays the owning replica and
reads the budget out of the frame instead of inferring it from a clock.

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

1002 lines
35 KiB
Go

package distributed_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAI/core/services/messaging"
"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"
"github.com/nats-io/nats.go"
"google.golang.org/grpc"
pgdriver "gorm.io/driver/postgres"
gormDB "gorm.io/gorm"
"gorm.io/gorm/logger"
)
// testLLM is a minimal AIModel implementation for testing.
// Override methods to write output to Dst so we can test the full
// FileStagingClient round-trip (upload inputs + download outputs).
type testLLM struct {
base.Base
loaded bool
lastModel string
// dstOutput is the content written to any Dst path by output-producing methods.
dstOutput []byte
// lastSrc records the last Src/input path seen (for verifying staging rewrote it).
lastSrc string
// lastVideoAudio records the staged Audio path from GenerateVideo.
lastVideoAudio string
// lastAudioDst records the Dst field from AudioTranscription (it's an input, not output).
lastAudioDst string
// lastTTSModel records the Model field from TTS requests (for verifying path rewriting).
lastTTSModel string
}
func (t *testLLM) Load(opts *pb.ModelOptions) error {
t.loaded = true
t.lastModel = opts.ModelFile
return nil
}
func (t *testLLM) Predict(opts *pb.PredictOptions) (string, error) {
if !t.loaded {
return "", fmt.Errorf("model not loaded")
}
return "test response from remote node", nil
}
func (t *testLLM) GenerateImage(req *pb.GenerateImageRequest) error {
t.lastSrc = req.Src
if req.Dst != "" && len(t.dstOutput) > 0 {
return os.WriteFile(req.Dst, t.dstOutput, 0644)
}
return nil
}
func (t *testLLM) GenerateVideo(req *pb.GenerateVideoRequest) error {
t.lastSrc = req.StartImage
t.lastVideoAudio = req.Audio
if req.Dst != "" && len(t.dstOutput) > 0 {
return os.WriteFile(req.Dst, t.dstOutput, 0644)
}
return nil
}
func (t *testLLM) TTS(req *pb.TTSRequest) error {
t.lastTTSModel = req.Model
if req.Dst != "" && len(t.dstOutput) > 0 {
return os.WriteFile(req.Dst, t.dstOutput, 0644)
}
return nil
}
func (t *testLLM) SoundGeneration(req *pb.SoundGenerationRequest) error {
if req.Src != nil {
t.lastSrc = *req.Src
}
if req.Dst != "" && len(t.dstOutput) > 0 {
return os.WriteFile(req.Dst, t.dstOutput, 0644)
}
return nil
}
func (t *testLLM) AudioTranscription(_ context.Context, req *pb.TranscriptRequest) (pb.TranscriptResult, error) {
t.lastAudioDst = req.Dst
return pb.TranscriptResult{Text: "transcribed text"}, nil
}
// startTestGRPCServer starts a real gRPC backend server on a free port
// and returns the address and cleanup function.
func startTestGRPCServer(llm grpcPkg.AIModel) (string, func(), error) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", nil, err
}
addr := lis.Addr().String()
s := grpc.NewServer(
grpc.MaxRecvMsgSize(50*1024*1024),
grpc.MaxSendMsgSize(50*1024*1024),
)
pb.RegisterBackendServer(s, grpcPkg.NewBackendServer(llm))
go func() {
defer GinkgoRecover()
_ = s.Serve(lis)
}()
cleanup := func() {
s.GracefulStop()
}
return addr, cleanup, nil
}
// startTestHTTPFileServer starts a test HTTP file transfer server (mirroring serve_backend_http.go)
// on a free port and returns the address and cleanup function.
func startTestHTTPFileServer(stagingDir string) (string, func(), error) {
if err := os.MkdirAll(stagingDir, 0750); err != nil {
return "", nil, err
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/files/", func(w http.ResponseWriter, r *http.Request) {
key := r.URL.Path[len("/v1/files/"):]
switch r.Method {
case http.MethodPut:
safeName := filepath.Base(key)
dstPath := filepath.Join(stagingDir, safeName)
f, err := os.Create(dstPath)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
io.Copy(f, r.Body)
f.Close()
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"local_path":%q}`, dstPath)
case http.MethodGet:
safeName := filepath.Base(key)
srcPath := filepath.Join(stagingDir, safeName)
if _, statErr := os.Stat(srcPath); os.IsNotExist(statErr) {
// AllocRemoteTemp creates files under stagingDir/tmp/
srcPath = filepath.Join(stagingDir, "tmp", safeName)
}
f, err := os.Open(srcPath)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
io.Copy(w, f)
case http.MethodPost:
if key == "temp" {
tmpDir := filepath.Join(stagingDir, "tmp")
os.MkdirAll(tmpDir, 0750)
f, err := os.CreateTemp(tmpDir, "output-*")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
localPath := f.Name()
f.Close()
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"local_path":%q}`, localPath)
} else {
http.Error(w, "not found", http.StatusNotFound)
}
}
})
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", nil, err
}
httpAddr := lis.Addr().String()
srv := &http.Server{Handler: mux}
go srv.Serve(lis)
cleanup := func() {
srv.Close()
}
return httpAddr, cleanup, nil
}
var _ = Describe("Full Distributed Inference Flow", Label("Distributed"), func() {
var (
infra *TestInfra
cancel context.CancelFunc
ctx context.Context
db *gormDB.DB
registry *nodes.NodeRegistry
)
BeforeEach(func() {
infra = SetupInfra("localai_fullflow_test")
ctx, cancel = context.WithTimeout(infra.Ctx, 2*time.Minute)
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())
})
AfterEach(func() {
cancel()
})
// newTestSmartRouter creates a SmartRouter with NATS wired up and a mock
// backend.install handler that always replies success for all registered nodes.
newTestSmartRouter := func(reg *nodes.NodeRegistry, extraOpts ...nodes.SmartRouterOptions) *nodes.SmartRouter {
unloader := nodes.NewRemoteUnloaderAdapter(reg, infra.NC, 3*time.Minute, 15*time.Minute)
opts := nodes.SmartRouterOptions{
Unloader: unloader,
}
if len(extraOpts) > 0 {
o := extraOpts[0]
if o.FileStager != nil {
opts.FileStager = o.FileStager
}
if o.GalleriesJSON != "" {
opts.GalleriesJSON = o.GalleriesJSON
}
if o.AuthToken != "" {
opts.AuthToken = o.AuthToken
}
if o.DB != nil {
opts.DB = o.DB
}
}
router := nodes.NewSmartRouter(reg, opts)
// Subscribe a mock backend.install handler that replies success for any node.
// We use a wildcard-style approach: subscribe to all nodes' install subjects
// by registering after each node. In practice, we rely on the test registering
// nodes before calling Route, so we subscribe to a catch-all pattern.
infra.NC.Conn().Subscribe("nodes.*.backend.install", func(msg *nats.Msg) {
reply := messaging.BackendInstallReply{Success: true}
data, _ := json.Marshal(reply)
msg.Respond(data)
})
_, err := infra.NC.Conn().Subscribe("nodes.*.models.running", func(msg *nats.Msg) {
data, _ := json.Marshal(messaging.ModelsRunningReply{})
_ = msg.Respond(data)
})
Expect(err).NotTo(HaveOccurred())
FlushNATS(infra.NC)
return router
}
// suppress unused warning in case some tests don't call it
_ = newTestSmartRouter
It("should route inference to a registered node with a real gRPC backend", func() {
// 1. Start a mock gRPC backend
llm := &testLLM{}
addr, cleanup, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
// 2. Register it as a node
node := &nodes.BackendNode{
Name: "test-gpu-1",
Address: addr,
}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// 3. Create SmartRouter and route a request
router := newTestSmartRouter(registry)
// The model is not loaded yet, so Route will pick the node and call LoadModel
result, err := router.Route(ctx, "", "test-model", "llama-cpp", "", &pb.ModelOptions{
Model: "test-model",
}, false)
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
Expect(result.Node.Name).To(Equal("test-gpu-1"))
// 4. Verify the model was loaded on the backend
Expect(llm.loaded).To(BeTrue())
// 5. Use the client to call Predict
reply, err := result.Client.Predict(ctx, &pb.PredictOptions{
Prompt: "Hello world",
})
Expect(err).ToNot(HaveOccurred())
Expect(string(reply.Message)).To(Equal("test response from remote node"))
// 6. Release and verify in-flight decremented
result.Release()
models, err := registry.GetNodeModels(context.Background(), node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(1))
Expect(models[0].InFlight).To(Equal(0))
// 7. Verify model recorded as "loaded" in registry
Expect(models[0].State).To(Equal("loaded"))
Expect(models[0].ModelName).To(Equal("test-model"))
})
It("should load-balance across multiple nodes with same model", func() {
// Start two mock gRPC backends
llm1 := &testLLM{}
addr1, cleanup1, err := startTestGRPCServer(llm1)
Expect(err).ToNot(HaveOccurred())
defer cleanup1()
llm2 := &testLLM{}
addr2, cleanup2, err := startTestGRPCServer(llm2)
Expect(err).ToNot(HaveOccurred())
defer cleanup2()
// Register both nodes
node1 := &nodes.BackendNode{Name: "node-heavy", Address: addr1}
node2 := &nodes.BackendNode{Name: "node-light", Address: addr2}
Expect(registry.Register(context.Background(), node1, true)).To(Succeed())
Expect(registry.Register(context.Background(), node2, true)).To(Succeed())
// Set both as having the model loaded
Expect(registry.SetNodeModel(context.Background(), node1.ID, "test-model", 0, "loaded", "", 0)).To(Succeed())
Expect(registry.SetNodeModel(context.Background(), node2.ID, "test-model", 0, "loaded", "", 0)).To(Succeed())
// Set node-1 with high in-flight (5), node-2 with low in-flight (1)
for range 5 {
Expect(registry.IncrementInFlight(context.Background(), node1.ID, "test-model", 0)).To(Succeed())
}
Expect(registry.IncrementInFlight(context.Background(), node2.ID, "test-model", 0)).To(Succeed())
// Route should pick node-2 (least loaded) thanks to ORDER BY in_flight ASC
router := newTestSmartRouter(registry)
result, err := router.Route(ctx, "", "test-model", "llama-cpp", "", nil, false)
Expect(err).ToNot(HaveOccurred())
Expect(result.Node.Name).To(Equal("node-light"))
result.Release()
})
It("should load model on empty node when no node has it", func() {
// Start a mock gRPC backend
llm := &testLLM{}
addr, cleanup, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
// Register a node with NO models loaded
node := &nodes.BackendNode{Name: "empty-node", Address: addr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Route should pick this node and call LoadModel on it
router := newTestSmartRouter(registry)
result, err := router.Route(ctx, "", "new-model", "llama-cpp", "", &pb.ModelOptions{
Model: "new-model",
}, false)
Expect(err).ToNot(HaveOccurred())
Expect(result.Node.Name).To(Equal("empty-node"))
Expect(llm.loaded).To(BeTrue())
// Verify model is now recorded in registry
models, err := registry.GetNodeModels(context.Background(), node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(HaveLen(1))
Expect(models[0].ModelName).To(Equal("new-model"))
Expect(models[0].State).To(Equal("loaded"))
result.Release()
})
It("should unload remote model via NATS", func() {
// Register a node with a loaded model
node := &nodes.BackendNode{Name: "gpu-unload", Address: "127.0.0.1:50099"}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
Expect(registry.SetNodeModel(context.Background(), node.ID, "old-model", 0, "loaded", "", 0)).To(Succeed())
// Subscribe to NATS backend.stop for this node
stopSubject := messaging.SubjectNodeBackendStop(node.ID)
received := make(chan struct{}, 1)
rawConn, err := nats.Connect(infra.NatsURL)
Expect(err).ToNot(HaveOccurred())
defer rawConn.Close()
_, err = rawConn.Subscribe(stopSubject, func(msg *nats.Msg) {
received <- struct{}{}
})
Expect(err).ToNot(HaveOccurred())
// Create RemoteUnloaderAdapter and unload model
unloader := nodes.NewRemoteUnloaderAdapter(registry, infra.NC, 3*time.Minute, 15*time.Minute)
err = unloader.UnloadRemoteModel("old-model")
Expect(err).ToNot(HaveOccurred())
// Verify NATS event received
Eventually(received, 5*time.Second).Should(Receive())
// Verify model removed from registry
models, err := registry.GetNodeModels(context.Background(), node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(models).To(BeEmpty())
})
It("should integrate ModelRouterAdapter with SmartRouter end-to-end", func() {
// Start a mock gRPC backend
llm := &testLLM{}
addr, cleanup, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanup()
// Register node
node := &nodes.BackendNode{Name: "adapter-node", Address: addr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Create SmartRouter + ModelRouterAdapter
router := newTestSmartRouter(registry)
adapter := nodes.NewModelRouterAdapter(router)
// Call adapter.Route() (same signature ModelLoader uses)
m, err := adapter.Route(ctx, "llama-cpp", "test-model-id", "test-model", "", "",
&pb.ModelOptions{Model: "test-model"}, false)
Expect(err).ToNot(HaveOccurred())
Expect(m).ToNot(BeNil())
// Verify returned Model has correct ID and nil process (remote)
Expect(m.ID).To(Equal("test-model-id"))
Expect(m.Process()).To(BeNil())
// Verify the model was loaded on the backend
Expect(llm.loaded).To(BeTrue())
// Use the Model's GRPC() method to get a client and verify inference works
client := m.GRPC(false, nil)
Expect(client).ToNot(BeNil())
reply, err := client.Predict(ctx, &pb.PredictOptions{Prompt: "test"})
Expect(err).ToNot(HaveOccurred())
Expect(string(reply.Message)).To(Equal("test response from remote node"))
// Release the model via adapter
adapter.ReleaseModel("test-model-id")
})
It("should stage model files via HTTP when routing to a new node", func() {
// Create a real model file on disk
modelDir := GinkgoT().TempDir()
modelContent := []byte("fake GGUF model data — this is test content for file transfer verification")
modelPath := filepath.Join(modelDir, "model.gguf")
Expect(os.WriteFile(modelPath, modelContent, 0644)).To(Succeed())
mmprojContent := []byte("fake mmproj data for multimodal projection")
mmprojPath := filepath.Join(modelDir, "mmproj.bin")
Expect(os.WriteFile(mmprojPath, mmprojContent, 0644)).To(Succeed())
// Start a real gRPC backend server (for AI RPCs) and HTTP server (for file transfer)
llm := &testLLM{}
stagingDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
httpAddr, cleanupHTTP, err := startTestHTTPFileServer(stagingDir)
Expect(err).ToNot(HaveOccurred())
defer cleanupHTTP()
// Register the node in PostgreSQL
node := &nodes.BackendNode{Name: "staging-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Create HTTPFileStager that resolves node IDs to HTTP addresses
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", directWorkerDialerFor)
// Create SmartRouter with the HTTPFileStager
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
// Route with ModelOptions that have file paths — SmartRouter should stage them
result, err := router.Route(ctx, "", "staged-model", "llama-cpp", "", &pb.ModelOptions{
Model: "staged-model",
ModelFile: modelPath,
MMProj: mmprojPath,
}, false)
Expect(err).ToNot(HaveOccurred())
Expect(result).ToNot(BeNil())
Expect(result.Node.Name).To(Equal("staging-node"))
// Verify the model file bytes were transferred to the backend's staging dir
stagedModelPath := filepath.Join(stagingDir, "model.gguf")
stagedModelData, err := os.ReadFile(stagedModelPath)
Expect(err).ToNot(HaveOccurred())
Expect(stagedModelData).To(Equal(modelContent))
stagedMMProjPath := filepath.Join(stagingDir, "mmproj.bin")
stagedMMProjData, err := os.ReadFile(stagedMMProjPath)
Expect(err).ToNot(HaveOccurred())
Expect(stagedMMProjData).To(Equal(mmprojContent))
// Verify LoadModel was called with the rewritten (remote) paths
Expect(llm.loaded).To(BeTrue())
Expect(llm.lastModel).To(Equal(stagedModelPath))
// Verify Predict still works through the FileStagingClient wrapper
reply, err := result.Client.Predict(ctx, &pb.PredictOptions{
Prompt: "test via staging client",
})
Expect(err).ToNot(HaveOccurred())
Expect(string(reply.Message)).To(Equal("test response from remote node"))
result.Release()
})
It("should stage multimodal input files via HTTP through FileStagingClient", func() {
// Create a real image file on disk
imageDir := GinkgoT().TempDir()
imageContent := []byte("fake JPEG image data for multimodal testing")
imagePath := filepath.Join(imageDir, "photo.jpg")
Expect(os.WriteFile(imagePath, imageContent, 0644)).To(Succeed())
// Start gRPC server (AI RPCs) and HTTP server (file transfer)
llm := &testLLM{}
stagingDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
httpAddr, cleanupHTTP, err := startTestHTTPFileServer(stagingDir)
Expect(err).ToNot(HaveOccurred())
defer cleanupHTTP()
// Register node
node := &nodes.BackendNode{Name: "mm-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Create HTTPFileStager
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", directWorkerDialerFor)
// Create SmartRouter with FileStager
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
// Route with ModelOptions — triggers LoadModel on the node
modelDir := GinkgoT().TempDir()
modelPath := filepath.Join(modelDir, "vision.gguf")
Expect(os.WriteFile(modelPath, []byte("vision model data"), 0644)).To(Succeed())
result, err := router.Route(ctx, "", "vision-model", "llama-cpp", "", &pb.ModelOptions{
Model: "vision-model",
ModelFile: modelPath,
}, false)
Expect(err).ToNot(HaveOccurred())
// Verify LoadModel was called (model file was staged)
Expect(llm.loaded).To(BeTrue())
// Now call Predict with image file paths — FileStagingClient should stage them
_, err = result.Client.Predict(ctx, &pb.PredictOptions{
Prompt: "describe this image",
Images: []string{imagePath},
})
Expect(err).ToNot(HaveOccurred())
// Verify the image file was actually transferred to the backend staging dir
stagedImagePath := filepath.Join(stagingDir, "photo.jpg")
stagedImageData, err := os.ReadFile(stagedImagePath)
Expect(err).ToNot(HaveOccurred())
Expect(stagedImageData).To(Equal(imageContent))
result.Release()
})
It("should transfer output files back via HTTP", func() {
// Start gRPC server (AI RPCs) and HTTP server (file transfer)
llm := &testLLM{}
stagingDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
httpAddr, cleanupHTTP, err := startTestHTTPFileServer(stagingDir)
Expect(err).ToNot(HaveOccurred())
defer cleanupHTTP()
// Register node
node := &nodes.BackendNode{Name: "output-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
// Create HTTPFileStager
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", directWorkerDialerFor)
// Test AllocRemoteTemp + FetchRemote directly (the output retrieval path)
remoteTmpPath, err := stager.AllocRemoteTemp(ctx, node.ID)
Expect(err).ToNot(HaveOccurred())
Expect(remoteTmpPath).ToNot(BeEmpty())
// Simulate backend writing output to the temp path
outputContent := []byte("generated image output data from the backend")
Expect(os.WriteFile(remoteTmpPath, outputContent, 0644)).To(Succeed())
// FetchRemote pulls the file from the backend to a local path
localOutputDir := GinkgoT().TempDir()
localOutputPath := filepath.Join(localOutputDir, "output.png")
err = stager.FetchRemote(ctx, node.ID, remoteTmpPath, localOutputPath)
Expect(err).ToNot(HaveOccurred())
// Verify the output file was retrieved with correct content
retrievedData, err := os.ReadFile(localOutputPath)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputContent))
})
// --- Full round-trip tests for every FileStagingClient src/dst path ---
// Helper: creates an HTTPFileStager + SmartRouter, registers a node,
// and routes to it. Returns the RouteResult (with FileStagingClient) and cleanup.
setupStagedRoute := func(llm *testLLM, backendType, modelName string) (
*nodes.RouteResult, string, func(),
) {
stagingDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
httpAddr, cleanupHTTP, err := startTestHTTPFileServer(stagingDir)
Expect(err).ToNot(HaveOccurred())
node := &nodes.BackendNode{Name: modelName + "-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", directWorkerDialerFor)
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
result, err := router.Route(ctx, "", modelName, backendType, "", &pb.ModelOptions{
Model: modelName,
}, false)
Expect(err).ToNot(HaveOccurred())
cleanup := func() {
cleanupGRPC()
cleanupHTTP()
}
return result, stagingDir, cleanup
}
It("should round-trip output via FileStagingClient.GenerateImage (Src + Dst)", func() {
outputData := []byte("PNG image generated by the backend - 1024x1024 pixels")
llm := &testLLM{dstOutput: outputData}
result, stagingDir, cleanup := setupStagedRoute(llm, "diffusers", "sd-model")
defer cleanup()
defer result.Release()
// Create a source image to test input staging (img2img)
srcDir := GinkgoT().TempDir()
srcContent := []byte("source image for img2img")
srcPath := filepath.Join(srcDir, "src.png")
Expect(os.WriteFile(srcPath, srcContent, 0644)).To(Succeed())
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "generated.png")
genResult, err := result.Client.GenerateImage(ctx, &pb.GenerateImageRequest{
PositivePrompt: "a cat",
Src: srcPath,
Dst: frontendDst,
Height: 1024,
Width: 1024,
})
Expect(err).ToNot(HaveOccurred())
Expect(genResult.Success).To(BeTrue())
// Verify input: Src was staged to backend — testLLM.lastSrc should be a staging dir path
Expect(llm.lastSrc).To(ContainSubstring(stagingDir))
// Verify the staged input file has correct content
stagedSrcData, err := os.ReadFile(llm.lastSrc)
Expect(err).ToNot(HaveOccurred())
Expect(stagedSrcData).To(Equal(srcContent))
// Verify output: the generated file was pulled back to the frontend
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should round-trip output via FileStagingClient.GenerateVideo (StartImage + Audio + Dst)", func() {
outputData := []byte("MP4 video generated by the backend")
llm := &testLLM{dstOutput: outputData}
result, stagingDir, cleanup := setupStagedRoute(llm, "diffusers", "vid-model")
defer cleanup()
defer result.Release()
// Create a start image to test input staging
imgDir := GinkgoT().TempDir()
startImageContent := []byte("start frame image data")
startImagePath := filepath.Join(imgDir, "start.png")
Expect(os.WriteFile(startImagePath, startImageContent, 0644)).To(Succeed())
audioContent := []byte("audio conditioning data")
audioPath := filepath.Join(imgDir, "speech.wav")
Expect(os.WriteFile(audioPath, audioContent, 0644)).To(Succeed())
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "generated.mp4")
genResult, err := result.Client.GenerateVideo(ctx, &pb.GenerateVideoRequest{
Prompt: "a flying cat",
StartImage: startImagePath,
Audio: audioPath,
Dst: frontendDst,
NumFrames: 16,
})
Expect(err).ToNot(HaveOccurred())
Expect(genResult.Success).To(BeTrue())
// Verify input: StartImage was staged
Expect(llm.lastSrc).To(ContainSubstring(stagingDir))
stagedStartData, err := os.ReadFile(llm.lastSrc)
Expect(err).ToNot(HaveOccurred())
Expect(stagedStartData).To(Equal(startImageContent))
Expect(llm.lastVideoAudio).To(ContainSubstring(stagingDir))
stagedAudioData, err := os.ReadFile(llm.lastVideoAudio)
Expect(err).ToNot(HaveOccurred())
Expect(stagedAudioData).To(Equal(audioContent))
// Verify output: video was pulled back
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should round-trip output via FileStagingClient.TTS (Dst only)", func() {
outputData := []byte("WAV audio generated by TTS backend")
llm := &testLLM{dstOutput: outputData}
result, _, cleanup := setupStagedRoute(llm, "piper", "tts-model")
defer cleanup()
defer result.Release()
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "speech.wav")
ttsResult, err := result.Client.TTS(ctx, &pb.TTSRequest{
Text: "Hello world",
Model: "tts-model",
Dst: frontendDst,
})
Expect(err).ToNot(HaveOccurred())
Expect(ttsResult.Success).To(BeTrue())
// Verify output: audio was pulled back
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should round-trip via FileStagingClient.SoundGeneration (Src + Dst)", func() {
outputData := []byte("generated sound effect audio data")
llm := &testLLM{dstOutput: outputData}
result, stagingDir, cleanup := setupStagedRoute(llm, "bark", "soundgen-model")
defer cleanup()
defer result.Release()
// Create input audio source
srcDir := GinkgoT().TempDir()
srcContent := []byte("input audio for sound generation")
srcPath := filepath.Join(srcDir, "input.wav")
Expect(os.WriteFile(srcPath, srcContent, 0644)).To(Succeed())
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "output.wav")
sgResult, err := result.Client.SoundGeneration(ctx, &pb.SoundGenerationRequest{
Text: "explosion sound",
Src: &srcPath,
Dst: frontendDst,
})
Expect(err).ToNot(HaveOccurred())
Expect(sgResult.Success).To(BeTrue())
// Verify input: Src was staged
Expect(llm.lastSrc).To(ContainSubstring(stagingDir))
stagedSrcData, err := os.ReadFile(llm.lastSrc)
Expect(err).ToNot(HaveOccurred())
Expect(stagedSrcData).To(Equal(srcContent))
// Verify output: audio was pulled back
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should stage input audio via FileStagingClient.AudioTranscription (Dst is input)", func() {
llm := &testLLM{}
result, stagingDir, cleanup := setupStagedRoute(llm, "whisper", "whisper-model")
defer cleanup()
defer result.Release()
// Create input audio file
audioDir := GinkgoT().TempDir()
audioContent := []byte("WAV audio data for transcription")
audioPath := filepath.Join(audioDir, "recording.wav")
Expect(os.WriteFile(audioPath, audioContent, 0644)).To(Succeed())
// AudioTranscription uses Dst as the input audio path (confusing naming)
txResult, err := result.Client.AudioTranscription(ctx, &pb.TranscriptRequest{
Dst: audioPath,
})
Expect(err).ToNot(HaveOccurred())
Expect(txResult.Text).To(Equal("transcribed text"))
// Verify input: audio file was staged to the backend
Expect(llm.lastAudioDst).To(ContainSubstring(stagingDir))
stagedAudioData, err := os.ReadFile(llm.lastAudioDst)
Expect(err).ToNot(HaveOccurred())
Expect(stagedAudioData).To(Equal(audioContent))
})
It("should translate TTS Model path to remote worker path", func() {
outputData := []byte("WAV audio generated by TTS backend")
llm := &testLLM{dstOutput: outputData}
// Set up real file transfer server so model staging preserves directory structure
modelsDir := GinkgoT().TempDir()
stagingDir := GinkgoT().TempDir()
dataDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
httpLis, err := net.Listen("tcp", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
httpAddr := httpLis.Addr().String()
httpServer, err := nodes.StartFileTransferServerWithListener(httpLis, stagingDir, modelsDir, dataDir, "", 0)
Expect(err).ToNot(HaveOccurred())
defer nodes.ShutdownFileTransferServer(httpServer)
node := &nodes.BackendNode{Name: "tts-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", directWorkerDialerFor)
// Create model files on the "frontend"
frontendModelsDir := GinkgoT().TempDir()
modelContent := []byte("fake onnx model data")
configContent := []byte(`{"audio":{"sample_rate":22050}}`)
modelFile := filepath.Join(frontendModelsDir, "it-paola-medium.onnx")
configFile := filepath.Join(frontendModelsDir, "it-paola-medium.onnx.json")
Expect(os.WriteFile(modelFile, modelContent, 0644)).To(Succeed())
Expect(os.WriteFile(configFile, configContent, 0644)).To(Succeed())
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
// Route with ModelFile pointing to the .onnx file (triggers model staging)
result, err := router.Route(ctx, "voice-it-paola-medium", "it-paola-medium.onnx", "piper", "", &pb.ModelOptions{
Model: "it-paola-medium.onnx",
ModelFile: modelFile,
}, false)
Expect(err).ToNot(HaveOccurred())
defer result.Release()
localOutputDir := GinkgoT().TempDir()
frontendDst := filepath.Join(localOutputDir, "speech.wav")
// Simulate what core/backend/tts.go does: construct Model path using frontend ModelPath
frontendModelPath := filepath.Join(frontendModelsDir, "it-paola-medium.onnx")
ttsResult, err := result.Client.TTS(ctx, &pb.TTSRequest{
Text: "Hello world",
Model: frontendModelPath, // frontend absolute path — should be translated to remote
Dst: frontendDst,
})
Expect(err).ToNot(HaveOccurred())
Expect(ttsResult.Success).To(BeTrue())
// Verify: the backend received the remote worker path, NOT the frontend path
Expect(llm.lastTTSModel).ToNot(Equal(frontendModelPath))
// The remote path should be under the worker's models dir with the tracking key
Expect(llm.lastTTSModel).To(ContainSubstring("voice-it-paola-medium"))
Expect(llm.lastTTSModel).To(HaveSuffix("it-paola-medium.onnx"))
// Verify the model file exists at the translated path (already staged during LoadModel)
stagedModelData, err := os.ReadFile(llm.lastTTSModel)
Expect(err).ToNot(HaveOccurred())
Expect(stagedModelData).To(Equal(modelContent))
// Verify the companion .onnx.json is next to it (staged during LoadModel)
companionPath := llm.lastTTSModel + ".json"
stagedConfigData, err := os.ReadFile(companionPath)
Expect(err).ToNot(HaveOccurred())
Expect(stagedConfigData).To(Equal(configContent))
// Verify output: audio was pulled back
retrievedData, err := os.ReadFile(frontendDst)
Expect(err).ToNot(HaveOccurred())
Expect(retrievedData).To(Equal(outputData))
})
It("should stage companion .onnx.json files alongside .onnx model files", func() {
llm := &testLLM{}
modelsDir := GinkgoT().TempDir()
stagingDir := GinkgoT().TempDir()
dataDir := GinkgoT().TempDir()
addr, cleanupGRPC, err := startTestGRPCServer(llm)
Expect(err).ToNot(HaveOccurred())
defer cleanupGRPC()
// Use the real file transfer server (preserves directory structure)
httpLis, err := net.Listen("tcp", "127.0.0.1:0")
Expect(err).ToNot(HaveOccurred())
httpAddr := httpLis.Addr().String()
httpServer, err := nodes.StartFileTransferServerWithListener(httpLis, stagingDir, modelsDir, dataDir, "", 0)
Expect(err).ToNot(HaveOccurred())
defer nodes.ShutdownFileTransferServer(httpServer)
node := &nodes.BackendNode{Name: "companion-node", Address: addr, HTTPAddress: httpAddr}
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
stager := nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
n, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
return n.HTTPAddress, nil
}, "", directWorkerDialerFor)
// Create model files: .onnx and .onnx.json in a temp "models" dir
frontendModelsDir := GinkgoT().TempDir()
modelContent := []byte("fake onnx model")
configContent := []byte(`{"audio":{"sample_rate":22050}}`)
modelFile := filepath.Join(frontendModelsDir, "my-model.onnx")
configFile := filepath.Join(frontendModelsDir, "my-model.onnx.json")
Expect(os.WriteFile(modelFile, modelContent, 0644)).To(Succeed())
Expect(os.WriteFile(configFile, configContent, 0644)).To(Succeed())
router := newTestSmartRouter(registry, nodes.SmartRouterOptions{FileStager: stager})
// Route with ModelFile pointing to the .onnx file
result, err := router.Route(ctx, "piper-companion-test", "my-model.onnx", "piper", "", &pb.ModelOptions{
Model: "my-model.onnx",
ModelFile: modelFile,
}, false)
Expect(err).ToNot(HaveOccurred())
defer result.Release()
// Verify: both .onnx and .onnx.json were staged to the worker's models dir
stagedOnnx := filepath.Join(modelsDir, "piper-companion-test", "my-model.onnx")
stagedConfig := filepath.Join(modelsDir, "piper-companion-test", "my-model.onnx.json")
stagedOnnxData, err := os.ReadFile(stagedOnnx)
Expect(err).ToNot(HaveOccurred(), "companion .onnx model should be staged")
Expect(stagedOnnxData).To(Equal(modelContent))
stagedConfigData, err := os.ReadFile(stagedConfig)
Expect(err).ToNot(HaveOccurred(), "companion .onnx.json config should be staged alongside model")
Expect(stagedConfigData).To(Equal(configContent))
})
})