Skip to content

Commit 64c1576

Browse files
committed
enhancements, refactorings
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent a5e212d commit 64c1576

33 files changed

Lines changed: 381 additions & 208 deletions

core/application/application.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -167,26 +167,27 @@ func (a *Application) StartAgentPool() {
167167
if !a.applicationConfig.AgentPool.Enabled {
168168
return
169169
}
170-
aps, err := agentpool.NewAgentPoolService(a.applicationConfig)
170+
// Build options struct from available dependencies
171+
opts := agentpool.AgentPoolOptions{
172+
AuthDB: a.authDB,
173+
}
174+
if d := a.Distributed(); d != nil {
175+
if d.DistStores != nil && d.DistStores.Skills != nil {
176+
opts.SkillStore = d.DistStores.Skills
177+
}
178+
opts.NATSClient = d.Nats
179+
opts.EventBridge = d.AgentBridge
180+
opts.AgentStore = d.AgentStore
181+
}
182+
183+
aps, err := agentpool.NewAgentPoolService(a.applicationConfig, opts)
171184
if err != nil {
172185
xlog.Error("Failed to create agent pool service", "error", err)
173186
return
174187
}
175-
if a.authDB != nil {
176-
aps.SetAuthDB(a.authDB)
177-
}
188+
178189
// Wire distributed mode components
179190
if d := a.Distributed(); d != nil {
180-
if d.DistStores != nil && d.DistStores.Skills != nil {
181-
aps.SetSkillStore(d.DistStores.Skills)
182-
}
183-
aps.SetNATSClient(d.Nats)
184-
if d.AgentBridge != nil {
185-
aps.SetEventBridge(d.AgentBridge)
186-
}
187-
if d.AgentStore != nil {
188-
aps.SetAgentStore(d.AgentStore)
189-
}
190191
// Wait for at least one healthy backend worker before starting the agent pool.
191192
// Collections initialization calls embeddings which require a worker.
192193
if d.Registry != nil {

core/application/distributed.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66
"fmt"
7+
"io"
78
"strings"
89

910
"github.com/google/uuid"
@@ -49,6 +50,9 @@ func (ds *DistributedServices) Shutdown() {
4950
if ds.Dispatcher != nil {
5051
ds.Dispatcher.Stop()
5152
}
53+
if closer, ok := ds.Store.(io.Closer); ok {
54+
closer.Close()
55+
}
5256
// AgentBridge has no Close method — its NATS subscriptions are cleaned up
5357
// when the NATS client is closed below.
5458
if ds.Nats != nil {
@@ -107,7 +111,7 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
107111
if cfg.Distributed.StorageBucket == "" {
108112
return nil, fmt.Errorf("distributed storage bucket must be set when storage URL is configured")
109113
}
110-
s3Store, err := storage.NewS3Store(storage.S3Config{
114+
s3Store, err := storage.NewS3Store(context.Background(), storage.S3Config{
111115
Endpoint: cfg.Distributed.StorageURL,
112116
Region: cfg.Distributed.StorageRegion,
113117
Bucket: cfg.Distributed.StorageBucket,

core/cli/worker.go

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,8 @@ func (cmd *WorkerCMD) Run(ctx *cliContext.Context) error {
189189
// subscribeFileStaging subscribes to NATS file staging subjects for this node.
190190
func (cmd *WorkerCMD) subscribeFileStaging(natsClient messaging.MessagingClient, nodeID string) error {
191191
// Create FileManager with same S3 config as the frontend
192-
s3Store, err := storage.NewS3Store(storage.S3Config{
192+
// TODO: propagate a caller-provided context once WorkerCMD carries one
193+
s3Store, err := storage.NewS3Store(context.Background(), storage.S3Config{
193194
Endpoint: cmd.StorageURL,
194195
Region: cmd.StorageRegion,
195196
Bucket: cmd.StorageBucket,
@@ -383,9 +384,10 @@ func (s *backendSupervisor) startBackend(backend, backendPath string) (string, e
383384
port = s.nextPort
384385
s.nextPort++
385386
}
386-
addr := fmt.Sprintf("0.0.0.0:%d", port)
387+
bindAddr := fmt.Sprintf("0.0.0.0:%d", port)
388+
clientAddr := fmt.Sprintf("127.0.0.1:%d", port)
387389

388-
proc, err := s.ml.StartProcess(backendPath, backend, addr)
390+
proc, err := s.ml.StartProcess(backendPath, backend, bindAddr)
389391
if err != nil {
390392
s.mu.Unlock()
391393
return "", fmt.Errorf("starting backend process: %w", err)
@@ -394,17 +396,17 @@ func (s *backendSupervisor) startBackend(backend, backendPath string) (string, e
394396
s.processes[backend] = &backendProcess{
395397
proc: proc,
396398
backend: backend,
397-
addr: addr,
399+
addr: clientAddr,
398400
}
399-
xlog.Info("Backend process started", "backend", backend, "addr", addr)
401+
xlog.Info("Backend process started", "backend", backend, "addr", clientAddr)
400402

401403
// Capture reference before unlocking for race-safe health check.
402404
// Another goroutine could stopBackend and recycle the port while we poll.
403405
bp := s.processes[backend]
404406
s.mu.Unlock()
405407

406408
// Wait for the gRPC server to be ready
407-
client := grpc.NewClientWithToken(addr, false, nil, false, s.cmd.RegistrationToken)
409+
client := grpc.NewClientWithToken(clientAddr, false, nil, false, s.cmd.RegistrationToken)
408410
for range 20 {
409411
time.Sleep(200 * time.Millisecond)
410412
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@@ -417,27 +419,34 @@ func (s *backendSupervisor) startBackend(backend, backendPath string) (string, e
417419
if !exists || currentBP != bp {
418420
return "", fmt.Errorf("backend %s was stopped during startup", backend)
419421
}
420-
xlog.Debug("Backend gRPC server is ready", "backend", backend, "addr", addr)
421-
return addr, nil
422+
xlog.Debug("Backend gRPC server is ready", "backend", backend, "addr", clientAddr)
423+
return clientAddr, nil
422424
}
423425
cancel()
424426
}
425427

426-
xlog.Warn("Backend gRPC server not ready after waiting, proceeding anyway", "backend", backend, "addr", addr)
427-
return addr, nil
428+
xlog.Warn("Backend gRPC server not ready after waiting, proceeding anyway", "backend", backend, "addr", clientAddr)
429+
return clientAddr, nil
428430
}
429431

430432
// stopBackend stops a specific backend's gRPC process.
431433
func (s *backendSupervisor) stopBackend(backend string) {
432434
s.mu.Lock()
433-
defer s.mu.Unlock()
434-
435435
bp, ok := s.processes[backend]
436436
if !ok || bp.proc == nil {
437+
s.mu.Unlock()
437438
return
438439
}
440+
// Clean up map and recycle port while holding lock
441+
delete(s.processes, backend)
442+
if _, portStr, err := net.SplitHostPort(bp.addr); err == nil {
443+
if p, err := strconv.Atoi(portStr); err == nil {
444+
s.freePorts = append(s.freePorts, p)
445+
}
446+
}
447+
s.mu.Unlock()
439448

440-
// Best-effort Free() to release GPU memory
449+
// Network I/O outside the lock
441450
client := grpc.NewClientWithToken(bp.addr, false, nil, false, s.cmd.RegistrationToken)
442451
if freeFunc, ok := client.(interface{ Free() error }); ok {
443452
xlog.Debug("Calling Free() before stopping backend", "backend", backend)
@@ -450,13 +459,6 @@ func (s *backendSupervisor) stopBackend(backend string) {
450459
if err := bp.proc.Stop(); err != nil {
451460
xlog.Error("Error stopping backend process", "backend", backend, "error", err)
452461
}
453-
// Recycle the port for future backends
454-
if _, portStr, err := net.SplitHostPort(bp.addr); err == nil {
455-
if p, err := strconv.Atoi(portStr); err == nil {
456-
s.freePorts = append(s.freePorts, p)
457-
}
458-
}
459-
delete(s.processes, backend)
460462
}
461463

462464
// stopAllBackends stops all running backend processes.

core/cli/workerregistry/client.go

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type RegistrationClient struct {
2121
FrontendURL string
2222
RegistrationToken string
2323
HTTPTimeout time.Duration // used for registration calls; defaults to 10s
24+
client *http.Client
2425
}
2526

2627
// httpTimeout returns the configured timeout or a sensible default.
@@ -31,6 +32,14 @@ func (c *RegistrationClient) httpTimeout() time.Duration {
3132
return 10 * time.Second
3233
}
3334

35+
// httpClient returns the shared HTTP client, initializing it on first use.
36+
func (c *RegistrationClient) httpClient() *http.Client {
37+
if c.client == nil {
38+
c.client = &http.Client{Timeout: c.httpTimeout()}
39+
}
40+
return c.client
41+
}
42+
3443
// baseURL returns FrontendURL with any trailing slash stripped.
3544
func (c *RegistrationClient) baseURL() string {
3645
return strings.TrimRight(c.FrontendURL, "/")
@@ -62,7 +71,7 @@ func (c *RegistrationClient) Register(ctx context.Context, body map[string]any)
6271
req.Header.Set("Content-Type", "application/json")
6372
c.setAuth(req)
6473

65-
resp, err := (&http.Client{Timeout: c.httpTimeout()}).Do(req)
74+
resp, err := c.httpClient().Do(req)
6675
if err != nil {
6776
return "", "", fmt.Errorf("posting to %s: %w", url, err)
6877
}
@@ -114,11 +123,11 @@ func (c *RegistrationClient) Heartbeat(ctx context.Context, nodeID string, body
114123
req.Header.Set("Content-Type", "application/json")
115124
c.setAuth(req)
116125

117-
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
126+
resp, err := c.httpClient().Do(req)
118127
if err != nil {
119128
return err
120129
}
121-
resp.Body.Close()
130+
defer resp.Body.Close()
122131
return nil
123132
}
124133

@@ -147,11 +156,11 @@ func (c *RegistrationClient) Drain(ctx context.Context, nodeID string) error {
147156
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
148157
c.setAuth(req)
149158

150-
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
159+
resp, err := c.httpClient().Do(req)
151160
if err != nil {
152161
return err
153162
}
154-
resp.Body.Close()
163+
defer resp.Body.Close()
155164
if resp.StatusCode != http.StatusOK {
156165
return fmt.Errorf("drain failed with status %d", resp.StatusCode)
157166
}
@@ -162,16 +171,17 @@ func (c *RegistrationClient) Drain(ctx context.Context, nodeID string) error {
162171
// in-flight requests, or until timeout elapses.
163172
func (c *RegistrationClient) WaitForDrain(ctx context.Context, nodeID string, timeout time.Duration) {
164173
url := c.baseURL() + "/api/node/" + nodeID + "/models"
165-
client := &http.Client{Timeout: 5 * time.Second}
166174

167175
deadline := time.Now().Add(timeout)
168176
for time.Now().Before(deadline) {
169177
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
170178
c.setAuth(req)
171179

172-
resp, err := client.Do(req)
180+
resp, err := c.httpClient().Do(req)
173181
if err != nil {
174-
break
182+
xlog.Warn("Drain poll failed, will retry", "error", err)
183+
time.Sleep(1 * time.Second)
184+
continue
175185
}
176186
var models []struct {
177187
InFlight int `json:"in_flight"`
@@ -201,11 +211,11 @@ func (c *RegistrationClient) Deregister(ctx context.Context, nodeID string) erro
201211
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
202212
c.setAuth(req)
203213

204-
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
214+
resp, err := c.httpClient().Do(req)
205215
if err != nil {
206216
return err
207217
}
208-
resp.Body.Close()
218+
defer resp.Body.Close()
209219
if resp.StatusCode != http.StatusOK {
210220
return fmt.Errorf("deregistration failed with status %d", resp.StatusCode)
211221
}

core/services/advisorylock/advisorylock_test.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import (
1010
. "github.com/onsi/ginkgo/v2"
1111
. "github.com/onsi/gomega"
1212

13-
"github.com/mudler/LocalAI/core/services/messaging"
1413
"github.com/mudler/LocalAI/core/services/testutil"
1514
"gorm.io/gorm"
1615
)
@@ -97,12 +96,12 @@ var _ = Describe("AdvisoryLock", func() {
9796

9897
It("all advisory lock keys are unique", func() {
9998
keys := map[int64]string{
100-
messaging.AdvisoryLockCronScheduler: "messaging.AdvisoryLockCronScheduler",
101-
messaging.AdvisoryLockStaleNodeCleanup: "messaging.AdvisoryLockStaleNodeCleanup",
102-
messaging.AdvisoryLockGalleryDedup: "messaging.AdvisoryLockGalleryDedup",
103-
messaging.AdvisoryLockAgentScheduler: "messaging.AdvisoryLockAgentScheduler",
104-
messaging.AdvisoryLockHealthCheck: "messaging.AdvisoryLockHealthCheck",
105-
messaging.AdvisoryLockSchemaMigrate: "messaging.AdvisoryLockSchemaMigrate",
99+
KeyCronScheduler: "KeyCronScheduler",
100+
KeyStaleNodeCleanup: "KeyStaleNodeCleanup",
101+
KeyGalleryDedup: "KeyGalleryDedup",
102+
KeyAgentScheduler: "KeyAgentScheduler",
103+
KeyHealthCheck: "KeyHealthCheck",
104+
KeySchemaMigrate: "KeySchemaMigrate",
106105
}
107106

108107
Expect(keys).To(HaveLen(6), "some advisory lock keys have the same value")

core/services/advisorylock/keys.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package advisorylock
2+
3+
// Advisory lock keys for distributed coordination via PostgreSQL.
4+
// These keys are global across the database — avoid collisions with
5+
// other applications sharing the same PostgreSQL instance.
6+
const (
7+
KeyCronScheduler int64 = 100
8+
KeyStaleNodeCleanup int64 = 101
9+
KeyGalleryDedup int64 = 102
10+
KeyAgentScheduler int64 = 103
11+
KeyHealthCheck int64 = 104
12+
KeySchemaMigrate int64 = 105
13+
)

core/services/agentpool/agent_pool.go

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,8 @@ type AgentEventBridge interface {
7979
PublishMessage(agentName, userID, sender, content, messageID string) error
8080
PublishStatus(agentName, userID, status string) error
8181
PublishStreamEvent(agentName, userID string, data map[string]any) error
82-
RegisterCancel(agentName, userID string, cancel context.CancelFunc)
83-
DeregisterCancel(agentName, userID string)
82+
RegisterCancel(key string, cancel context.CancelFunc)
83+
DeregisterCancel(key string)
8484
}
8585

8686
// AgentConfigStore is the interface for agent config persistence.
@@ -93,10 +93,39 @@ type AgentConfigStore interface {
9393
UpdateLastRun(userID, name string) error
9494
}
9595

96-
func NewAgentPoolService(appConfig *config.ApplicationConfig) (*AgentPoolService, error) {
97-
return &AgentPoolService{
96+
// AgentPoolOptions holds optional dependencies for AgentPoolService.
97+
// Zero values are fine — the service degrades gracefully without them.
98+
type AgentPoolOptions struct {
99+
AuthDB *gorm.DB
100+
SkillStore *distributed.SkillStore
101+
NATSClient messaging.Publisher
102+
EventBridge AgentEventBridge
103+
AgentStore *agents.AgentStore
104+
}
105+
106+
func NewAgentPoolService(appConfig *config.ApplicationConfig, opts ...AgentPoolOptions) (*AgentPoolService, error) {
107+
svc := &AgentPoolService{
98108
appConfig: appConfig,
99-
}, nil
109+
}
110+
if len(opts) > 0 {
111+
o := opts[0]
112+
if o.AuthDB != nil {
113+
svc.users.authDB = o.AuthDB
114+
}
115+
if o.SkillStore != nil {
116+
svc.distributed.skillStore = o.SkillStore
117+
}
118+
if o.NATSClient != nil {
119+
svc.distributed.natsClient = o.NATSClient
120+
}
121+
if o.EventBridge != nil {
122+
svc.distributed.eventBridge = o.EventBridge
123+
}
124+
if o.AgentStore != nil {
125+
svc.distributed.agentStore = o.AgentStore
126+
}
127+
}
128+
return svc, nil
100129
}
101130

102131
func (s *AgentPoolService) Start(ctx context.Context) error {
@@ -337,16 +366,19 @@ func (s *AgentPoolService) Pool() *state.AgentPool {
337366
}
338367

339368
// SetNATSClient sets the NATS client for distributed agent execution.
369+
// Deprecated: prefer passing NATSClient via AgentPoolOptions at construction time.
340370
func (s *AgentPoolService) SetNATSClient(nc messaging.Publisher) {
341371
s.distributed.natsClient = nc
342372
}
343373

344374
// SetEventBridge sets the event bridge for distributed SSE + persistence.
375+
// Deprecated: prefer passing EventBridge via AgentPoolOptions at construction time.
345376
func (s *AgentPoolService) SetEventBridge(eb AgentEventBridge) {
346377
s.distributed.eventBridge = eb
347378
}
348379

349380
// SetAgentStore sets the PostgreSQL agent config store.
381+
// Deprecated: prefer passing AgentStore via AgentPoolOptions at construction time.
350382
func (s *AgentPoolService) SetAgentStore(store *agents.AgentStore) {
351383
s.distributed.agentStore = store
352384
}
@@ -578,11 +610,13 @@ func (s *AgentPoolService) UserServicesManager() *UserServicesManager {
578610
}
579611

580612
// SetAuthDB sets the auth database for API key generation.
613+
// Deprecated: prefer passing AuthDB via AgentPoolOptions at construction time.
581614
func (s *AgentPoolService) SetAuthDB(db *gorm.DB) {
582615
s.users.authDB = db
583616
}
584617

585618
// SetSkillStore sets the distributed skill store for persisting skill metadata to PostgreSQL.
619+
// Deprecated: prefer passing SkillStore via AgentPoolOptions at construction time.
586620
func (s *AgentPoolService) SetSkillStore(store *distributed.SkillStore) {
587621
s.distributed.skillStore = store
588622
}

0 commit comments

Comments
 (0)