Files
LocalAI/tests/e2e/distributed/job_dispatch_test.go
T
Ettore Di Giacinto 06f58d0d88 feat(distributed): dispatch queued work as a claim queue
The three NATS queue groups jobs.new, jobs.mcp-ci.new and agent.execute are
gone. Dispatching work is now a row in a work_claims table, taken by one
frontend replica with SELECT ... FOR UPDATE SKIP LOCKED and driven on an agent
worker as a streaming control RPC over that worker's tunnel. Exactly-one
delivery among competing consumers is a database problem, not a broker feature.
An agent worker has no database, so it never claims; it executes what the
claiming replica hands it.

A claim must not outlive the replica that took it. The reap releases a claim
whose owner is no longer a live replica in the instances table, on the database
clock, and never asks how long the claim has been held. A job that legitimately
runs for an hour on a heartbeating replica is left alone, while a claim whose
owner stopped heartbeating becomes claimable again within one liveness window.
A replica with no advertised address has no instances row at all, so it refuses
to claim rather than have its work reaped out from under it mid-run.

The settle rule is stated once, in settleClaim, and every exit path calls it. A
transport failure releases the claim and never completes or discards it; only a
decoded reply line completes it. That line is deliberately not
cluster.IsWorkerAnswer, which accepts the stream refusals a worker's tunnel
writes before any request body reaches its control server: completing on those
would discard work that never ran.

The terminal line is persisted before the claim is completed, so a store that
refuses leaves the claim standing rather than leaving the job running for ever.
That is the dropped-result defect fixed structurally rather than by retry.

This also surfaces a pre-existing gap rather than causing one: no worker has
ever served plain task jobs, and publishing them into an empty queue group left
them running with no trace. Such a claim is now failed with a reason.

Removes QueueWorkers, --agent-subject and --agent-queue, and narrows an agent
worker's minted JWT by agent.execute and jobs.mcp-ci.new.

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

271 lines
9.5 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.NC, 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.NC, 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("NATS 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.
It("broadcasts a cancel for a job on the job's own cancel subject", func() {
dispatcher := jobs.NewDispatcher(store, infra.NC, 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 := infra.NC.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() }()
FlushNATS(infra.NC)
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
}