Files
LocalAI/core/application/distributed.go
T
localai-org-maint-bot 8403d9da05 Merge master into test/distributed-e2e-ci
Preserve heartbeat checkpoints and backend readiness across the tunnel
transport changes. Update incoming tests for the renamed worker address
fields and health monitor arguments.

Assisted-by: Codex:gpt-6
2026-09-07 04:08:18 +00:00

1027 lines
48 KiB
Go

package application
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services/agents"
"github.com/mudler/LocalAI/core/services/cluster"
"github.com/mudler/LocalAI/core/services/distributed"
"github.com/mudler/LocalAI/core/services/jobs"
"github.com/mudler/LocalAI/core/services/messaging"
"github.com/mudler/LocalAI/core/services/monitoring"
"github.com/mudler/LocalAI/core/services/nodes"
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
"github.com/mudler/LocalAI/core/services/pgbus"
"github.com/mudler/LocalAI/core/services/storage"
"github.com/mudler/LocalAI/internal"
"github.com/mudler/LocalAI/pkg/distributedhdr"
"github.com/mudler/LocalAI/pkg/sanitize"
"github.com/mudler/xlog"
"gorm.io/gorm"
)
// DistributedServices holds all services initialized for distributed mode.
type DistributedServices struct {
Store storage.ObjectStore
Registry *nodes.NodeRegistry
Router *nodes.SmartRouter
Health *nodes.HealthMonitor
Reconciler *nodes.ReplicaReconciler
JobStore *jobs.JobStore
Dispatcher *jobs.Dispatcher
AgentStore *agents.AgentStore
AgentBridge *agents.EventBridge
DistStores *distributed.Stores
FileMgr *storage.FileManager
FileStager nodes.FileStager
ModelAdapter *nodes.ModelRouterAdapter
Unloader *nodes.RemoteUnloaderAdapter
ModelCleanup *nodes.ModelCleanupService
// Bus is the deployment's fan-out carrier, riding the auth database's
// PostgreSQL rather than a message broker. Every cross-replica family the
// frontend broadcasts is on it now, with the single exception named above.
// Adopters reach it through Broadcast() rather than through this field, so
// that "which carrier does this family travel on" is decided once instead
// of at every adopter; its DSN likewise has exactly one legitimate source,
// settled in newBroadcastBus rather than invented per call site.
Bus *pgbus.Bus
// Cluster is the replica-membership registry: which frontend replicas are
// alive, at which address, and which of them holds a given worker's tunnel.
Cluster *cluster.Registry
// Membership publishes this replica's row and reaps the dead. Nil when no
// peer-reachable address could be determined, which leaves this replica
// invisible to its peers but otherwise fully functional.
Membership *cluster.Membership
// PeerSessions owns the peer links other replicas dialled into this one,
// and relays the streams that arrive on them onto the worker tunnels this
// replica holds.
PeerSessions *cluster.SessionStore
// Peers owns the peer links this replica dialled OUT, the mirror of
// PeerSessions. It is what the relaying dialer opens a stream on when a
// request arrives here for a worker another replica holds.
Peers *cluster.PeerPool
// Tunnels holds the worker tunnels this replica has accepted and keeps the
// node_connections table agreeing with them. It is handed to the membership
// loop, which re-claims what it holds after this replica has been reaped,
// and to the route that accepts a worker's dial.
Tunnels *cluster.TunnelRegistry
// WorkerDialer is how anything in this process reaches a worker: locally
// when this replica holds the tunnel, and through the owning replica when
// it does not. The HTTP layer takes its WebSocket log proxy from here.
WorkerDialer *cluster.WorkerDialer
// BackendClients builds the gRPC clients for worker backend processes, over
// WorkerDialer. Exposed so the model store built in startup.go reaches
// remote models the same way every other caller does.
BackendClients nodes.BackendClientFactory
// AgentControl carries the frontend's MCP verbs to whichever agent worker
// holds a tunnel this deployment can reach. It is what the chat, responses,
// messages and MCP endpoints reach an agent worker through; a nil one means
// this frontend cannot run MCP at all, which is why initDistributed refuses
// to come up without it rather than leaving the endpoints to discover it
// one request at a time.
AgentControl *nodes.AgentControlClient
// JobDispatch takes queued work off the job store and drives it on an agent
// worker over that worker's tunnel. It is what replaces the three NATS
// queue groups: dispatch is a claim, and a claim is a row and a lock.
JobDispatch *jobs.DispatchLoop
shutdownOnce sync.Once
}
// Shutdown stops all distributed services in reverse initialization order.
// It is safe to call on a nil receiver and is idempotent (uses sync.Once).
func (ds *DistributedServices) Shutdown() {
if ds == nil {
return
}
ds.shutdownOnce.Do(func() {
// Peer state first: a replica that is going away should stop claiming
// to be alive before it stops answering, so peers re-home rather than
// dial a process in teardown.
if ds.Membership != nil {
ds.Membership.Stop()
}
if ds.PeerSessions != nil {
ds.PeerSessions.CloseAll()
}
// Both halves of the peer mesh go down together. A pool left open
// holds a WebSocket and two yamux loop goroutines per peer for as long
// as the process lives, and an Open after this reports ErrPoolClosed,
// which is a fact about this process and never node absence.
if ds.Peers != nil {
ds.Peers.Close()
}
if ds.Health != nil {
ds.Health.Stop()
}
if ds.JobDispatch != nil {
ds.JobDispatch.Stop()
}
if ds.Dispatcher != nil {
ds.Dispatcher.Stop()
}
if closer, ok := ds.Store.(io.Closer); ok {
closer.Close()
}
// AgentBridge has no Close method and needs none: it holds no
// process-lifetime subscription of its own beyond the observable
// persister, whose carrier is closed below.
// The broadcast carrier holds a PostgreSQL session pinned for the life
// of the process, plus the goroutine parked on it. A replica that
// leaves one behind on every restart runs the server out of
// connections, and the symptom lands on whatever connects next.
if ds.Bus != nil {
ds.Bus.Close()
}
xlog.Info("Distributed services shut down")
})
}
// initDistributed validates distributed mode prerequisites and initializes
// NATS, object storage, node registry, and instance identity.
// Returns nil if distributed mode is not enabled.
// configLoader is used by the SmartRouter to compute concurrency-group
// anti-affinity at placement time (#9659); it may be nil in tests.
//
// galleryProgress is the gallery service, narrowed to the one method a node
// departure needs. It is a PARAMETER and not a later setter because the
// registration of every per-node cache a departure evicts happens here, in one
// place, and a cache registered somewhere else is a cache a reader cannot find
// by reading this function.
func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoader *config.ModelConfigLoader, galleryProgress nodeProgressDropper) (*DistributedServices, error) {
if !cfg.Distributed.Enabled {
return nil, nil
}
xlog.Info("Distributed mode enabled — validating prerequisites")
// Validate distributed config (NATS URL, S3 credential pairing, durations, etc.)
if err := cfg.Distributed.Validate(); err != nil {
return nil, err
}
// Validate PostgreSQL is configured (auth DB must be PostgreSQL for distributed mode)
if !cfg.Auth.Enabled {
return nil, fmt.Errorf("distributed mode requires authentication to be enabled (--auth / LOCALAI_AUTH=true)")
}
if !isPostgresURL(cfg.Auth.DatabaseURL) {
return nil, fmt.Errorf("distributed mode requires PostgreSQL for auth database (got %q)", sanitize.URL(cfg.Auth.DatabaseURL))
}
// Generate instance ID if not set
if cfg.Distributed.InstanceID == "" {
cfg.Distributed.InstanceID = uuid.New().String()
}
xlog.Info("Distributed instance", "id", cfg.Distributed.InstanceID)
// No message bus is dialled here, and there is none left to dial. The last
// family that needed one was agent.<name>.cancel, whose subscriber is an
// agent worker that has no database and so could not join the broadcast
// carrier below; it is now a control RPC on the tunnel that worker holds.
// A distributed deployment needs PostgreSQL and the frontends' own HTTP
// listener, and nothing else.
//
// success guards the carriers opened below, which must not be left pinned
// when a later initialization step fails.
success := false
// Initialize object storage
var store storage.ObjectStore
if cfg.Distributed.StorageURL != "" {
if cfg.Distributed.StorageBucket == "" {
return nil, fmt.Errorf("distributed storage bucket must be set when storage URL is configured")
}
s3Store, err := storage.NewS3Store(context.Background(), storage.S3Config{
Endpoint: cfg.Distributed.StorageURL,
Region: cfg.Distributed.StorageRegion,
Bucket: cfg.Distributed.StorageBucket,
AccessKeyID: cfg.Distributed.StorageAccessKey,
SecretAccessKey: cfg.Distributed.StorageSecretKey,
ForcePathStyle: true, // required for MinIO
})
if err != nil {
return nil, fmt.Errorf("initializing S3 storage: %w", err)
}
xlog.Info("Object storage initialized (S3)", "endpoint", cfg.Distributed.StorageURL, "bucket", cfg.Distributed.StorageBucket)
store = s3Store
} else {
// Fallback to filesystem storage in distributed mode (useful for single-node testing)
fsStore, err := storage.NewFilesystemStore(cfg.DataPath + "/objectstore")
if err != nil {
return nil, fmt.Errorf("initializing filesystem storage: %w", err)
}
xlog.Info("Object storage initialized (filesystem fallback)", "path", cfg.DataPath+"/objectstore")
store = fsStore
}
// Initialize node registry (requires the auth DB which is PostgreSQL)
if authDB == nil {
return nil, fmt.Errorf("distributed mode requires auth database to be initialized first")
}
// The fan-out carrier, opened before anything that might want it. It is
// built here and not by its first adopter because its DSN has one
// legitimate source, and a setting that decides whether every broadcast in
// the deployment is delivered should not be settled under the time pressure
// of a migration.
bus, err := newBroadcastBus(cfg.Context, cfg, authDB)
if err != nil {
return nil, err
}
defer func() {
if !success {
bus.Close()
}
}()
registry, err := nodes.NewNodeRegistry(authDB)
if err != nil {
return nil, fmt.Errorf("initializing node registry: %w", err)
}
xlog.Info("Node registry initialized")
// Bound durable heartbeat writes: a beat that only carries a fresher
// timestamp is what turned backend_nodes into a 460 MB six-row table.
registry.SetHeartbeatCheckpoint(cfg.Distributed.NodeHeartbeatCheckpointOrDefault())
// Measure the vacuum horizon. The 42 days it stayed open went unnoticed
// because no gauge reported it until models started failing to load.
if err := monitoring.RegisterControlPlaneDBMetrics(authDB, 30*time.Second); err != nil {
// Metrics are diagnostic; a failure here must not stop the frontend.
xlog.Warn("Control-plane database metrics unavailable", "error", err)
}
// Replica membership. NewNodeRegistry has just migrated the tables this
// reads, so it has to come after it.
clusterRegistry := cluster.NewRegistry(authDB)
// This replica's proof of which replica it is, minted ONCE per process and
// handed to both halves of the peer mesh: the membership loop publishes its
// hash in the instances row, and the peer pool presents its plaintext on
// every outbound dial. Two mints would leave a replica whose published hash
// and presented secret disagree, which every peer refuses and which reads
// from the logs like a peer running an older release.
//
// The plaintext never leaves this process except in a peer dial's header.
// There is nothing to configure and nothing to rotate: a restart mints a
// new one, and the same registration that republishes this replica's
// address republishes the hash beside it.
peerCredential := cluster.NewPeerCredential()
var membership *cluster.Membership
if advertised, err := advertisedPeerAddr(cfg); err != nil {
// Not fatal, and the cost is worth stating exactly rather than as
// "peers cannot reach it", because it is larger than that now.
//
// Without a row in the instances table this replica is not a live
// owner as far as Registry.Owner is concerned: that read joins a
// connection against a live instance, so a worker whose tunnel lands
// HERE is answered as unroutable at every OTHER replica, for as long
// as it stays here. This replica serves that worker perfectly well
// itself; nobody else can. On N replicas behind round robin that is
// (N-1)/N of the traffic for that worker.
//
// It does not refuse to START. Refusing would take out every existing
// single-host deployment, whose route to a local database is loopback
// and which has no peers to be unreachable by; the deployments this
// hurts are multi-replica ones, and telling those two apart at startup
// is a change with its own design and its own specs rather than a line
// here.
//
// What it does not get to do is stay quiet. One startup line scrolls
// away in seconds and the cost is paid for the whole life of the
// process, on a symptom (workers that 5xx from most of the fleet) whose
// obvious reading is "the worker is broken". So this is an ERROR, not a
// warning, and nagUnadvertisedReplica below repeats it for as long as
// the state lasts, naming the workers it is currently costing.
//
// It now costs the OTHER direction too, which is why the line says so.
// A peer link is authenticated by the dialling replica's own credential,
// published in the instances row this replica never writes, so this
// replica cannot dial a peer either: its own relayed requests are
// refused as an unproven identity rather than merely arriving nowhere.
xlog.Error("This replica is not registered in the cluster: no advertised address. Peers cannot reach it, any worker whose tunnel lands here will be unroutable from every other replica, and this replica cannot relay OUT either, because a peer link is authenticated by the credential an instance row publishes and this replica has no row",
"error", err, "knob", "LOCALAI_DISTRIBUTED_ADVERTISE_ADDR")
} else {
membership = cluster.NewMembership(clusterRegistry, cfg.Distributed.InstanceID, advertised, internal.PrintableVersion(), peerCredential)
// Before Start, so the first sweep already purges on the retention this
// deployment's grace requires rather than on the floor.
membership.SetReconnectGrace(cfg.Distributed.ReconnectGraceOrDefault())
if err := membership.Start(cfg.Context); err != nil {
return nil, fmt.Errorf("registering this replica in the cluster: %w", err)
}
}
// The worker tunnels this replica accepts. It claims as the SAME instance
// ID membership registers under, because that is the ID a peer's Owner
// lookup joins a claim against to decide the owner is alive; two IDs here
// would make every claim this replica writes look like it belongs to a
// replica that does not exist.
tunnels := cluster.NewTunnelRegistry(clusterRegistry, cfg.Distributed.InstanceID)
// Without this the re-claim in the heartbeat loop is dead code: a replica
// stalled long enough to be swept loses the connection rows it owned, and
// nothing would ever write them back, so every other replica would answer
// "not connected" for workers that are connected right here.
//
// Nil when no peer-reachable address could be determined above. There is no
// heartbeat loop to hand it to in that case, and no other replica can reach
// this one anyway; the registry is still built, because it is what the
// tunnel endpoint attaches to and what this replica opens its own streams
// through.
if membership != nil {
membership.SetTunnels(tunnels)
} else {
// The runtime symptom the startup line cannot be. See
// nagUnadvertisedReplica.
go nagUnadvertisedReplica(cfg.Context, tunnels.Held, unadvertisedNagInterval, logUnroutableWorkers)
}
// The links peers dial IN, with the relay installed on them. This is what
// makes more than one replica work: a worker holds one tunnel, it lands on
// one replica, and every request that arrives anywhere else reaches the
// worker through this handler. Passing nil here would leave every such
// request refused, promptly and only at debug level, which presents as a
// worker that is connected and unusable from most of the deployment.
peerSessions := cluster.NewSessionStore(cluster.NewRelay(tunnels).Stream)
// The links this replica dials OUT, the other half of the same mesh. It
// authenticates with the registration token because that is the token the
// peer route checks (see RegisterClusterRoutes); two different tokens here
// would make every peer dial 401 with nothing naming the mismatch.
//
// And with this replica's own credential, which is the half that says WHICH
// replica is dialling. It is the same value membership published the hash
// of, by construction: there is one mint above and both call sites read it.
peers := cluster.NewPeerPool(cfg.Distributed.InstanceID, cfg.Distributed.RegistrationToken, peerCredential, clusterRegistry)
// The one door to every worker. Nothing in the frontend may dial a worker's
// advertised address any more: a worker holds ONE tunnel, it lands on ONE
// replica, and this resolves which replica that is and relays through it
// when it is not this one. The three transports the frontend speaks to a
// worker (gRPC to backend processes, HTTP for file staging and logs, a
// WebSocket for live log streaming) are all pointed at it below.
workerDialer := cluster.NewWorkerDialer(tunnels, peers)
backendClients, err := nodes.NewTunnelClientFactory(cfg.Distributed.RegistrationToken, workerDialer.GRPCDialerFor)
if err != nil {
return nil, fmt.Errorf("wiring the worker backend client factory: %w", err)
}
// Bound to the http tag: the worker ignores the target for it and routes to
// its own file-transfer and log server, wherever that bound.
workerHTTPDialer := nodes.WorkerNetDialerFor(func(nodeID string) func(ctx context.Context, network, addr string) (net.Conn, error) {
return workerDialer.DialerFor(nodeID, cluster.StreamTagHTTP)
})
// Let scheduling rules be keyed by a model alias. The registry resolves a
// rule's name through the config loader to find the model it governs, so an
// operator can pin placement to a stable name like "production" and have it
// follow the alias when the alias is repointed. Wired before the seed below
// and before the reconciler starts, so the first tick already resolves.
if configLoader != nil {
registry.SetAliasResolver(configLoader)
}
// Seed declarative per-model scheduling config (LOCALAI_MODEL_SCHEDULING /
// LOCALAI_MODEL_SCHEDULING_CONFIG). Authoritative: overwrites matching models
// on every boot. Runs before the reconciler starts so the first tick already
// sees the desired state. Models not listed are left untouched.
if cfg.Distributed.ModelSchedulingJSON != "" || cfg.Distributed.ModelSchedulingConfigPath != "" {
schedConfigs, err := nodes.ParseSchedulingSeed(cfg.Distributed.ModelSchedulingJSON, cfg.Distributed.ModelSchedulingConfigPath)
if err != nil {
return nil, fmt.Errorf("parsing declarative model scheduling config: %w", err)
}
if err := registry.SeedModelScheduling(context.Background(), schedConfigs); err != nil {
return nil, fmt.Errorf("seeding declarative model scheduling config: %w", err)
}
xlog.Info("Applied declarative model scheduling config", "models", len(schedConfigs))
}
// Collect SmartRouter option values; the router itself is created after all
// dependencies (including FileStager and Unloader) are ready.
var routerAuthToken string
if cfg.Distributed.RegistrationToken != "" {
routerAuthToken = cfg.Distributed.RegistrationToken
}
var routerGalleriesJSON string
if galleriesJSON, err := json.Marshal(cfg.BackendGalleries); err == nil {
routerGalleriesJSON = string(galleriesJSON)
}
// The health monitor is the SECOND reader of absence, and it reads it from
// the same place and against the same window as the scheduler: a heartbeat
// says the worker's supervisor is alive, presence says whether anything
// here can still reach its backends, and a worker can be the first without
// being the second indefinitely.
//
// The departure notifier is built HERE, before its only caller, and its
// subscribers are registered further down once the caches they drop exist.
// One object, one caller, so "what does a departure evict" is answered by
// reading registerDepartureEvictions and nothing else.
departures := nodes.NewDepartureNotifier()
healthMon := nodes.NewHealthMonitor(registry, authDB,
cfg.Distributed.HealthCheckIntervalOrDefault(),
cfg.Distributed.StaleNodeThresholdOrDefault(),
routerAuthToken,
!cfg.Distributed.DisablePerModelHealthCheck,
clusterRegistry,
cfg.Distributed.ReconnectGraceOrDefault(),
departures,
backendClients,
)
// Initialize job store
jobStore, err := jobs.NewJobStore(authDB)
if err != nil {
return nil, fmt.Errorf("initializing job store: %w", err)
}
xlog.Info("Distributed job store initialized")
// Initialize agent store
agentStore, err := agents.NewAgentStore(authDB)
if err != nil {
return nil, fmt.Errorf("initializing agent store: %w", err)
}
xlog.Info("Distributed agent store initialized")
// The frontend's control plane client. It reaches every worker over that
// worker's own tunnel, on the same `http` stream tag the file stager below
// uses, so a control RPC to a worker another replica holds is relayed the
// way an inference request is.
//
// ONE of these for the whole frontend, and the S3 file stager takes this
// one rather than minting a second. The client caches an http.Client per
// node, which is what keeps a worker's tunnel stream warm between verbs; a
// second client would open its own and the two would never share one.
controlClient := nodes.NewControlClient(workerHTTPDialer, cfg.Distributed.RegistrationToken)
// The caller the agent worker's control plane has been waiting for. MCP
// execution and discovery used to be a NATS request onto a queue group,
// where the bus chose the worker and neither side could say which one had
// answered; they are now a query against the connection rows plus an
// ordinary control RPC over the chosen worker's tunnel.
agentControl, err := newAgentControl(cfg.Distributed, registry, clusterRegistry, controlClient)
if err != nil {
return nil, fmt.Errorf("wiring the agent control client: %w", err)
}
// The job dispatcher and the agent event bridge, both on the broadcast
// carrier. See newFanoutBridges for why the two constructors are reached
// through one function that names *pgbus.Bus.
//
// The bridge takes agentControl and not a carrier: a cancel is the one
// family whose far end is an agent worker, and it now rides that worker's
// tunnel as a control RPC. It is built ABOVE for that reason, rather than
// with the rest of the control plane below.
dispatcher, agentBridge, rebroadcast, err := newFanoutBridges(bus, agentControl, jobStore, agentStore, authDB, cfg.Distributed.InstanceID)
if err != nil {
return nil, err
}
// Initialize Phase 4 stores (MCP, Gallery, FineTune, Skills)
distStores, err := distributed.InitStores(authDB)
if err != nil {
return nil, fmt.Errorf("initializing distributed stores: %w", err)
}
// Initialize file manager with local cache
cacheDir := cfg.DataPath + "/cache"
fileMgr, err := storage.NewFileManager(store, cacheDir)
if err != nil {
return nil, fmt.Errorf("initializing file manager: %w", err)
}
xlog.Info("File manager initialized", "cacheDir", cacheDir)
// The consumer side of the claim queue, built and started in one act: see
// startJobDispatchLoop for why those are not two lines.
jobDispatch, err := startJobDispatchLoop(cfg.Context, cfg.Distributed, authDB, jobStore, registry, clusterRegistry, controlClient, rebroadcast)
if err != nil {
return nil, fmt.Errorf("wiring the job dispatch loop: %w", err)
}
// Create FileStager for distributed file transfer
var fileStager nodes.FileStager
if cfg.Distributed.StorageURL != "" {
fileStager = nodes.NewS3FileStager(fileMgr, controlClient)
xlog.Info("File stager initialized (object store + worker tunnel)")
} else {
fileStager = nodes.NewHTTPFileStager(func(nodeID string) (string, error) {
node, err := registry.Get(context.Background(), nodeID)
if err != nil {
return "", err
}
// An empty HTTPAddress is no longer a refusal. A tunnel-only worker
// reports none and does not need one: the http stream tag ignores
// the target and the worker routes to its own server. The host is
// only ever the URL's host component here, and WorkerHTTPHost
// supplies one that resolves nowhere so it cannot become a dial.
return nodes.WorkerHTTPHost(nodeID, node.HTTPAddress), nil
}, cfg.Distributed.RegistrationToken, workerHTTPDialer)
xlog.Info("File stager initialized (HTTP direct transfer)")
}
// Create RemoteUnloaderAdapter — needed by SmartRouter and startup.go
remoteUnloader := nodes.NewRemoteUnloaderAdapter(
registry,
controlClient,
cfg.Distributed.BackendInstallTimeoutOrDefault(),
cfg.Distributed.BackendUpgradeTimeoutOrDefault(),
)
// Prefix-cache-aware routing. Enabled by default; an operator can opt out
// with --distributed-prefix-cache=false, which leaves prefixProvider and
// pressure nil so the SmartRouter and reconciler behave exactly as the
// round-robin floor (true no-op). When enabled we build the local index,
// wrap it in a Sync on the broadcast carrier (which both publishes our
// observations and applies peers'), install the extraction hook used by
// core/backend/llm.go, and run a background eviction ticker on the app ctx.
var prefixProvider prefixcache.Provider
var pressure *prefixcache.Pressure
var prefixCfg prefixcache.Config
// The CONCRETE Sync, declared out here so the departure wiring below can be
// handed it. Concrete and not prefixProvider, because a nil interface value
// carrying a nil *Sync is not nil, and the disabled deployment would then
// register an eviction that dereferences it.
var prefixDrop *prefixcache.Sync
if !cfg.Distributed.PrefixCacheDisabled {
prefixCfg = prefixcache.DefaultConfig()
if cfg.Distributed.PrefixCacheTTL > 0 {
prefixCfg.TTL = cfg.Distributed.PrefixCacheTTL
}
if err := prefixCfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid prefix-cache configuration: %w", err)
}
idx := prefixcache.NewIndex(prefixCfg)
// S4. One call puts this replica's observations and its peers' on the
// same carrier, and it takes the CONCRETE carrier so that no other
// thing satisfying messaging.Broadcaster can be handed to it by
// accident. There is no second carrier in this scope to hand over any
// more; the type stays narrow so there is still none on the day one is
// added. See cache_fanout_wiring.go for the whole argument.
prefixSync, err := wirePrefixCacheBroadcasts(bus, prefixCfg, idx)
if err != nil {
return nil, err
}
pressure = prefixcache.NewPressure(prefixCfg.PressureWindow)
prefixProvider = prefixSync
prefixDrop = prefixSync
// Invalidate the prefix-cache index whenever a replica row is removed.
// AddReplicaRemovedHook fires from the single chokepoint all removal paths
// funnel through (RemoveNodeModel / RemoveAllNodeModelReplicas), so this
// one hook covers every path: reconciler scale-down, probe reaper,
// health-monitor reap, RemoteUnloaderAdapter, and the router. Registering
// it only inside this enabled block keeps the disabled path a true no-op
// for the prefix cache; other subsystems register their own hooks
// independently and are unaffected either way.
registry.AddReplicaRemovedHook(func(model, node string, replica int) {
if replica < 0 {
prefixSync.InvalidateNode(model, node)
} else {
prefixSync.Invalidate(model, prefixcache.ReplicaKey{NodeID: node, Replica: replica})
}
})
distributedhdr.PrefixChainHook = func(model, prompt string) []uint64 {
return prefixcache.ExtractChain(model, prompt, prefixCfg)
}
// Background eviction: sweep idle entries on the app context. Stopped
// when the app context is cancelled (mirrors the reconciler loop which
// also runs on options.Context). TTL/2 keeps stale entries from
// outliving their idle window by more than half a TTL.
evictInterval := prefixCfg.TTL / 2
go func() {
ticker := time.NewTicker(evictInterval)
defer ticker.Stop()
for {
select {
case <-cfg.Context.Done():
return
case <-ticker.C:
prefixSync.Evict(time.Now())
}
}
}()
xlog.Info("Prefix-cache-aware routing enabled", "ttl", prefixCfg.TTL, "evictInterval", evictInterval)
} else {
xlog.Info("Prefix-cache-aware routing disabled: using round-robin routing")
}
// All dependencies ready — build SmartRouter with all options at once
var conflictResolver nodes.ConcurrencyConflictResolver
if configLoader != nil {
conflictResolver = configLoader
}
modelCleanup := nodes.NewModelCleanupService(registry, remoteUnloader)
// Absence is stamped on by distributedSchedulerOptions rather than written
// here. It is the only source of absence the scheduler has -- a fact read
// from the database, so every replica answers it identically, where the bus
// sentinel it replaces was one frontend's observation that nobody answered
// IT within a budget -- and a field carrying that in a literal this size is
// the easiest thing in this file to lose without a symptom.
router := nodes.NewSmartRouter(registry, distributedSchedulerOptions(cfg.Distributed, clusterRegistry, nodes.SmartRouterOptions{
Unloader: remoteUnloader,
ModelCleanup: modelCleanup,
FileStager: fileStager,
GalleriesJSON: routerGalleriesJSON,
AuthToken: routerAuthToken,
ClientFactory: backendClients,
DB: authDB,
ConflictResolver: conflictResolver,
PrefixProvider: prefixProvider,
PrefixConfig: prefixCfg,
Pressure: pressure,
SharedModels: cfg.Distributed.SharedModels,
// A closure over the live ApplicationConfig, NOT a snapshot: the
// runtime setting (distributed_disk_headroom_check) mutates this exact
// member, so a snapshot here would make the toggle a no-op until
// restart. env/CLI sets the boot value, POST /api/settings overrides it
// live, and this is the single member both write.
DiskHeadroomEnabled: func() bool { return !cfg.Distributed.DiskHeadroomDisabled },
// RAW, not OrDefault: zero means "derive the budget per model from the
// checkpoint size" (config.ModelLoadTimeoutForSize), which is what makes
// a 70 GB video checkpoint work without the operator first hitting a
// DeadlineExceeded and going looking for a knob. A non-zero value here is
// an explicit override and is used verbatim.
ModelLoadTimeout: cfg.Distributed.ModelLoadTimeout,
// Cap how long a cold load may hold the per-model advisory lock. Derived
// from BOTH configured budgets it has to cover, so raising either the
// install timeout (slow links pulling multi-GB images) or the model load
// timeout (very large checkpoints) widens the ceiling too, instead of
// letting a stale bound cut a legitimately slow load short.
ModelLoadCeiling: nodes.ModelLoadCeilingFor(
cfg.Distributed.BackendInstallTimeoutOrDefault(),
cfg.Distributed.ModelLoadTimeoutOrDefault(),
),
// Bounds the REQUEST, not the load: a caller out of budget gets 503 with
// live staging progress while the job keeps running underneath.
ModelLoadWait: cfg.Distributed.ModelLoadWait,
}))
// Wire staging-progress broadcasting so file-staging shows up on every
// replica, not just the one performing the transfer. Without this, a
// /api/operations poll that round-robins onto a peer sees no staging row and
// the progress flickers. The origin publishes; peers mirror via the
// wildcard, on the same carrier.
// A silently disabled safety check is how the original incident stayed
// invisible for sixteen minutes. Say so once, loudly, at startup.
if cfg.Distributed.DiskHeadroomDisabled {
xlog.Info("Disk-headroom admission check is DISABLED: node selection will ignore whether a worker can store the model, and staging may fail with ENOSPC partway through a transfer",
"knob", config.FlagDiskHeadroomCheck, "env", "LOCALAI_DISTRIBUTED_DISK_HEADROOM_CHECK")
}
// S3, and it is ONE call rather than a publisher and a subscriber: see
// StagingTracker.SetBroadcaster for why a tracker that could name two
// carriers is a progress bar that only the originating replica shows.
if _, err := wireStagingBroadcasts(bus, router.StagingTracker()); err != nil {
xlog.Warn("Failed to subscribe to staging progress broadcasts", "error", err)
}
// Every per-node cache a departure leaves stale, onto the one notification
// point, after the router that owns two of them exists.
if err := registerDepartureEvictions(departures, prefixDrop, router, galleryProgress, controlClient, fileStager); err != nil {
return nil, err
}
// Create ReplicaReconciler for auto-scaling model replicas. Adapter +
// RegistrationToken feed the state-reconciliation passes: pending op
// drain uses the adapter, and model health probes use the token to auth
// against workers' gRPC HealthCheck.
reconciler := nodes.NewReplicaReconciler(nodes.ReplicaReconcilerOptions{
Registry: registry,
Scheduler: router,
Unloader: remoteUnloader,
Adapter: remoteUnloader,
RegistrationToken: cfg.Distributed.RegistrationToken,
ClientFactory: backendClients,
DB: authDB,
Interval: 30 * time.Second,
ScaleDownDelay: 5 * time.Minute,
ProbeStaleAfter: 2 * time.Minute,
Pressure: pressure,
PressureThreshold: prefixCfg.PressureScaleThreshold,
})
// Both readers of absence, checked once, here. See requireAbsenceWiring for
// why a missing assignment has no other symptom.
if err := requireAbsenceWiring(router, healthMon); err != nil {
return nil, err
}
// Create ModelRouterAdapter to wire into ModelLoader
modelAdapter := nodes.NewModelRouterAdapter(router)
success = true
ds := &DistributedServices{
Store: store,
Registry: registry,
Router: router,
Health: healthMon,
Reconciler: reconciler,
JobStore: jobStore,
Dispatcher: dispatcher,
AgentStore: agentStore,
AgentBridge: agentBridge,
DistStores: distStores,
FileMgr: fileMgr,
FileStager: fileStager,
ModelAdapter: modelAdapter,
Unloader: remoteUnloader,
ModelCleanup: modelCleanup,
Cluster: clusterRegistry,
Membership: membership,
PeerSessions: peerSessions,
Peers: peers,
Tunnels: tunnels,
WorkerDialer: workerDialer,
BackendClients: backendClients,
AgentControl: agentControl,
JobDispatch: jobDispatch,
Bus: bus,
}
// Checked once, here, on the assembled struct. See requireBroadcastCarrier.
if err := requireBroadcastCarrier(ds); err != nil {
return nil, err
}
return ds, nil
}
// nodeProgressDropper is the gallery service narrowed to the one method a node
// departure needs. An interface so the wiring below cannot reach for anything
// else on the gallery service from inside an eviction hook.
type nodeProgressDropper interface {
DropNodeProgress(nodeID string)
}
// The names each per-node cache is registered under. They are constants because
// they are asserted: a wiring spec that spelled them itself would keep passing
// after a subscriber was renamed and lost.
const (
departurePrefixCache = "prefix-cache"
departureProbeCache = "probe-cache"
departureStagingTracker = "staging-tracker"
departureGalleryNodes = "gallery-node-progress"
departureControlClients = "control-http-clients"
departureStagerClients = "file-stager-http-clients"
)
// registerDepartureEvictions registers every per-node cache that a node's
// departure leaves stale on the deployment's one departure notification point.
//
// One function, and every subscriber in it, because the reason the notifier
// exists is that a reader cannot otherwise enumerate what a demotion
// invalidates: before it, one node type could depart and each stale cache was
// dropped from wherever its owner happened to notice. Adding a per-node cache
// without adding a line here is the failure this shape exists to make visible,
// which is why the subscribers are NAMED and the names are asserted.
//
// It REFUSES rather than skipping when the router or the gallery service is
// missing. A deployment whose departed nodes keep their probe entries, staging
// rows and per-node operation progress does not fail, log or slow down: it
// answers with state for a node that left, indefinitely.
//
// prefix may be nil, and only prefix. That is --distributed-prefix-cache=false,
// where there is no index to drop from, and it stays a true no-op: nothing is
// registered rather than a hook registered onto nothing. It is the CONCRETE
// *prefixcache.Sync for that decision to be safe, since a nil provider inside
// an interface would compare non-nil here and dereference on the first
// departure.
func registerDepartureEvictions(departures *nodes.DepartureNotifier, prefix *prefixcache.Sync, router *nodes.SmartRouter, gallery nodeProgressDropper, control *nodes.ControlClient, stager nodes.FileStager) error {
if departures == nil {
return fmt.Errorf("wiring departure evictions: no departure notifier, so a departed node would keep every per-node cache entry it has for the life of the process")
}
if router == nil {
return fmt.Errorf("wiring departure evictions: no router, so a departed node would keep its probe-freshness entries and its staging operations")
}
if gallery == nil {
return fmt.Errorf("wiring departure evictions: no gallery service, so a departed node would stay in every open operation's per-node breakdown")
}
if control == nil {
return fmt.Errorf("wiring departure evictions: no control client, so a departed node would keep its cached HTTP client and that client's idle streams on a tunnel that is gone")
}
if stager == nil {
return fmt.Errorf("wiring departure evictions: no file stager, so a departed node would keep the cached HTTP client its transfers ran on")
}
// S1. Inside a nil check and not inside the prefix-cache-enabled block, so
// that "the disabled deployment registers nothing" is a fact a spec can
// hold rather than a property of where a line was written.
if prefix != nil {
departures.OnDeparture(departurePrefixCache, func(node nodes.DepartedNode) {
prefix.DropNode(node.ID)
})
}
// S2 and S3 are two registrations and not one, because they are two rules:
// a probe entry is keyed by node ID and a staging op by node NAME, and a
// single hook doing both would hide which of them was lost.
departures.OnDeparture(departureProbeCache, func(node nodes.DepartedNode) {
router.InvalidateNodeProbes(node.ID)
})
departures.OnDeparture(departureStagingTracker, func(node nodes.DepartedNode) {
router.StagingTracker().DropNode(node.Name)
})
// S4.
departures.OnDeparture(departureGalleryNodes, func(node nodes.DepartedNode) {
gallery.DropNodeProgress(node.ID)
})
// S5 and S6, the two per-node http.Client caches. Two registrations again,
// because they are two caches with two owners: the control client's entry
// is built on the first verb issued to a node and the stager's on the first
// file staged to it, so a node can be in either without being in the other,
// and one hook doing both would say only that some client was kept.
//
// Both are keyed by node ID and both are DROPPED rather than emptied. A
// worker that comes back builds a fresh client on its next verb, over
// whatever tunnel it has by then; keeping the old one would keep a
// transport whose idle streams belong to a session that has ended.
departures.OnDeparture(departureControlClients, func(node nodes.DepartedNode) {
control.ForgetNode(node.ID)
})
departures.OnDeparture(departureStagerClients, func(node nodes.DepartedNode) {
stager.ForgetNode(node.ID)
})
return nil
}
// requireBroadcastCarrier refuses to hand back a distributed deployment whose
// broadcast carrier is missing.
//
// The carrier reaches the deployment over two lines: the newBroadcastBus call
// in initDistributed, and the Bus field in the twenty-three field literal
// above. Deleting either one compiles and leaves every suite in this repository
// green, and the two failures are different. Without the construction, nothing
// can ever be published between replicas. Without the assignment the carrier is
// opened and connected but Shutdown cannot see it, so every restart leaves a
// pinned PostgreSQL session and its goroutines behind until the server runs out
// of connections, and the operator sees the failure land on whatever connects
// next rather than on LocalAI.
//
// Neither line can be reddened by a spec today: initDistributed opens NATS
// before it reaches any of this, so it cannot be called from a unit test, and a
// pointer field left out of a struct literal is not a compile error. What this
// converts both omissions into is a deployment that refuses to start and names
// what is missing, which is as far as they can be pinned until initDistributed
// is testable. The guard itself is spec'd.
func requireBroadcastCarrier(ds *DistributedServices) error {
if ds == nil || ds.Bus == nil {
return fmt.Errorf("distributed mode was initialized without a broadcast carrier: nothing could be published between replicas, and the PostgreSQL session it pins could not be closed on shutdown")
}
return nil
}
// Broadcast is the ONE place a wiring site gets the deployment's fan-out
// carrier, and it exists so that "this family travels on the broadcast carrier
// and not on NATS" is decided once instead of at every adopter.
//
// It was five field reads before this: the fine-tune service, the quantization
// service, the agent-task setter (twice, on two startup paths), the per-user
// services manager and the Open Responses store. Every one of them takes a
// messaging.Broadcaster, and the struct used to carry a second field that
// satisfied it, so a site that reached for the wrong one compiled, started,
// published and was delivered onto a carrier almost nothing read. That second
// field is gone with the last family that needed a bus. Collapsing the choice
// to one function is what keeps it a fact a spec can pin.
//
// The return is the interface and not *pgbus.Bus on purpose: handing a nil
// *pgbus.Bus to an adopter would produce a non-nil interface wrapping a nil
// pointer, and every adopter reads a nil carrier as "standalone, do not
// broadcast". A typed nil would instead panic on the first Set. initDistributed
// already refuses to return a deployment with no carrier (see
// requireBroadcastCarrier), so the nil branch here is belt and braces for a
// zero-valued struct in a test.
func (ds *DistributedServices) Broadcast() messaging.Broadcaster {
if ds == nil || ds.Bus == nil {
return nil
}
return ds.Bus
}
// newBroadcastBus opens the deployment's fan-out carrier on the auth database.
//
// The DSN is cfg.Auth.DatabaseURL and it may never be anything else. A second
// source, a flag of its own or a value read from the environment, would let the
// pinned LISTEN connection and the connection pool address two different
// databases; that carrier publishes successfully, delivers nothing, on every
// replica, and reports no error anywhere. isPostgresURL above has already
// refused a value this carrier could not use.
//
// It is a function rather than four lines inside initDistributed so that the
// equality can be pinned by a spec. initDistributed opens NATS before it
// reaches this point and so cannot be called from a unit test, which would
// leave the assignment as one line in a long function that compiles perfectly
// well when it names the wrong field.
func newBroadcastBus(ctx context.Context, cfg *config.ApplicationConfig, authDB *gorm.DB) (*pgbus.Bus, error) {
if err := pgbus.Migrate(ctx, authDB); err != nil {
return nil, fmt.Errorf("migrating the broadcast carrier: %w", err)
}
bus, err := pgbus.New(ctx, pgbus.Config{DSN: cfg.Auth.DatabaseURL, DB: authDB})
if err != nil {
return nil, fmt.Errorf("opening the broadcast carrier: %w", err)
}
return bus, nil
}
// unadvertisedNagInterval is how often a replica that could not advertise
// itself says so again.
//
// Five minutes is chosen against the log it lands in, not against the urgency:
// the condition never clears on its own, so this line is either read once and
// acted on or it is noise for the life of the process, and a noisy line gets
// filtered rather than fixed. It is still frequent enough that the state is
// visible in any window of logs an operator pulls while investigating the
// symptom it causes.
const unadvertisedNagInterval = 5 * time.Minute
// nagUnadvertisedReplica repeats, for as long as the process runs, that this
// replica is invisible to its peers, and names what that is currently costing.
//
// It exists because the deferral it accompanies changed cost between phases and
// nothing about the deployment says so. Before workers held tunnels, a replica
// with no advertised address was merely unreachable BY peers and could still
// dial every worker directly, so a startup warning was proportionate. Now a
// worker's tunnel lands on one replica and every other replica reaches it by
// relaying to the owner, and the owner is resolved by joining the connection
// row against a LIVE INSTANCES ROW - which this replica does not have. So every
// worker that lands here is answered as unroutable everywhere else: on N
// replicas behind round robin, (N-1)/N of that worker's traffic fails, while
// this replica serves it perfectly and reports nothing.
//
// held is passed as a function rather than the registry so this can be driven
// without one, and alarm is passed rather than logged inline so a spec can
// observe the alarms instead of scraping a log.
func nagUnadvertisedReplica(ctx context.Context, held func() []string, every time.Duration, alarm func([]string)) {
ticker := time.NewTicker(every)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
alarm(held())
}
}
}
// logUnroutableWorkers says what the state costs RIGHT NOW.
//
// The two cases are kept apart because they call for different urgency and an
// operator can tell them apart at a glance. With no worker held this is a
// misconfiguration that has not been paid for yet; with workers held, every one
// of them is named, because "which worker is broken" is the question the
// symptom sends an operator to ask and the answer is that none of them is.
func logUnroutableWorkers(held []string) {
if len(held) == 0 {
xlog.Warn("This replica is still not registered in the cluster: no advertised address. No worker holds a tunnel here yet; the first that does will be unroutable from every other replica",
"knob", "LOCALAI_DISTRIBUTED_ADVERTISE_ADDR")
return
}
xlog.Error("This replica is not registered in the cluster and holds worker tunnels: those workers are unroutable from every OTHER replica, and requests for their models fail there with no route. The workers are healthy; this replica is invisible",
"workers", held, "worker_count", len(held), "knob", "LOCALAI_DISTRIBUTED_ADVERTISE_ADDR")
}
// advertisedPeerAddr is the host:port peers dial to reach this replica.
//
// The operator's value wins outright. Otherwise it is derived from the port
// this process serves on and the local address that routes to PostgreSQL, which
// is only a peer-reachable answer when the database is on another host;
// DiscoverAdvertisedAddr refuses rather than guessing when it is not.
func advertisedPeerAddr(cfg *config.ApplicationConfig) (string, error) {
if configured := cfg.Distributed.AdvertiseAddr; configured != "" {
// A configured address skips discovery, so it also skips every check
// discovery makes. Unusable is refused; merely questionable (a
// loopback address, correct on one host and wrong on three) is said
// once and honoured, because refusing it would refuse single-host
// deployments that use it correctly.
reason, err := cluster.CheckAdvertisedAddr(configured)
if err != nil {
return "", err
}
if reason != "" {
xlog.Warn("Configured peer address is not one another host can dial",
"address", configured, "reason", reason, "knob", "LOCALAI_DISTRIBUTED_ADVERTISE_ADDR")
}
return configured, nil
}
if cfg.APIAddress == "" {
return "", fmt.Errorf("no API address to derive a peer port from")
}
_, port, err := net.SplitHostPort(cfg.APIAddress)
if err != nil {
return "", fmt.Errorf("reading the peer port out of API address %q: %w", cfg.APIAddress, err)
}
portNumber, err := strconv.Atoi(port)
if err != nil {
return "", fmt.Errorf("API address %q has a non-numeric port: %w", cfg.APIAddress, err)
}
return cluster.DiscoverAdvertisedAddr(cfg.Auth.DatabaseURL, portNumber)
}
func isPostgresURL(url string) bool {
return strings.HasPrefix(url, "postgres://") || strings.HasPrefix(url, "postgresql://")
}