Files
LocalAI/tests/e2e/distributed/gallery_distributed_test.go
T
Ettore Di Giacinto be14db21e2 feat(distributed): move the last nine fan-out families onto PostgreSQL
Gallery progress and cancel, the operation cache's start and end, the model
and backend cache invalidations, staging progress, and the prefix cache's
observations and invalidations now travel on the LISTEN/NOTIFY carrier. No
subject is published or subscribed on messaging.Client anywhere in the tree,
which is what makes retiring that package a deletion rather than a migration:

  $ grep -rn 'natsClient\.Publish\|nats\.Publish\|\.Nats\.Publish\|QueueSubscribe\|SubscribeReply\|\.Request(' \
      --include='*.go' core/ pkg/ | grep -v _test \
    | grep -v 'c\.Request()\|ctx\.Request()\|Request()\.Context' \
    | grep -v 'core/services/testutil/fakebus.go'
  core/services/messaging/client.go:168,170,172,227,234,236,250,252,254,268,269,287
  core/services/messaging/interfaces.go:21,22,23

Every remaining hit is inside core/services/messaging itself. The production
reads of the NATS client are now three, all of them the documented agent-worker
exception: Close on shutdown, the agent pool's publisher, and the agent-cancel
carrier passed to newFanoutBridges.

Prefix-cache observations publish like every other family rather than through a
method that refuses a message too large for a notification. The plan proposed
such a refusal on the reasoning that a long prompt makes a chain of thousands of
entries; ExtractChain caps a chain at Config.MaxDepth blocks, MaxDepth is a
constant with no operator knob, and the chain reaching Sync.Observe has one
source, the router's own extraction hook. A worst-case observation is a few
kilobytes against an 8000-byte cap, so the hot-path spill the refusal was
designed to avoid cannot occur, and shipping it would have added the programme's
only deliberate message drop to guard a condition that cannot arise. pgbus gains
FitsInline instead, a predicate that shares one size decision with Publish and
decides nothing, and core/application refuses at startup to wire a prefix cache
whose configured depth would put every observation over the cap.

The carrier choice is no longer stated at four sites. StagingTracker.SetPublisher
and SubscribeBroadcasts become one SetBroadcaster, so a tracker that publishes
where its peers are not listening cannot be spelled; prefixcache.Sync gains
SubscribeBroadcasts, which reads the carrier it publishes on; and the gallery
service and the operation cache are wired by methods on DistributedServices that
name no carrier at all, so the NATS client beside it cannot be handed over.
OpCache.SetMessagingClient and GalleryService.SetNATSClient are renamed to
SetBroadcaster so a missed call site fails to compile.

Two pre-existing defects that the two-real-carrier specs surfaced are fixed. A
progress tick published before a cancel and delivered after it cleared Cancelled
and left the operation reading as still running on that replica; mergeStatus now
drops a stale tick rather than merging it. GetStatus and GetAllStatus handed out
the stored OpStatus pointer while the broadcast subscribers mutated it in place,
so an /api/operations response could be marshalled mid-write; both now copy.

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

177 lines
6.0 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 infra.NC, which asserted that NATS
// 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.
Expect(appCfg.Distributed.NatsURL).To(BeEmpty())
})
})
})