mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-30 01:54:31 -04:00
Five of the six families whose subscriber is an open HTTP response rather than a process-lifetime cache now travel on pgbus: jobs.<id>.progress, jobs.<id>.result, jobs.<id>.cancel, agent.<name>.events.<user> and responses.<id>.cancel. Both ends of each move together, so there is no state where a publisher is on one carrier and its subscriber on the other. agent.<name>.cancel does NOT move, and the plan was wrong about why. Its only subscriber in the tree is the agent worker, which has no database and so cannot join the PostgreSQL carrier at all. Publishing that cancel on pgbus would have lost every cancel of a worker-run agent while returning nil, which reports a cancel that reached nobody as a cancel that was sent. EventBridge now names its cancel carrier separately, a frontend replica sets it to the carrier the worker reads, and it stays there until a cancel rides the worker's tunnel like every other verb addressed to a worker. The carrier drops at 256 rather than blocking, which is not safe on its own for a result: a lost result has no successor message. It is not the only path. The claiming replica persists the terminal line before it releases the claim, and an open progress stream re-reads the job row once after subscribing and then periodically, so a dropped terminal broadcast costs promptness and never the answer. Both per-request subscriptions close in a defer instead of on one return path, and pgbus grows Subscribers() so the leak they would otherwise cause can be asserted. It has no other symptom: only the first subscriber of a channel issues a LISTEN, so a leaked filter just adds one closure per notification for every stream the replica has ever served. Subscribe now issues its LISTEN before it registers, which makes that count a readiness signal rather than a figure to compare against itself. Two rules that were stated at several sites and pinned at none are now one each. The re-broadcaster is built beside the dispatcher and the bridge and handed to the dispatch loop, so no line is left that can point it at a carrier nobody subscribes to while every spec stays green. The set of statuses a job never leaves is one exported set that the SSE bridge and the store both read. The last hand-written subject filter in production code became messaging.SubjectAgentEventsWildcard. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
276 lines
9.8 KiB
Go
276 lines
9.8 KiB
Go
package distributed_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/services/advisorylock"
|
|
"github.com/mudler/LocalAI/core/services/cluster"
|
|
"github.com/mudler/LocalAI/core/services/jobs"
|
|
"github.com/mudler/LocalAI/core/services/messaging"
|
|
"github.com/mudler/LocalAI/core/services/nodes"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
pgdriver "gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
var _ = Describe("Job Dispatch", Label("Distributed"), func() {
|
|
var (
|
|
infra *TestInfra
|
|
db *gorm.DB
|
|
store *jobs.JobStore
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
infra = SetupInfra("localai_dispatch_test")
|
|
|
|
var err error
|
|
db, err = gorm.Open(pgdriver.Open(infra.PGURL), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Silent),
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
store, err = jobs.NewJobStore(db)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
})
|
|
|
|
Context("Claim-queue dispatch", func() {
|
|
// The whole path, against a real PostgreSQL: Enqueue writes a claim
|
|
// row, a frontend replica takes it with SELECT ... FOR UPDATE SKIP
|
|
// LOCKED, drives it as a streaming control RPC, and persists the
|
|
// worker's terminal line before it releases the claim.
|
|
It("enqueues a claim, drives it on a worker, and persists what the worker answered", func() {
|
|
Expect(cluster.Migrate(infra.Ctx, db)).To(Succeed())
|
|
const owner = "dispatch-instance"
|
|
// A replica that is not registered may not claim: its claims could
|
|
// not be told from ones a dead replica left.
|
|
Expect(cluster.NewRegistry(db).Register(infra.Ctx, owner, "127.0.0.1:8080", "v1")).To(Succeed())
|
|
|
|
dispatcher := jobs.NewDispatcher(store, infra.Bus(), db, owner)
|
|
|
|
task := &jobs.TaskRecord{UserID: "u1", Name: "dispatch-task", Model: "m1", Prompt: "p1"}
|
|
store.CreateTask(task)
|
|
job := &jobs.JobRecord{TaskID: task.ID, UserID: "u1", Status: "pending", TriggeredBy: "api"}
|
|
store.CreateJob(job)
|
|
|
|
Expect(dispatcher.Enqueue(job.ID, task.ID, "u1")).To(Succeed())
|
|
|
|
var claimed int64
|
|
Expect(db.Model(&jobs.WorkClaim{}).Count(&claimed).Error).To(Succeed())
|
|
Expect(claimed).To(Equal(int64(1)), "the enqueue must leave a row, not a publish nobody may be listening for")
|
|
|
|
worker := &scriptedWorker{reply: jobs.ClaimReply{JobID: job.ID, Status: "completed", Result: "done"}}
|
|
loop, err := jobs.NewDispatchLoop(jobs.DispatchConfig{
|
|
DB: db,
|
|
Owner: owner,
|
|
Selector: fixedAgent{},
|
|
Control: worker,
|
|
Store: store,
|
|
Liveness: time.Minute,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// The claim kind is "task" here (no MCP servers on the model), and
|
|
// no worker serves it, so the loop closes the job out with a reason
|
|
// rather than leaving it running for ever. Give the row a kind a
|
|
// worker DOES serve, so this spec exercises the dispatch path.
|
|
Expect(db.Model(&jobs.WorkClaim{}).Where("kind = ?", string(jobs.ClaimKindTask)).
|
|
Update("kind", string(jobs.ClaimKindMCPCI)).Error).To(Succeed())
|
|
|
|
Expect(loop.DispatchOnce(infra.Ctx)).To(Succeed())
|
|
|
|
Expect(worker.calls.Load()).To(Equal(int32(1)))
|
|
updated, _ := store.GetJob(job.ID)
|
|
Expect(updated.Status).To(Equal("completed"))
|
|
Expect(updated.Result).To(Equal("done"))
|
|
|
|
Expect(db.Model(&jobs.WorkClaim{}).Count(&claimed).Error).To(Succeed())
|
|
Expect(claimed).To(BeZero(), "an answered claim must not be able to run again")
|
|
})
|
|
|
|
It("leaves a plain task job failed with a reason, since no worker in this deployment serves that kind", func() {
|
|
Expect(cluster.Migrate(infra.Ctx, db)).To(Succeed())
|
|
const owner = "plain-instance"
|
|
Expect(cluster.NewRegistry(db).Register(infra.Ctx, owner, "127.0.0.1:8081", "v1")).To(Succeed())
|
|
|
|
dispatcher := jobs.NewDispatcher(store, infra.Bus(), db, owner)
|
|
task := &jobs.TaskRecord{UserID: "u1", Name: "plain-task", Model: "m1", Prompt: "p1"}
|
|
Expect(store.CreateTask(task)).To(Succeed())
|
|
job := &jobs.JobRecord{TaskID: task.ID, UserID: "u1", Status: "pending", TriggeredBy: "api"}
|
|
Expect(store.CreateJob(job)).To(Succeed())
|
|
Expect(dispatcher.Enqueue(job.ID, task.ID, "u1")).To(Succeed())
|
|
|
|
worker := &scriptedWorker{}
|
|
loop, err := jobs.NewDispatchLoop(jobs.DispatchConfig{
|
|
DB: db, Owner: owner, Selector: fixedAgent{}, Control: worker,
|
|
Store: store, Liveness: time.Minute,
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(loop.DispatchOnce(infra.Ctx)).To(Succeed())
|
|
|
|
Expect(worker.calls.Load()).To(BeZero(), "nothing serves this kind, so nothing may be asked of a worker")
|
|
updated, _ := store.GetJob(job.ID)
|
|
Expect(updated.Status).To(Equal("failed"))
|
|
Expect(updated.Error).To(ContainSubstring("plain task jobs"))
|
|
})
|
|
})
|
|
|
|
Context("PostgreSQL job persistence", func() {
|
|
It("should persist job state in PostgreSQL via JobStore", func() {
|
|
task := &jobs.TaskRecord{UserID: "u1", Name: "persist-task", Model: "m1", Prompt: "run something"}
|
|
Expect(store.CreateTask(task)).To(Succeed())
|
|
Expect(task.ID).ToNot(BeEmpty())
|
|
|
|
job := &jobs.JobRecord{
|
|
TaskID: task.ID,
|
|
UserID: "u1",
|
|
Status: "pending",
|
|
TriggeredBy: "api",
|
|
}
|
|
Expect(store.CreateJob(job)).To(Succeed())
|
|
Expect(job.ID).ToNot(BeEmpty())
|
|
|
|
// Verify retrieval
|
|
retrieved, err := store.GetJob(job.ID)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
Expect(retrieved.TaskID).To(Equal(task.ID))
|
|
Expect(retrieved.Status).To(Equal("pending"))
|
|
|
|
// Update status
|
|
Expect(store.UpdateJobStatus(job.ID, "running", "", "")).To(Succeed())
|
|
running, _ := store.GetJob(job.ID)
|
|
Expect(running.Status).To(Equal("running"))
|
|
Expect(running.StartedAt).ToNot(BeNil())
|
|
|
|
// Complete
|
|
Expect(store.UpdateJobStatus(job.ID, "completed", "output data", "")).To(Succeed())
|
|
completed, _ := store.GetJob(job.ID)
|
|
Expect(completed.Status).To(Equal("completed"))
|
|
Expect(completed.Result).To(Equal("output data"))
|
|
Expect(completed.CompletedAt).ToNot(BeNil())
|
|
})
|
|
})
|
|
|
|
Context("job cancellation", func() {
|
|
// Cancellation stays a BROADCAST and is not part of the claim queue: the
|
|
// replica holding a run is not the one an API cancel lands on, so the
|
|
// signal has to reach every replica and every worker.
|
|
//
|
|
// Asserted across TWO carriers, because one carrier hearing itself
|
|
// proves nothing about the replica that actually holds the execution.
|
|
// A cancel that does not arrive is not a cancel that was refused, so
|
|
// what is pinned here is arrival and never the publisher's error.
|
|
It("broadcasts a cancel for a job on the job's own cancel subject", func() {
|
|
publisher, listener := infra.Bus(), infra.Bus()
|
|
dispatcher := jobs.NewDispatcher(store, publisher, db, "cancel-instance")
|
|
|
|
task := &jobs.TaskRecord{UserID: "u1", Name: "cancel-task", Model: "m1", Prompt: "p1"}
|
|
store.CreateTask(task)
|
|
job := &jobs.JobRecord{TaskID: task.ID, UserID: "u1", Status: "pending", TriggeredBy: "api"}
|
|
store.CreateJob(job)
|
|
|
|
seen := make(chan string, 1)
|
|
sub, err := listener.Subscribe(messaging.SubjectJobCancelWildcard, func(data []byte) {
|
|
var evt jobs.CancelEvent
|
|
if json.Unmarshal(data, &evt) == nil {
|
|
select {
|
|
case seen <- evt.JobID:
|
|
default:
|
|
}
|
|
}
|
|
})
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer func() { _ = sub.Unsubscribe() }()
|
|
|
|
Expect(dispatcher.Cancel(job.ID)).To(Succeed())
|
|
Eventually(seen, "10s").Should(Receive(Equal(job.ID)))
|
|
})
|
|
})
|
|
|
|
Context("Cron leader election", func() {
|
|
It("should elect one cron leader via advisory lock", func() {
|
|
sqlDB, err := db.DB()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
conn1, err := sqlDB.Conn(context.Background())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer conn1.Close()
|
|
|
|
conn2, err := sqlDB.Conn(context.Background())
|
|
Expect(err).ToNot(HaveOccurred())
|
|
defer conn2.Close()
|
|
|
|
// Instance 1 acquires the cron leader lock
|
|
var acquired1 bool
|
|
conn1.QueryRowContext(context.Background(),
|
|
"SELECT pg_try_advisory_lock($1)", advisorylock.KeyCronScheduler).Scan(&acquired1)
|
|
Expect(acquired1).To(BeTrue())
|
|
|
|
// Instance 2 cannot acquire
|
|
var acquired2 bool
|
|
conn2.QueryRowContext(context.Background(),
|
|
"SELECT pg_try_advisory_lock($1)", advisorylock.KeyCronScheduler).Scan(&acquired2)
|
|
Expect(acquired2).To(BeFalse())
|
|
|
|
// Instance 1 releases
|
|
conn1.ExecContext(context.Background(),
|
|
"SELECT pg_advisory_unlock($1)", advisorylock.KeyCronScheduler)
|
|
|
|
// Now instance 2 can acquire
|
|
conn2.QueryRowContext(context.Background(),
|
|
"SELECT pg_try_advisory_lock($1)", advisorylock.KeyCronScheduler).Scan(&acquired2)
|
|
Expect(acquired2).To(BeTrue())
|
|
conn2.ExecContext(context.Background(),
|
|
"SELECT pg_advisory_unlock($1)", advisorylock.KeyCronScheduler)
|
|
})
|
|
})
|
|
|
|
Context("Without --distributed", func() {
|
|
It("should use local channel without --distributed", func() {
|
|
appCfg := config.NewApplicationConfig()
|
|
Expect(appCfg.Distributed.Enabled).To(BeFalse())
|
|
|
|
// Without distributed mode, jobs use local in-process dispatch.
|
|
// The JobStore can still be used standalone with SQLite or in-memory.
|
|
Expect(appCfg.Distributed.NatsURL).To(BeEmpty())
|
|
})
|
|
})
|
|
})
|
|
|
|
// fixedAgent is a selection that always names one connected agent worker. The
|
|
// selection itself is pinned against real connection rows in
|
|
// core/services/nodes; what this suite drives is the dispatch that follows it.
|
|
type fixedAgent struct{}
|
|
|
|
func (fixedAgent) PickConnected(context.Context) (string, string, error) {
|
|
return "agent-node-1", nodes.NodeTypeAgent, nil
|
|
}
|
|
|
|
// scriptedWorker answers a streaming control RPC with a scripted reply, so this
|
|
// suite exercises the claim, the persist and the settle against a real database
|
|
// without needing a worker process.
|
|
type scriptedWorker struct {
|
|
calls atomic.Int32
|
|
reply jobs.ClaimReply
|
|
err error
|
|
}
|
|
|
|
func (w *scriptedWorker) CallStreaming(_ context.Context, _, _ string, _, reply any,
|
|
_ func(string, json.RawMessage)) error {
|
|
w.calls.Add(1)
|
|
if w.err != nil {
|
|
return w.err
|
|
}
|
|
if out, ok := reply.(*jobs.ClaimReply); ok {
|
|
*out = w.reply
|
|
}
|
|
return nil
|
|
}
|