Skip to content

Commit ab01ed1

Browse files
localai-botmudler
andauthored
fix(agentpool): close truncate-then-read race in agent_jobs.json persistence (#9811)
* fix(agentpool): close truncate-then-read race in agent_jobs.json persistence Three call sites wrote and read agent_jobs.json (and agent_tasks.json) through three independent mutexes: - AgentJobService.ExecuteJob spawns go saveJobs(job) -> fileJobPersister holding p.mu - AgentJobService.SaveJobsToFile holding service.fileMutex - AgentJobService.LoadJobsFromFile on a separate service instance holding a different service.fileMutex Nothing serialized those mutexes, and both writers used os.WriteFile, which opens O_TRUNC. A reader landing between the truncate and the write saw a zero-byte file and surfaced as `unexpected end of JSON input` at offset 0. The macOS tests-apple job started hitting this consistently once the path filter was removed from .github/workflows/test.yml and the file-mode race test ran on every push (run 25823124797 was the first observed failure). Two changes close the window: 1. fileJobPersister.saveTasksToFile / saveJobsToFile now write to a same-directory temp file and os.Rename to the final path. rename(2) is atomic on POSIX, so concurrent readers see either the prior contents or the new contents and never a zero-byte window. The helper Syncs before close so a crash mid-write leaves either the old file intact or the temp behind (cleaned up on next save). 2. AgentJobService.{Load,Save}{Tasks,Jobs}{FromFile,ToFile} are collapsed to thin wrappers around fileJobPersister, removing the duplicate write path and the redundant service.fileMutex / service.tasksFile / service.jobsFile fields. Within a single service all task/job I/O now serializes on the persister's mutex; the atomic rename handles the cross-instance case the tests exercise. Adds a regression test that hammers SaveJobsToFile and LoadJobsFromFile concurrently for 500ms across two service instances on the same paths. On master this reproduces `unexpected end of JSON input` on Linux within ~500ms; with the fix the suite ran -until-it-fails for 30s (54 attempts, all green). Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(agentpool): route service flush/load through JobPersister interface The first cut of the race fix made AgentJobService.{Save,Load}{Tasks,Jobs}* type-assert s.persister to *fileJobPersister so they could reach the unexported saveTasksToFile / saveJobsToFile helpers. That defeats the JobPersister interface: the service is back to reasoning about a concrete implementation instead of an abstraction. Promote the bulk-flush operations to the interface as FlushTasks / FlushJobs: - fileJobPersister.FlushTasks/FlushJobs call the existing private helpers (atomic temp+rename writes from the prior commit). - dbJobPersister.FlushTasks/FlushJobs are no-ops because SaveTask/SaveJob are already write-through to the database. The service's four file-named methods now talk only to the interface: LoadTasks/LoadJobs read through s.persister.LoadTasks/LoadJobs, and the Save side calls FlushTasks/FlushJobs. The "FromFile"/"ToFile" suffixes stay for backward compat with user_services.go and the existing tests, but they no longer claim a file-only contract. Assisted-by: Claude:claude-opus-4-7 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 6bfe7f8 commit ab01ed1

5 files changed

Lines changed: 149 additions & 106 deletions

File tree

core/services/agentpool/agent_jobs.go

Lines changed: 20 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"io"
1212
"net"
1313
"net/http"
14-
"os"
1514
"path/filepath"
1615
"slices"
1716
"strings"
@@ -46,8 +45,6 @@ type AgentJobService struct {
4645
tasks *xsync.SyncedMap[string, schema.Task]
4746
jobs *xsync.SyncedMap[string, schema.Job]
4847
persister JobPersister
49-
tasksFile string // Path to agent_tasks.json (kept for backward compat)
50-
jobsFile string // Path to agent_jobs.json (kept for backward compat)
5148
userID string // Scoping: empty for global (main service), set for per-user instances
5249

5350
// Job execution channel
@@ -70,9 +67,6 @@ type AgentJobService struct {
7067
// Service lifecycle
7168
ctx context.Context
7269
cancel context.CancelFunc
73-
74-
// Mutex for file operations
75-
fileMutex sync.Mutex
7670
}
7771

7872
// DistributedDispatcher is the interface for distributed job dispatching via NATS.
@@ -220,8 +214,6 @@ func NewAgentJobServiceWithPaths(
220214
tasksFile: tasksFile,
221215
jobsFile: jobsFile,
222216
},
223-
tasksFile: tasksFile,
224-
jobsFile: jobsFile,
225217
jobQueue: make(chan JobExecution, 100), // Buffer for 100 jobs
226218
cancellations: xsync.NewSyncedMap[string, context.CancelFunc](),
227219
cronScheduler: cron.New(), // Support seconds in cron
@@ -230,127 +222,51 @@ func NewAgentJobServiceWithPaths(
230222
}
231223
}
232224

233-
// LoadTasksFromFile loads tasks from agent_tasks.json
225+
// LoadTasksFromFile loads tasks from the persister into the in-memory map
226+
// and schedules cron entries. Named "FromFile" for backward compat; in DB
227+
// mode it loads from the database.
234228
func (s *AgentJobService) LoadTasksFromFile() error {
235-
if s.tasksFile == "" {
236-
return nil // No file path configured
237-
}
238-
239-
s.fileMutex.Lock()
240-
defer s.fileMutex.Unlock()
241-
242-
if _, err := os.Stat(s.tasksFile); os.IsNotExist(err) {
243-
xlog.Debug("agent_tasks.json not found, starting with empty tasks")
244-
return nil
245-
}
246-
247-
fileContent, err := os.ReadFile(s.tasksFile)
229+
tasks, err := s.persister.LoadTasks(s.userID)
248230
if err != nil {
249-
return fmt.Errorf("failed to read tasks file: %w", err)
250-
}
251-
252-
var tasksFile schema.TasksFile
253-
if err := json.Unmarshal(fileContent, &tasksFile); err != nil {
254-
return fmt.Errorf("failed to parse tasks file: %w", err)
231+
return err
255232
}
256-
257-
for _, task := range tasksFile.Tasks {
233+
for _, task := range tasks {
258234
s.tasks.Set(task.ID, task)
259-
// Schedule cron if enabled and has cron expression
260235
if task.Enabled && task.Cron != "" {
261236
if err := s.ScheduleCronTask(task); err != nil {
262237
xlog.Warn("Failed to schedule cron task on load", "error", err, "task_id", task.ID)
263238
}
264239
}
265240
}
266-
267-
xlog.Info("Loaded tasks from file", "count", len(tasksFile.Tasks))
268-
269241
return nil
270242
}
271243

272-
// SaveTasksToFile saves tasks to agent_tasks.json
244+
// SaveTasksToFile flushes the current tasks map via the persister. File
245+
// persister bulk-writes the JSON file atomically; DB persister no-ops
246+
// because per-task SaveTask calls already wrote through.
273247
func (s *AgentJobService) SaveTasksToFile() error {
274-
if s.tasksFile == "" {
275-
return nil // No file path configured
276-
}
277-
278-
s.fileMutex.Lock()
279-
defer s.fileMutex.Unlock()
280-
281-
tasksFile := schema.TasksFile{
282-
Tasks: s.tasks.Values(),
283-
}
284-
285-
fileContent, err := json.MarshalIndent(tasksFile, "", " ")
286-
if err != nil {
287-
return fmt.Errorf("failed to marshal tasks: %w", err)
288-
}
289-
290-
if err := os.WriteFile(s.tasksFile, fileContent, 0600); err != nil {
291-
return fmt.Errorf("failed to write tasks file: %w", err)
292-
}
293-
294-
return nil
248+
return s.persister.FlushTasks()
295249
}
296250

297-
// LoadJobsFromFile loads jobs from agent_jobs.json
251+
// LoadJobsFromFile loads jobs from the persister into the in-memory map.
252+
// Named "FromFile" for backward compat; in DB mode it loads from the
253+
// database.
298254
func (s *AgentJobService) LoadJobsFromFile() error {
299-
if s.jobsFile == "" {
300-
return nil // No file path configured
301-
}
302-
303-
s.fileMutex.Lock()
304-
defer s.fileMutex.Unlock()
305-
306-
if _, err := os.Stat(s.jobsFile); os.IsNotExist(err) {
307-
xlog.Debug("agent_jobs.json not found, starting with empty jobs")
308-
return nil
309-
}
310-
311-
fileContent, err := os.ReadFile(s.jobsFile)
255+
jobs, err := s.persister.LoadJobs(s.userID)
312256
if err != nil {
313-
return fmt.Errorf("failed to read jobs file: %w", err)
314-
}
315-
316-
var jobsFile schema.JobsFile
317-
if err := json.Unmarshal(fileContent, &jobsFile); err != nil {
318-
return fmt.Errorf("failed to parse jobs file: %w", err)
257+
return err
319258
}
320-
321-
// Load jobs into memory
322-
for _, job := range jobsFile.Jobs {
259+
for _, job := range jobs {
323260
s.jobs.Set(job.ID, job)
324261
}
325-
326-
xlog.Info("Loaded jobs from file", "count", len(jobsFile.Jobs))
327262
return nil
328263
}
329264

330-
// SaveJobsToFile saves jobs to agent_jobs.json
265+
// SaveJobsToFile flushes the current jobs map via the persister. File
266+
// persister bulk-writes the JSON file atomically; DB persister no-ops
267+
// because per-job SaveJob calls already wrote through.
331268
func (s *AgentJobService) SaveJobsToFile() error {
332-
if s.jobsFile == "" {
333-
return nil // No file path configured
334-
}
335-
336-
s.fileMutex.Lock()
337-
defer s.fileMutex.Unlock()
338-
339-
jobsFile := schema.JobsFile{
340-
Jobs: s.jobs.Values(),
341-
LastCleanup: time.Now(),
342-
}
343-
344-
fileContent, err := json.MarshalIndent(jobsFile, "", " ")
345-
if err != nil {
346-
return fmt.Errorf("failed to marshal jobs: %w", err)
347-
}
348-
349-
if err := os.WriteFile(s.jobsFile, fileContent, 0600); err != nil {
350-
return fmt.Errorf("failed to write jobs file: %w", err)
351-
}
352-
353-
return nil
269+
return s.persister.FlushJobs()
354270
}
355271

356272
// CreateTask creates a new task

core/services/agentpool/agent_jobs_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"os"
66
"path/filepath"
7+
"sync"
78
"time"
89

910
"github.com/mudler/LocalAI/core/config"
@@ -281,6 +282,71 @@ var _ = Describe("AgentJobService", func() {
281282
Expect(err).NotTo(HaveOccurred())
282283
Expect(retrieved.TaskID).To(Equal(taskID))
283284
})
285+
286+
It("does not surface a partial file when saves and loads race", func() {
287+
// Regression for the macOS-only CI flake where a concurrent
288+
// LoadJobsFromFile landed between os.WriteFile's open(O_TRUNC)
289+
// and write, yielding "unexpected end of JSON input" at offset 0.
290+
// Atomic temp+rename in the persister eliminates the window.
291+
task := schema.Task{
292+
Name: "Race Task",
293+
Model: "test-model",
294+
Prompt: "Test prompt",
295+
Enabled: true,
296+
}
297+
298+
taskID, err := service.CreateTask(task)
299+
Expect(err).NotTo(HaveOccurred())
300+
301+
_, err = service.ExecuteJob(taskID, map[string]string{}, "test", nil)
302+
Expect(err).NotTo(HaveOccurred())
303+
Expect(service.SaveJobsToFile()).To(Succeed())
304+
305+
newService := agentpool.NewAgentJobService(
306+
appConfig,
307+
modelLoader,
308+
configLoader,
309+
evaluator,
310+
)
311+
312+
var wg sync.WaitGroup
313+
deadline := time.Now().Add(500 * time.Millisecond)
314+
readerErrs := make(chan error, 1024)
315+
316+
for range 4 {
317+
wg.Add(1)
318+
go func() {
319+
defer wg.Done()
320+
for time.Now().Before(deadline) {
321+
_ = service.SaveJobsToFile()
322+
}
323+
}()
324+
}
325+
326+
for range 4 {
327+
wg.Add(1)
328+
go func() {
329+
defer wg.Done()
330+
for time.Now().Before(deadline) {
331+
if err := newService.LoadJobsFromFile(); err != nil {
332+
readerErrs <- err
333+
return
334+
}
335+
}
336+
}()
337+
}
338+
339+
wg.Wait()
340+
close(readerErrs)
341+
342+
var firstErr error
343+
for err := range readerErrs {
344+
if firstErr == nil {
345+
firstErr = err
346+
}
347+
}
348+
Expect(firstErr).NotTo(HaveOccurred(), "concurrent load saw a partial/empty file")
349+
})
284350
})
285351

286352
Describe("Prompt templating", func() {

core/services/agentpool/job_persister.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ type JobPersister interface {
1616
SaveJob(userID string, job schema.Job) error
1717
DeleteJob(jobID string) error
1818

19+
// Bulk flush of the current in-memory state. File-backed persister
20+
// rewrites the whole JSON file; DB-backed persister no-ops because
21+
// SaveTask/SaveJob are already write-through.
22+
FlushTasks() error
23+
FlushJobs() error
24+
1925
// Authoritative reads — DB returns fresh data; file returns nil, nil
2026
GetJob(jobID string) (*schema.Job, error)
2127
ListJobs(userID, taskID, status string, limit int) ([]schema.Job, error)

core/services/agentpool/job_persister_db.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ func (p *dbJobPersister) DeleteJob(jobID string) error {
3232
return p.store.DeleteJob(jobID)
3333
}
3434

35+
// FlushTasks is a no-op: SaveTask already writes through to the DB.
36+
func (p *dbJobPersister) FlushTasks() error { return nil }
37+
38+
// FlushJobs is a no-op: SaveJob already writes through to the DB.
39+
func (p *dbJobPersister) FlushJobs() error { return nil }
40+
3541
func (p *dbJobPersister) GetJob(jobID string) (*schema.Job, error) {
3642
rec, err := p.store.GetJob(jobID)
3743
if err != nil {

core/services/agentpool/job_persister_file.go

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"path/filepath"
78
"sync"
89
"time"
910

@@ -41,6 +42,14 @@ func (p *fileJobPersister) DeleteJob(_ string) error {
4142
return p.saveJobsToFile()
4243
}
4344

45+
func (p *fileJobPersister) FlushTasks() error {
46+
return p.saveTasksToFile()
47+
}
48+
49+
func (p *fileJobPersister) FlushJobs() error {
50+
return p.saveJobsToFile()
51+
}
52+
4453
// GetJob returns nil — file persister has no authoritative reads.
4554
func (p *fileJobPersister) GetJob(_ string) (*schema.Job, error) {
4655
return nil, nil
@@ -127,7 +136,7 @@ func (p *fileJobPersister) saveTasksToFile() error {
127136
return fmt.Errorf("failed to marshal tasks: %w", err)
128137
}
129138

130-
return os.WriteFile(p.tasksFile, data, 0600)
139+
return writeFileAtomic(p.tasksFile, data, 0600)
131140
}
132141

133142
// saveJobsToFile serializes the entire jobs map to the JSON file.
@@ -149,5 +158,45 @@ func (p *fileJobPersister) saveJobsToFile() error {
149158
return fmt.Errorf("failed to marshal jobs: %w", err)
150159
}
151160

152-
return os.WriteFile(p.jobsFile, data, 0600)
161+
return writeFileAtomic(p.jobsFile, data, 0600)
162+
}
163+
164+
// writeFileAtomic writes data to path via a same-directory temp file + rename.
165+
// os.WriteFile opens with O_TRUNC, so a concurrent reader can land between the
166+
// truncate and the write and see an empty file ("unexpected end of JSON input").
167+
// rename(2) is atomic on POSIX, so readers see either the prior contents or the
168+
// new contents and never a zero-byte window.
169+
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
170+
dir := filepath.Dir(path)
171+
tmp, err := os.CreateTemp(dir, filepath.Base(path)+".tmp-*")
172+
if err != nil {
173+
return fmt.Errorf("failed to create temp file: %w", err)
174+
}
175+
tmpPath := tmp.Name()
176+
removeTmp := func() { _ = os.Remove(tmpPath) }
177+
178+
if _, err := tmp.Write(data); err != nil {
179+
_ = tmp.Close()
180+
removeTmp()
181+
return fmt.Errorf("failed to write temp file: %w", err)
182+
}
183+
if err := tmp.Chmod(perm); err != nil {
184+
_ = tmp.Close()
185+
removeTmp()
186+
return fmt.Errorf("failed to chmod temp file: %w", err)
187+
}
188+
if err := tmp.Sync(); err != nil {
189+
_ = tmp.Close()
190+
removeTmp()
191+
return fmt.Errorf("failed to sync temp file: %w", err)
192+
}
193+
if err := tmp.Close(); err != nil {
194+
removeTmp()
195+
return fmt.Errorf("failed to close temp file: %w", err)
196+
}
197+
if err := os.Rename(tmpPath, path); err != nil {
198+
removeTmp()
199+
return fmt.Errorf("failed to rename temp file: %w", err)
200+
}
201+
return nil
153202
}

0 commit comments

Comments
 (0)