mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-21 21:54:52 -04:00
Distributed mode has not dialled a message broker since the control plane moved onto the workers' own outward tunnels and every fan-out family moved onto PostgreSQL LISTEN/NOTIFY. What was left was the dependency itself, and the code that existed only to feed it. Dropped from go.mod: nats-io/jwt/v2, nats-io/nats.go, nats-io/nkeys, nats-io/nuid and testcontainers-go/modules/nats, along with the fourteen indirect requires that only the NATS testcontainer pulled in. go.sum carries no nats line either, so the removal is not the partial kind where the require goes and the checksum stays. Deleted with them: pkg/natsauth in full, the broker client's remaining options and TLS files, the per-node JWT minting on both the register and the approve path, and the natsauth.Config parameter threaded through the node routes. The credential manager is renamed and stripped rather than deleted, because it still holds the tunnel token that every re-registration rotates. The bus flags stay accepted and ignored, and are now hidden, on every command that had them, so an existing unit file, compose file or Helm values file still starts on the day of the upgrade. What is not kept is the validation that REQUIRED one: a distributed frontend started with no bus URL is no longer fatal. The TLS paths lose type:"existingfile" deliberately, so a certificate deleted along with the broker cannot fail a startup. One operator-visible behaviour change: --nats-require-auth no longer makes an agent worker wait through admin approval. Ask for that wait with --distributed-require-auth, which already implied it. It is documented in the migration section and pinned from both sides. A deployment now needs PostgreSQL and the frontends' own HTTP listener, and nothing else. coverage-baseline.txt moves from 54.2 to 62.0. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
180 lines
6.1 KiB
Go
180 lines
6.1 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"sync/atomic"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/services/distributed"
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var _ = Describe("Gallery Distributed", Label("Distributed"), func() {
|
|
var (
|
|
infra *TestInfra
|
|
db *gorm.DB
|
|
galleryStore *distributed.GalleryStore
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
infra = SetupInfra("localai_gallery_dist_test")
|
|
|
|
var err error
|
|
db, err = gorm.Open(pgdriver.Open(infra.PGURL), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
galleryStore, err = distributed.NewGalleryStore(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Context("PostgreSQL gallery operations", func() {
|
|
It("should write gallery operation status to PostgreSQL", func() {
|
|
op := &distributed.GalleryOperationRecord{
|
|
GalleryElementName: "llama3-8b",
|
|
OpType: "model_install",
|
|
Status: "downloading",
|
|
Cancellable: true,
|
|
FrontendID: "f1",
|
|
}
|
|
Expect(galleryStore.Create(op)).To(Succeed())
|
|
Expect(op.ID).ToNot(BeEmpty())
|
|
|
|
retrieved, err := galleryStore.Get(op.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.GalleryElementName).To(Equal("llama3-8b"))
|
|
Expect(retrieved.Status).To(Equal("downloading"))
|
|
Expect(retrieved.FrontendID).To(Equal("f1"))
|
|
|
|
// Update progress (cancellable: a downloading install can be cancelled)
|
|
Expect(galleryStore.UpdateProgress(op.ID, 0.75, "75% complete", "6GB", true)).To(Succeed())
|
|
|
|
updated, _ := galleryStore.Get(op.ID)
|
|
Expect(updated.Progress).To(BeNumerically("~", 0.75, 0.01))
|
|
Expect(updated.Message).To(Equal("75% complete"))
|
|
Expect(updated.Cancellable).To(BeTrue())
|
|
|
|
// Complete
|
|
Expect(galleryStore.UpdateStatus(op.ID, "completed", "")).To(Succeed())
|
|
completed, _ := galleryStore.Get(op.ID)
|
|
Expect(completed.Status).To(Equal("completed"))
|
|
})
|
|
})
|
|
|
|
// The gallery families ride the broadcast carrier, not NATS.
|
|
//
|
|
// These used to publish and subscribe on a message-bus client, which
|
|
// asserted that the bus delivers to itself and nothing about this
|
|
// deployment: they would have
|
|
// stayed green through the whole migration while the gallery service had
|
|
// already moved. Two carriers on the deployment's own database is the shape
|
|
// a fleet has, and it is the shape that fails when one end moves and the
|
|
// other does not.
|
|
//
|
|
// No flush, unlike the NATS version: pgbus.Subscribe has already issued its
|
|
// LISTEN by the time it returns, and Subscribers() counts only live
|
|
// handlers, so there is no window to wait out.
|
|
Context("gallery progress on the broadcast carrier", func() {
|
|
It("delivers a peer replica's progress updates", func() {
|
|
op := &distributed.GalleryOperationRecord{
|
|
GalleryElementName: "whisper-large",
|
|
OpType: "model_install",
|
|
Status: "downloading",
|
|
}
|
|
Expect(galleryStore.Create(op)).To(Succeed())
|
|
|
|
publisher, subscriber := infra.Bus(), infra.Bus()
|
|
|
|
var received atomic.Int32
|
|
sub, err := subscriber.Subscribe(messaging.SubjectGalleryProgress(op.ID), func([]byte) {
|
|
received.Add(1)
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { Expect(sub.Unsubscribe()).To(Succeed()) }()
|
|
|
|
Expect(publisher.Publish(messaging.SubjectGalleryProgress(op.ID), map[string]any{
|
|
"op_id": op.ID, "progress": 0.25, "message": "25%",
|
|
})).To(Succeed())
|
|
Expect(publisher.Publish(messaging.SubjectGalleryProgress(op.ID), map[string]any{
|
|
"op_id": op.ID, "progress": 0.50, "message": "50%",
|
|
})).To(Succeed())
|
|
|
|
Eventually(func() int32 { return received.Load() }, "20s").Should(Equal(int32(2)))
|
|
})
|
|
})
|
|
|
|
Context("gallery cancel on the broadcast carrier", func() {
|
|
It("delivers a cancel to the replica holding the operation", func() {
|
|
op := &distributed.GalleryOperationRecord{
|
|
GalleryElementName: "cancel-model",
|
|
OpType: "model_install",
|
|
Status: "downloading",
|
|
Cancellable: true,
|
|
}
|
|
Expect(galleryStore.Create(op)).To(Succeed())
|
|
|
|
publisher, subscriber := infra.Bus(), infra.Bus()
|
|
|
|
var cancelReceived atomic.Bool
|
|
sub, err := subscriber.Subscribe(messaging.SubjectGalleryCancel(op.ID), func([]byte) {
|
|
cancelReceived.Store(true)
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { Expect(sub.Unsubscribe()).To(Succeed()) }()
|
|
|
|
Expect(publisher.Publish(messaging.SubjectGalleryCancel(op.ID), map[string]string{
|
|
"op_id": op.ID,
|
|
})).To(Succeed())
|
|
|
|
Eventually(func() bool { return cancelReceived.Load() }, "20s").Should(BeTrue())
|
|
|
|
// The row is what survives a replica that was not listening. The
|
|
// broadcast is the hint to go and look at it.
|
|
Expect(galleryStore.Cancel(op.ID)).To(Succeed())
|
|
updated, _ := galleryStore.Get(op.ID)
|
|
Expect(updated.Status).To(Equal("cancelled"))
|
|
})
|
|
})
|
|
|
|
Context("Deduplication", func() {
|
|
It("should deduplicate concurrent downloads of same model", func() {
|
|
op := &distributed.GalleryOperationRecord{
|
|
GalleryElementName: "same-model-v2",
|
|
OpType: "model_install",
|
|
Status: "downloading",
|
|
}
|
|
Expect(galleryStore.Create(op)).To(Succeed())
|
|
|
|
// Another instance tries to download the same model
|
|
dup, err := galleryStore.FindDuplicate("same-model-v2")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(dup.ID).To(Equal(op.ID))
|
|
|
|
// Completed operations should not be considered duplicates
|
|
Expect(galleryStore.UpdateStatus(op.ID, "completed", "")).To(Succeed())
|
|
_, err = galleryStore.FindDuplicate("same-model-v2")
|
|
Expect(err).To(HaveOccurred()) // no active duplicate
|
|
})
|
|
})
|
|
|
|
Context("Without --distributed", func() {
|
|
It("should use in-memory map without --distributed", func() {
|
|
appCfg := config.NewApplicationConfig()
|
|
Expect(appCfg.Distributed.Enabled).To(BeFalse())
|
|
|
|
// Without distributed mode, gallery operations use the existing
|
|
// in-memory galleryApplier map. No PostgreSQL needed.
|
|
//
|
|
// The bus-URL half of this assertion went with the field it read;
|
|
// core/config's "broker surface" spec pins its absence.
|
|
})
|
|
})
|
|
})
|