mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-23 14:44:54 -04:00
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>
237 lines
6.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|