489 lines
15 KiB
Go
489 lines
15 KiB
Go
package galleryop_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/services/distributed"
|
|
"github.com/mudler/LocalAI/core/services/galleryop"
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/LocalAI/core/services/testutil"
|
|
)
|
|
|
|
// fakeBus is an in-memory MessagingClient that delivers each published
|
|
// message synchronously to every registered subscriber whose subject filter
|
|
// matches, including NATS-style wildcard subjects (`*` matches one token).
|
|
//
|
|
// Synchronous delivery keeps the specs deterministic: the moment Publish
|
|
// returns, every subscriber's handler has run, so the spec body can read
|
|
// the resulting state without polling.
|
|
type fakeBus struct {
|
|
mu sync.Mutex
|
|
subs []fakeBusSub
|
|
}
|
|
|
|
type fakeBusSub struct {
|
|
subject string
|
|
handler func([]byte)
|
|
}
|
|
|
|
func newFakeBus() *fakeBus { return &fakeBus{} }
|
|
|
|
func subjectMatches(filter, subject string) bool {
|
|
if filter == subject {
|
|
return true
|
|
}
|
|
fp := strings.Split(filter, ".")
|
|
sp := strings.Split(subject, ".")
|
|
if len(fp) != len(sp) {
|
|
return false
|
|
}
|
|
for i := range fp {
|
|
if fp[i] == "*" {
|
|
continue
|
|
}
|
|
if fp[i] != sp[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (b *fakeBus) Publish(subject string, data any) error {
|
|
payload, err := json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b.mu.Lock()
|
|
subs := append([]fakeBusSub(nil), b.subs...)
|
|
b.mu.Unlock()
|
|
for _, s := range subs {
|
|
if subjectMatches(s.subject, subject) {
|
|
s.handler(payload)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type fakeBusSubscription struct {
|
|
bus *fakeBus
|
|
subRef fakeBusSub
|
|
}
|
|
|
|
func (s *fakeBusSubscription) Unsubscribe() error {
|
|
s.bus.mu.Lock()
|
|
defer s.bus.mu.Unlock()
|
|
for i, candidate := range s.bus.subs {
|
|
if candidate.subject == s.subRef.subject {
|
|
s.bus.subs = append(s.bus.subs[:i], s.bus.subs[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (b *fakeBus) Subscribe(subject string, handler func([]byte)) (messaging.Subscription, error) {
|
|
sub := fakeBusSub{subject: subject, handler: handler}
|
|
b.mu.Lock()
|
|
b.subs = append(b.subs, sub)
|
|
b.mu.Unlock()
|
|
return &fakeBusSubscription{bus: b, subRef: sub}, nil
|
|
}
|
|
|
|
func (b *fakeBus) QueueSubscribe(subject, _ string, handler func([]byte)) (messaging.Subscription, error) {
|
|
return b.Subscribe(subject, handler)
|
|
}
|
|
|
|
func (b *fakeBus) QueueSubscribeReply(string, string, func([]byte, func([]byte))) (messaging.Subscription, error) {
|
|
return &fakeBusSubscription{bus: b}, nil
|
|
}
|
|
|
|
func (b *fakeBus) SubscribeReply(string, func([]byte, func([]byte))) (messaging.Subscription, error) {
|
|
return &fakeBusSubscription{bus: b}, nil
|
|
}
|
|
|
|
func (b *fakeBus) Request(string, []byte, time.Duration) ([]byte, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (b *fakeBus) IsConnected() bool { return true }
|
|
func (b *fakeBus) Close() {}
|
|
|
|
var _ = Describe("OpStatus JSON wire format", func() {
|
|
It("round-trips a non-nil Error through Marshal/Unmarshal as a string", func() {
|
|
original := &galleryop.OpStatus{
|
|
Progress: 42.0,
|
|
Message: "downloading",
|
|
GalleryElementName: "vllm",
|
|
Error: errors.New("disk full"),
|
|
Processed: true,
|
|
}
|
|
raw, err := json.Marshal(original)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
var got galleryop.OpStatus
|
|
Expect(json.Unmarshal(raw, &got)).To(Succeed())
|
|
Expect(got.Error).ToNot(BeNil(), "the error must survive the round-trip — peer replicas need to surface the failure")
|
|
Expect(got.Error.Error()).To(Equal("disk full"))
|
|
Expect(got.Progress).To(Equal(42.0))
|
|
Expect(got.GalleryElementName).To(Equal("vllm"))
|
|
Expect(got.Processed).To(BeTrue())
|
|
})
|
|
|
|
It("emits no error field when Error is nil", func() {
|
|
original := &galleryop.OpStatus{Progress: 10.0}
|
|
raw, err := json.Marshal(original)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(string(raw)).ToNot(ContainSubstring(`"error":`),
|
|
"omitempty must keep nil errors out of the wire payload")
|
|
|
|
var got galleryop.OpStatus
|
|
Expect(json.Unmarshal(raw, &got)).To(Succeed())
|
|
Expect(got.Error).To(BeNil())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("OpCache distributed sync", func() {
|
|
var (
|
|
svcA *galleryop.GalleryService
|
|
svcB *galleryop.GalleryService
|
|
opA *galleryop.OpCache
|
|
opB *galleryop.OpCache
|
|
bus *fakeBus
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
bus = newFakeBus()
|
|
svcA = galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
svcB = galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
opA = galleryop.NewOpCache(svcA)
|
|
opB = galleryop.NewOpCache(svcB)
|
|
opA.SetMessagingClient(bus)
|
|
opB.SetMessagingClient(bus)
|
|
Expect(opA.Start(context.Background())).To(Succeed())
|
|
Expect(opB.Start(context.Background())).To(Succeed())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
opA.Close()
|
|
opB.Close()
|
|
})
|
|
|
|
It("propagates a Set on replica A to replica B's OpCache", func() {
|
|
opA.Set("llama-3-8b", "job-uuid-1")
|
|
|
|
Expect(opB.Exists("llama-3-8b")).To(BeTrue(),
|
|
"replica B should see the operation that replica A admitted")
|
|
Expect(opB.Get("llama-3-8b")).To(Equal("job-uuid-1"))
|
|
Expect(opB.IsBackendOp("llama-3-8b")).To(BeFalse())
|
|
})
|
|
|
|
It("propagates SetBackend with the backend-op flag set", func() {
|
|
opA.SetBackend("official@vllm", "job-uuid-2")
|
|
|
|
Expect(opB.Exists("official@vllm")).To(BeTrue())
|
|
Expect(opB.IsBackendOp("official@vllm")).To(BeTrue(),
|
|
"peer must learn that this op is a backend install, not a model install")
|
|
})
|
|
|
|
It("propagates DeleteUUID across replicas", func() {
|
|
opA.Set("llama-3-8b", "job-uuid-3")
|
|
Expect(opB.Exists("llama-3-8b")).To(BeTrue())
|
|
|
|
opA.DeleteUUID("job-uuid-3")
|
|
|
|
Expect(opA.Exists("llama-3-8b")).To(BeFalse())
|
|
Expect(opB.Exists("llama-3-8b")).To(BeFalse(),
|
|
"a dismissed operation must clear from peer replicas too")
|
|
})
|
|
|
|
It("does not double-write on echo (replica A's broadcast received by replica A)", func() {
|
|
// We can't directly observe self-receive but we can confirm the
|
|
// resulting state is the same single value, not corrupted or doubled.
|
|
opA.Set("model-x", "job-uuid-4")
|
|
Expect(opA.Map()).To(HaveLen(1))
|
|
Expect(opA.Get("model-x")).To(Equal("job-uuid-4"))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("OpCache PostgreSQL hydration", func() {
|
|
It("rebuilds the OpCache from active gallery_operations rows on Start", func() {
|
|
db := testutil.SetupTestDB()
|
|
store, err := distributed.NewGalleryStore(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Seed: two active model installs (one with backend flag, one without)
|
|
// and one completed op that must NOT resurrect.
|
|
Expect(store.UpsertCacheKey("job-A", "llama-3-8b", false)).To(Succeed())
|
|
Expect(store.UpsertCacheKey("job-B", "official@vllm", true)).To(Succeed())
|
|
Expect(store.UpsertCacheKey("job-C", "old-stale", false)).To(Succeed())
|
|
Expect(store.UpdateStatus("job-C", "completed", "")).To(Succeed())
|
|
|
|
svc := galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
cache := galleryop.NewOpCache(svc)
|
|
cache.SetGalleryStore(store)
|
|
Expect(cache.Start(context.Background())).To(Succeed())
|
|
|
|
Expect(cache.Exists("llama-3-8b")).To(BeTrue())
|
|
Expect(cache.IsBackendOp("llama-3-8b")).To(BeFalse())
|
|
Expect(cache.Get("llama-3-8b")).To(Equal("job-A"))
|
|
|
|
Expect(cache.Exists("official@vllm")).To(BeTrue())
|
|
Expect(cache.IsBackendOp("official@vllm")).To(BeTrue())
|
|
|
|
Expect(cache.Exists("old-stale")).To(BeFalse(),
|
|
"completed operations must not resurrect on hydration")
|
|
})
|
|
})
|
|
|
|
var _ = Describe("GalleryService broadcast sync", func() {
|
|
var (
|
|
svcA *galleryop.GalleryService
|
|
svcB *galleryop.GalleryService
|
|
bus *fakeBus
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
bus = newFakeBus()
|
|
svcA = galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
svcB = galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
svcA.SetNATSClient(bus)
|
|
svcB.SetNATSClient(bus)
|
|
Expect(svcA.SubscribeBroadcasts()).To(Succeed())
|
|
Expect(svcB.SubscribeBroadcasts()).To(Succeed())
|
|
})
|
|
|
|
AfterEach(func() {
|
|
svcA.CloseBroadcasts()
|
|
svcB.CloseBroadcasts()
|
|
})
|
|
|
|
It("delivers an UpdateStatus on A into B's statuses map via the wildcard subscriber", func() {
|
|
svcA.UpdateStatus("op-1", &galleryop.OpStatus{
|
|
Progress: 50.0,
|
|
Message: "halfway",
|
|
GalleryElementName: "llama-3-8b",
|
|
})
|
|
|
|
st := svcB.GetStatus("op-1")
|
|
Expect(st).ToNot(BeNil(),
|
|
"replica B must see the progress its peer just published")
|
|
Expect(st.Progress).To(Equal(50.0))
|
|
Expect(st.Message).To(Equal("halfway"))
|
|
Expect(st.GalleryElementName).To(Equal("llama-3-8b"))
|
|
})
|
|
|
|
It("preserves a peer's accumulated Nodes when a tick arrives with empty Nodes", func() {
|
|
// Seed B with a multi-node breakdown via UpdateNodeProgress.
|
|
svcB.UpdateNodeProgress("op-2", "n1", galleryop.NodeProgress{
|
|
NodeID: "n1", NodeName: "worker-a", Status: galleryop.NodeStatusDownloading, Percentage: 20.0,
|
|
})
|
|
svcB.UpdateNodeProgress("op-2", "n2", galleryop.NodeProgress{
|
|
NodeID: "n2", NodeName: "worker-b", Status: galleryop.NodeStatusDownloading, Percentage: 30.0,
|
|
})
|
|
|
|
// A publishes a single-bar progress tick (no Nodes) — must not wipe B's Nodes.
|
|
svcA.UpdateStatus("op-2", &galleryop.OpStatus{Progress: 25.0, Message: "downloading"})
|
|
|
|
st := svcB.GetStatus("op-2")
|
|
Expect(st.Nodes).To(HaveLen(2),
|
|
"merged tick must carry forward existing per-node breakdown")
|
|
})
|
|
|
|
It("preserves the error message through a peer's wildcard broadcast", func() {
|
|
svcA.UpdateStatus("op-3", &galleryop.OpStatus{
|
|
Processed: true,
|
|
Error: errors.New("oci pull failed"),
|
|
})
|
|
|
|
st := svcB.GetStatus("op-3")
|
|
Expect(st).ToNot(BeNil())
|
|
Expect(st.Error).ToNot(BeNil(),
|
|
"a failed op's error must survive the broadcast hop")
|
|
Expect(st.Error.Error()).To(Equal("oci pull failed"))
|
|
Expect(st.Processed).To(BeTrue())
|
|
})
|
|
|
|
It("runs the local cancel func when a peer publishes a cancel event", func() {
|
|
var cancelCalled bool
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
svcB.StoreCancellation("op-4", func() {
|
|
cancelCalled = true
|
|
cancel()
|
|
})
|
|
_ = ctx
|
|
|
|
// A side fires CancelOperation; the cancel func lives on B and must run.
|
|
Expect(svcA.CancelOperation("op-4")).To(Succeed())
|
|
|
|
Expect(cancelCalled).To(BeTrue(),
|
|
"the replica holding the cancel func must run it when a peer requests cancellation")
|
|
st := svcB.GetStatus("op-4")
|
|
Expect(st).ToNot(BeNil())
|
|
Expect(st.Cancelled).To(BeTrue())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("GalleryService cache invalidation broadcasts", func() {
|
|
var (
|
|
svcA *galleryop.GalleryService
|
|
svcB *galleryop.GalleryService
|
|
bus *fakeBus
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
bus = newFakeBus()
|
|
svcA = galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
svcB = galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
svcA.SetNATSClient(bus)
|
|
svcB.SetNATSClient(bus)
|
|
})
|
|
|
|
AfterEach(func() {
|
|
svcA.CloseBroadcasts()
|
|
svcB.CloseBroadcasts()
|
|
})
|
|
|
|
It("delivers SubjectCacheInvalidateModels to peer's OnModelsChanged callback", func() {
|
|
var (
|
|
mu sync.Mutex
|
|
seen []messaging.CacheInvalidateEvent
|
|
)
|
|
svcB.OnModelsChanged = func(evt messaging.CacheInvalidateEvent) {
|
|
mu.Lock()
|
|
seen = append(seen, evt)
|
|
mu.Unlock()
|
|
}
|
|
Expect(svcA.SubscribeBroadcasts()).To(Succeed())
|
|
Expect(svcB.SubscribeBroadcasts()).To(Succeed())
|
|
|
|
Expect(bus.Publish(messaging.SubjectCacheInvalidateModels, messaging.CacheInvalidateEvent{
|
|
Element: "llama-3-8b", Op: "install",
|
|
})).To(Succeed())
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
// Both replicas subscribed; both callbacks fire (svcA's is nil-callback so no-op).
|
|
Expect(seen).To(ContainElement(messaging.CacheInvalidateEvent{
|
|
Element: "llama-3-8b", Op: "install",
|
|
}))
|
|
})
|
|
|
|
It("delivers SubjectCacheInvalidateBackends to peer's OnBackendOpCompleted callback", func() {
|
|
done := make(chan struct{}, 1)
|
|
svcB.OnBackendOpCompleted = func() {
|
|
select {
|
|
case done <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
Expect(svcA.SubscribeBroadcasts()).To(Succeed())
|
|
Expect(svcB.SubscribeBroadcasts()).To(Succeed())
|
|
|
|
Expect(bus.Publish(messaging.SubjectCacheInvalidateBackends, messaging.CacheInvalidateEvent{
|
|
Element: "vllm", Op: "upgrade",
|
|
})).To(Succeed())
|
|
|
|
Eventually(done, "2s", "10ms").Should(Receive(),
|
|
"peer must fire its UpgradeChecker hook when any replica completes a backend op")
|
|
})
|
|
|
|
It("survives a nil OnModelsChanged callback (subscriber set but no handler)", func() {
|
|
Expect(svcA.SubscribeBroadcasts()).To(Succeed())
|
|
Expect(svcB.SubscribeBroadcasts()).To(Succeed())
|
|
// No callbacks registered on either side — just ensure publish does not panic.
|
|
Expect(bus.Publish(messaging.SubjectCacheInvalidateModels, messaging.CacheInvalidateEvent{
|
|
Element: "x", Op: "install",
|
|
})).To(Succeed())
|
|
})
|
|
|
|
It("BroadcastModelsChanged delivers the element and op to a peer's OnModelsChanged", func() {
|
|
var (
|
|
mu sync.Mutex
|
|
seen []messaging.CacheInvalidateEvent
|
|
)
|
|
svcB.OnModelsChanged = func(evt messaging.CacheInvalidateEvent) {
|
|
mu.Lock()
|
|
seen = append(seen, evt)
|
|
mu.Unlock()
|
|
}
|
|
Expect(svcA.SubscribeBroadcasts()).To(Succeed())
|
|
Expect(svcB.SubscribeBroadcasts()).To(Succeed())
|
|
|
|
// An admin edit on replica A must reach replica B over the same subject
|
|
// the gallery path uses, so B refreshes its in-memory config loader.
|
|
svcA.BroadcastModelsChanged("my-alias", "install")
|
|
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
Expect(seen).To(ContainElement(messaging.CacheInvalidateEvent{
|
|
Element: "my-alias", Op: "install",
|
|
}))
|
|
})
|
|
|
|
It("BroadcastModelsChanged is a no-op when NATS is not wired (standalone)", func() {
|
|
standalone := galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
// No SetNATSClient: must not panic and must simply do nothing.
|
|
Expect(func() { standalone.BroadcastModelsChanged("x", "delete") }).ToNot(Panic())
|
|
})
|
|
})
|
|
|
|
var _ = Describe("GalleryService PostgreSQL hydration", func() {
|
|
It("rebuilds the in-memory statuses map from active rows", func() {
|
|
db := testutil.SetupTestDB()
|
|
store, err := distributed.NewGalleryStore(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
Expect(store.Create(&distributed.GalleryOperationRecord{
|
|
ID: "active-op",
|
|
GalleryElementName: "llama-3-8b",
|
|
OpType: "model_install",
|
|
Status: "downloading",
|
|
Progress: 65.0,
|
|
Message: "fetching shards",
|
|
})).To(Succeed())
|
|
Expect(store.Create(&distributed.GalleryOperationRecord{
|
|
ID: "failed-op",
|
|
GalleryElementName: "broken",
|
|
OpType: "backend_install",
|
|
Status: "downloading",
|
|
Progress: 10.0,
|
|
Error: "oci pull failed",
|
|
})).To(Succeed())
|
|
Expect(store.Create(&distributed.GalleryOperationRecord{
|
|
ID: "completed-op",
|
|
GalleryElementName: "done",
|
|
OpType: "model_install",
|
|
Status: "completed",
|
|
Progress: 100.0,
|
|
})).To(Succeed())
|
|
|
|
svc := galleryop.NewGalleryService(&config.ApplicationConfig{}, nil)
|
|
svc.SetGalleryStore(store)
|
|
Expect(svc.Hydrate()).To(Succeed())
|
|
|
|
active := svc.GetStatus("active-op")
|
|
Expect(active).ToNot(BeNil(), "in-flight op must be hydrated")
|
|
Expect(active.Progress).To(Equal(65.0))
|
|
Expect(active.Message).To(Equal("fetching shards"))
|
|
Expect(active.GalleryElementName).To(Equal("llama-3-8b"))
|
|
|
|
failed := svc.GetStatus("failed-op")
|
|
Expect(failed).ToNot(BeNil())
|
|
Expect(failed.Error).ToNot(BeNil(),
|
|
"the persisted error message must be reconstructed as an error value")
|
|
Expect(failed.Error.Error()).To(Equal("oci pull failed"))
|
|
|
|
Expect(svc.GetStatus("completed-op")).To(BeNil(),
|
|
"completed ops are filtered out of ListActive and must not hydrate")
|
|
})
|
|
})
|