Files
LocalAI/core/services/agentpool/user_services.go
T
Ettore Di Giacinto 94bfbd2221 feat(distributed): carry the state.*.delta families on PostgreSQL
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>
2026-09-20 03:05:34 +00:00

237 lines
6.8 KiB
Go

package agentpool
import (
"sync"
"github.com/mudler/LocalAGI/services/skills"
"github.com/mudler/LocalAGI/webui/collections"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/jobs"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// UserServicesManager lazily creates per-user service instances for
// collections, skills, and jobs.
type UserServicesManager struct {
mu sync.RWMutex
storage *UserScopedStorage
appConfig *config.ApplicationConfig
modelLoader *model.ModelLoader
configLoader *config.ModelConfigLoader
evaluator *templates.Evaluator
collectionsCache map[string]collections.Backend
skillsCache map[string]*skills.Service
jobsCache map[string]*AgentJobService
// Shared distributed backends (set once, inherited by per-user job services)
jobDispatcher DistributedDispatcher
jobDBStore *jobs.JobStore
// jobBus keeps per-user agent tasks consistent across replicas (nil in
// standalone). Inherited by each per-user AgentJobService.
jobBus messaging.Broadcaster
}
// NewUserServicesManager creates a new UserServicesManager.
func NewUserServicesManager(
storage *UserScopedStorage,
appConfig *config.ApplicationConfig,
modelLoader *model.ModelLoader,
configLoader *config.ModelConfigLoader,
evaluator *templates.Evaluator,
) *UserServicesManager {
return &UserServicesManager{
storage: storage,
appConfig: appConfig,
modelLoader: modelLoader,
configLoader: configLoader,
evaluator: evaluator,
collectionsCache: make(map[string]collections.Backend),
skillsCache: make(map[string]*skills.Service),
jobsCache: make(map[string]*AgentJobService),
}
}
// GetCollections returns the collections backend for a user, creating it lazily.
func (m *UserServicesManager) GetCollections(userID string) (collections.Backend, error) {
m.mu.RLock()
if backend, ok := m.collectionsCache[userID]; ok {
m.mu.RUnlock()
return backend, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
// Double-check after acquiring write lock
if backend, ok := m.collectionsCache[userID]; ok {
return backend, nil
}
if err := m.storage.EnsureUserDirs(userID); err != nil {
return nil, err
}
cfg := m.appConfig.AgentPool
apiURL := cfg.APIURL
if apiURL == "" {
apiURL = "http://127.0.0.1:" + getPort(m.appConfig)
}
apiKey := cfg.APIKey
if apiKey == "" && len(m.appConfig.ApiKeys) > 0 {
apiKey = m.appConfig.ApiKeys[0]
}
collectionsCfg := &collections.Config{
LLMAPIURL: apiURL,
LLMAPIKey: apiKey,
LLMModel: cfg.DefaultModel,
CollectionDBPath: m.storage.CollectionsDir(userID),
FileAssets: m.storage.AssetsDir(userID),
VectorEngine: cfg.VectorEngine,
EmbeddingModel: cfg.EmbeddingModel,
MaxChunkingSize: cfg.MaxChunkingSize,
ChunkOverlap: cfg.ChunkOverlap,
DatabaseURL: cfg.DatabaseURL,
}
backend, _ := collections.NewInProcessBackend(collectionsCfg)
m.collectionsCache[userID] = backend
return backend, nil
}
// GetSkills returns the skills service for a user, creating it lazily.
func (m *UserServicesManager) GetSkills(userID string) (*skills.Service, error) {
m.mu.RLock()
if svc, ok := m.skillsCache[userID]; ok {
m.mu.RUnlock()
return svc, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
if svc, ok := m.skillsCache[userID]; ok {
return svc, nil
}
if err := m.storage.EnsureUserDirs(userID); err != nil {
return nil, err
}
skillsDir := m.storage.SkillsDir(userID)
svc, err := skills.NewService(skillsDir)
if err != nil {
return nil, err
}
m.skillsCache[userID] = svc
return svc, nil
}
// GetJobs returns the agent job service for a user, creating it lazily.
func (m *UserServicesManager) GetJobs(userID string) (*AgentJobService, error) {
m.mu.RLock()
if svc, ok := m.jobsCache[userID]; ok {
m.mu.RUnlock()
return svc, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
if svc, ok := m.jobsCache[userID]; ok {
return svc, nil
}
if err := m.storage.EnsureUserDirs(userID); err != nil {
return nil, err
}
svc := NewAgentJobServiceWithPaths(
m.appConfig,
m.modelLoader,
m.configLoader,
m.evaluator,
m.storage.TasksFile(userID),
m.storage.JobsFile(userID),
)
// Set user ID for per-user DB scoping
svc.SetUserID(userID)
// Inherit distributed backends so per-user jobs go through NATS + DB
if m.jobDispatcher != nil {
svc.SetDistributedBackends(m.jobDispatcher)
}
// Inherit the broadcast carrier so per-user tasks fan out across replicas.
// Must be set before the hydrate below (LoadFromDB / LoadTasksFromFile) so the
// tasks SyncedMap is rebuilt with the carrier while it is still empty.
//
// This is a second wiring site for the same rule, and it is the one that is
// invisible: fixing the global service alone leaves every tenant's map on
// whatever carrier this manager was handed, with nothing failing.
svc.SetTaskSyncBus(m.jobBus)
if m.jobDBStore != nil {
svc.SetDistributedJobStore(m.jobDBStore)
// Load tasks/jobs from DB immediately (per-user services skip Start())
svc.LoadFromDB()
} else {
// Load from per-user files
if err := svc.LoadTasksFromFile(); err != nil {
xlog.Warn("Failed to load tasks from file for user", "userID", userID, "error", err)
}
if err := svc.LoadJobsFromFile(); err != nil {
xlog.Warn("Failed to load jobs from file for user", "userID", userID, "error", err)
}
}
m.jobsCache[userID] = svc
return svc, nil
}
// SetJobDispatcher sets the distributed dispatcher for per-user job services.
func (m *UserServicesManager) SetJobDispatcher(d DistributedDispatcher) {
m.jobDispatcher = d
}
// SetJobDBStore sets the database-backed job store for per-user job services.
func (m *UserServicesManager) SetJobDBStore(s *jobs.JobStore) {
m.jobDBStore = s
}
// SetJobSyncBus sets the broadcast carrier used to keep per-user agent tasks
// consistent across replicas. Every per-user service built afterwards inherits
// it; see the call in the builder above.
func (m *UserServicesManager) SetJobSyncBus(bus messaging.Broadcaster) {
m.jobBus = bus
}
// ListAllUserIDs returns all user IDs that have scoped data directories.
func (m *UserServicesManager) ListAllUserIDs() ([]string, error) {
return m.storage.ListUserDirs()
}
// getPort extracts the port from the API address config.
func getPort(appConfig *config.ApplicationConfig) string {
addr := appConfig.APIAddress
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[i+1:]
}
}
return addr
}
// StopAll stops all cached job services.
func (m *UserServicesManager) StopAll() {
m.mu.Lock()
defer m.mu.Unlock()
for _, svc := range m.jobsCache {
if err := svc.Stop(); err != nil {
xlog.Error("Failed to stop user job service", "error", err)
}
}
}