mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-14 23:28:23 -04:00
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>
164 lines
4.6 KiB
Go
164 lines
4.6 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
|
|
"github.com/mudler/LocalAI/core/services/nodes"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// directBackendClients stands in for the worker tunnel in these specs.
|
|
//
|
|
// The store refuses to build a client for a remote model without a way to reach
|
|
// the worker, which is the point: a model built with no client dials its raw
|
|
// address on first use. These specs have no worker tunnel and no worker, so the
|
|
// dial is a plain TCP one; production supplies the real dialer from
|
|
// core/application.
|
|
func directBackendClients() nodes.BackendClientFactory {
|
|
GinkgoHelper()
|
|
clients, err := nodes.NewTunnelClientFactory("", func(string) func(ctx context.Context, addr string) (net.Conn, error) {
|
|
var d net.Dialer
|
|
return func(ctx context.Context, addr string) (net.Conn, error) {
|
|
return d.DialContext(ctx, "tcp", addr)
|
|
}
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
return clients
|
|
}
|
|
|
|
var _ = Describe("DistributedModelStore", Label("Distributed"), func() {
|
|
var (
|
|
infra *TestInfra
|
|
db *gorm.DB
|
|
registry *nodes.NodeRegistry
|
|
localStore *model.InMemoryModelStore
|
|
dStore *nodes.DistributedModelStore
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
infra = SetupInfra("localai_dstore_test")
|
|
|
|
var err error
|
|
db, err = gorm.Open(pgdriver.Open(infra.PGURL), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
registry, err = nodes.NewNodeRegistry(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
localStore = model.NewInMemoryModelStore()
|
|
dStore = nodes.NewDistributedModelStore(localStore, registry, directBackendClients())
|
|
})
|
|
|
|
Context("Get", func() {
|
|
It("returns model from local cache on hit", func() {
|
|
expected := model.NewModel("local-model", "local:5000", nil)
|
|
localStore.Set("local-model", expected)
|
|
|
|
m, ok := dStore.Get("local-model")
|
|
Expect(ok).To(BeTrue())
|
|
Expect(m).To(Equal(expected))
|
|
})
|
|
|
|
It("returns (nil, false) when model is not in local cache", func() {
|
|
m, ok := dStore.Get("ghost-model")
|
|
Expect(ok).To(BeFalse())
|
|
Expect(m).To(BeNil())
|
|
})
|
|
})
|
|
|
|
Context("Set", func() {
|
|
It("delegates to local store", func() {
|
|
expected := model.NewModel("set-model", "addr:1234", nil)
|
|
dStore.Set("set-model", expected)
|
|
|
|
m, ok := localStore.Get("set-model")
|
|
Expect(ok).To(BeTrue())
|
|
Expect(m).To(Equal(expected))
|
|
})
|
|
})
|
|
|
|
Context("Delete", func() {
|
|
It("removes from local store", func() {
|
|
localStore.Set("del-model", model.NewModel("del-model", "addr", nil))
|
|
dStore.Delete("del-model")
|
|
|
|
_, ok := localStore.Get("del-model")
|
|
Expect(ok).To(BeFalse())
|
|
})
|
|
})
|
|
|
|
Context("Range", func() {
|
|
It("returns local-only models", func() {
|
|
localStore.Set("local-a", model.NewModel("local-a", "addr-a", nil))
|
|
localStore.Set("local-b", model.NewModel("local-b", "addr-b", nil))
|
|
|
|
visited := map[string]bool{}
|
|
dStore.Range(func(id string, m *model.Model) bool {
|
|
visited[id] = true
|
|
return true
|
|
})
|
|
Expect(visited).To(HaveLen(2))
|
|
Expect(visited).To(HaveKey("local-a"))
|
|
Expect(visited).To(HaveKey("local-b"))
|
|
})
|
|
|
|
It("returns DB-only models not in local cache", func() {
|
|
node := &nodes.BackendNode{
|
|
Name: "range-node", Address: "range:9000",
|
|
}
|
|
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
|
|
Expect(registry.SetNodeModel(context.Background(), node.ID, "db-only-model", 0, "loaded", "", 0)).To(Succeed())
|
|
|
|
visited := map[string]bool{}
|
|
dStore.Range(func(id string, m *model.Model) bool {
|
|
visited[id] = true
|
|
return true
|
|
})
|
|
Expect(visited).To(HaveKey("db-only-model"))
|
|
})
|
|
|
|
It("deduplicates models present in both local and DB", func() {
|
|
node := &nodes.BackendNode{
|
|
Name: "dup-node", Address: "dup:9000",
|
|
}
|
|
Expect(registry.Register(context.Background(), node, true)).To(Succeed())
|
|
Expect(registry.SetNodeModel(context.Background(), node.ID, "shared-model", 0, "loaded", "", 0)).To(Succeed())
|
|
|
|
// Also in local store
|
|
localStore.Set("shared-model", model.NewModel("shared-model", "dup:9000", nil))
|
|
|
|
count := 0
|
|
dStore.Range(func(id string, m *model.Model) bool {
|
|
if id == "shared-model" {
|
|
count++
|
|
}
|
|
return true
|
|
})
|
|
Expect(count).To(Equal(1))
|
|
})
|
|
|
|
It("stops early when callback returns false", func() {
|
|
localStore.Set("r1", model.NewModel("r1", "a", nil))
|
|
localStore.Set("r2", model.NewModel("r2", "b", nil))
|
|
localStore.Set("r3", model.NewModel("r3", "c", nil))
|
|
|
|
count := 0
|
|
dStore.Range(func(id string, m *model.Model) bool {
|
|
count++
|
|
return false
|
|
})
|
|
Expect(count).To(Equal(1))
|
|
})
|
|
})
|
|
})
|