mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-13 22:57:36 -04:00
syncstate.Config held one carrier field typed as the NATS client, so a pgbus.Bus could not be handed to a SyncedMap at all: it satisfies messaging.Broadcaster and not MessagingClient. The durable re-hydration path built for the responses map therefore had a NATS-only consumer and nothing in the build said so. The field becomes Bus messaging.Broadcaster, SubscribeJSON moves to its own file and relaxes its parameter to Broadcaster, and the four adopters fan out over PostgreSQL LISTEN/NOTIFY: fine-tune jobs, quantization jobs, agent tasks with their per-tenant children, and Open Responses metadata. A new spec proves it on a real database, over two Bus instances on two pinned listener connections: a Set and a Delete carry, a payload past the 8000-byte notification cap comes back byte identical through the spill row, two families sharing one LISTEN channel stay separate, and a terminated listener re-hydrates a row written while it was gone. The five sites that each chose a carrier for an adopter are collapsed into one DistributedServices.Broadcast() accessor. Five field reads were five chances to leave one family on NATS with nothing failing, because messaging.Client satisfies Broadcaster too. The accessor also refuses to hand out a nil pgbus.Bus wrapped in a non-nil interface, which every adopter would read as "broadcast" and dereference on the first Set. SetTaskSyncNATS and SetJobSyncNATS are renamed to SetTaskSyncBus and SetJobSyncBus so a missed wiring site fails to compile. The response metadata table gains a retention of its own, defaulting to 24 hours. It inherited the Open Responses store TTL, which defaults to 0 meaning no expiration. Zero is defensible for a map that dies with the process and is not for a table: the table grew for the life of the deployment and a restarting replica re-hydrated every response the cluster had ever created. A row that names its own expiry is still judged on that column alone, and "this row is dead" now has one SQL spelling that PurgeExpired deletes by and ListUnexpired is the negation of, so a hydrate cannot resurrect what a sweep has already retired. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
173 lines
6.0 KiB
Go
173 lines
6.0 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/mudler/LocalAI/core/services/distributed"
|
|
"github.com/mudler/LocalAI/core/services/pgbus"
|
|
"github.com/mudler/LocalAI/core/services/syncstate"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// ftSyncStore adapts the real FineTuneStore to syncstate.Store, exactly as the
|
|
// finetune service does in production. Defined here (rather than reusing the
|
|
// service's unexported adapter) so the e2e exercises the store + component over
|
|
// real infrastructure without pulling in backend execution.
|
|
type ftSyncStore struct{ s *distributed.FineTuneStore }
|
|
|
|
func (a ftSyncStore) List(_ context.Context) ([]*distributed.FineTuneJobRecord, error) {
|
|
recs, err := a.s.ListAll()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]*distributed.FineTuneJobRecord, len(recs))
|
|
for i := range recs {
|
|
r := recs[i]
|
|
out[i] = &r
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a ftSyncStore) Upsert(_ context.Context, r *distributed.FineTuneJobRecord) error {
|
|
return a.s.Upsert(r)
|
|
}
|
|
|
|
func (a ftSyncStore) Delete(_ context.Context, k string) error { return a.s.Delete(k) }
|
|
|
|
// This suite is the real-infrastructure counterpart to the fake-bus unit tests:
|
|
// two SyncedMap instances stand in for two LocalAI frontend replicas, each with
|
|
// its OWN pinned LISTEN connection to the shared PostgreSQL and a SHARED store
|
|
// on the same database - the exact distributed-mode invariant (single shared
|
|
// DB, per-replica process state). It proves the delta path works over the wire
|
|
// and that a late-joining replica recovers via store hydrate (the at-most-once
|
|
// gap a fake bus cannot exercise).
|
|
//
|
|
// There is no NATS here any more. This family's deltas ride the deployment's
|
|
// PostgreSQL broadcast carrier, so a suite that still proved them over a broker
|
|
// would be proving something the product no longer does.
|
|
var _ = Describe("SyncedMap two-replica sync over the PostgreSQL carrier", Label("Distributed"), func() {
|
|
var (
|
|
infra *TestInfra
|
|
db *gorm.DB
|
|
ftStore *distributed.FineTuneStore
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
infra = SetupInfra("localai_syncstate_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())
|
|
|
|
Expect(pgbus.Migrate(infra.Ctx, db)).To(Succeed())
|
|
|
|
ftStore, err = distributed.NewFineTuneStore(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
// newReplica builds an independent "replica": its own carrier, with its own
|
|
// pinned LISTEN connection, plus a SyncedMap over the shared store, started
|
|
// (hydrate + subscribe) and cleaned up automatically.
|
|
//
|
|
// No flush is needed where NATS needed one: pgbus.Subscribe returns only
|
|
// after the server has acknowledged the LISTEN, so a subscription that has
|
|
// been registered cannot miss a NOTIFY issued afterwards.
|
|
newReplica := func() *syncstate.SyncedMap[string, *distributed.FineTuneJobRecord] {
|
|
GinkgoHelper()
|
|
bus, err := pgbus.New(infra.Ctx, pgbus.Config{DSN: infra.PGURL, DB: db})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
sm := syncstate.New(syncstate.Config[string, *distributed.FineTuneJobRecord]{
|
|
Name: "finetune.jobs",
|
|
Key: func(r *distributed.FineTuneJobRecord) string { return r.ID },
|
|
Bus: bus,
|
|
Store: ftSyncStore{s: ftStore},
|
|
})
|
|
Expect(sm.Start(infra.Ctx)).To(Succeed())
|
|
DeferCleanup(func() {
|
|
_ = sm.Close()
|
|
bus.Close()
|
|
})
|
|
return sm
|
|
}
|
|
|
|
rec := func(id, status string) *distributed.FineTuneJobRecord {
|
|
return &distributed.FineTuneJobRecord{
|
|
ID: id, UserID: "u1", Model: "m", Backend: "b",
|
|
TrainingType: "lora", TrainingMethod: "sft", Status: status,
|
|
}
|
|
}
|
|
|
|
It("propagates a create from replica A to replica B over the wire", func() {
|
|
a := newReplica()
|
|
b := newReplica()
|
|
|
|
Expect(a.Set(infra.Ctx, rec("job-1", "queued"))).To(Succeed())
|
|
|
|
Eventually(func() bool { _, ok := b.Get("job-1"); return ok }, "10s", "50ms").
|
|
Should(BeTrue(), "replica B must observe the job created on A over the carrier")
|
|
|
|
got, ok := b.Get("job-1")
|
|
Expect(ok).To(BeTrue())
|
|
Expect(got.Status).To(Equal("queued"))
|
|
})
|
|
|
|
It("propagates an update and a delete across replicas", func() {
|
|
a := newReplica()
|
|
b := newReplica()
|
|
|
|
Expect(a.Set(infra.Ctx, rec("job-2", "queued"))).To(Succeed())
|
|
Eventually(func() bool { _, ok := b.Get("job-2"); return ok }, "10s", "50ms").Should(BeTrue())
|
|
|
|
// Update on A -> B reflects the new status.
|
|
Expect(a.Set(infra.Ctx, rec("job-2", "training"))).To(Succeed())
|
|
Eventually(func() string {
|
|
if r, ok := b.Get("job-2"); ok {
|
|
return r.Status
|
|
}
|
|
return ""
|
|
}, "10s", "50ms").Should(Equal("training"))
|
|
|
|
// Delete on A -> B prunes (a reload-from-path could not do this).
|
|
Expect(a.Delete(infra.Ctx, "job-2")).To(Succeed())
|
|
Eventually(func() bool { _, ok := b.Get("job-2"); return ok }, "10s", "50ms").
|
|
Should(BeFalse(), "replica B must drop the job deleted on A")
|
|
})
|
|
|
|
It("hydrates a late-joining replica from the shared store (missed-delta recovery)", func() {
|
|
a := newReplica()
|
|
|
|
// Written (and broadcast) BEFORE replica C exists, so C can never receive
|
|
// the delta - it can only learn the job by hydrating from shared Postgres
|
|
// on Start. This is the at-most-once gap a fake bus cannot exercise.
|
|
Expect(a.Set(infra.Ctx, rec("job-3", "completed"))).To(Succeed())
|
|
Eventually(func() (*distributed.FineTuneJobRecord, error) { return ftStore.Get("job-3") }, "10s", "50ms").
|
|
ShouldNot(BeNil(), "write-through must reach the shared store first")
|
|
|
|
c := newReplica() // joins late; Start() hydrates from the store synchronously
|
|
|
|
got, ok := c.Get("job-3")
|
|
Expect(ok).To(BeTrue(), "late replica must recover the job via store hydrate, not a delta")
|
|
Expect(got.Status).To(Equal("completed"))
|
|
})
|
|
|
|
It("write-through persists a local Set to the shared PostgreSQL store", func() {
|
|
a := newReplica()
|
|
|
|
Expect(a.Set(infra.Ctx, rec("job-4", "queued"))).To(Succeed())
|
|
|
|
persisted, err := ftStore.Get("job-4")
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(persisted.ID).To(Equal("job-4"))
|
|
Expect(persisted.Status).To(Equal("queued"))
|
|
})
|
|
})
|