Skip to content

feat(distributed): SyncedMap component + migrate finetune/quant/agent-tasks to cross-replica state#10542

Merged
mudler merged 6 commits into
masterfrom
feat/syncstate-component
Jun 27, 2026
Merged

feat(distributed): SyncedMap component + migrate finetune/quant/agent-tasks to cross-replica state#10542
mudler merged 6 commits into
masterfrom
feat/syncstate-component

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

Why

Follow-up to #10540. In distributed mode LocalAI runs multiple frontend replicas behind a round-robin LB. Several features keep process-local in-memory state surfaced to the API; each one that syncs correctly today (gallery statuses, staging progress, op cache) hand-wires the same three legs — in-memory map + NATS broadcast/apply + read-through store — and the ones that don't wire it silently serve stale/missing data on whichever replica didn't originate the change.

This introduces one well-tested component so cross-replica consistency is a configuration choice, then migrates the worst offenders onto it.

The component — core/services/syncstate.SyncedMap[K,V]

A thread-safe in-memory map that keeps itself consistent across replicas:

  • Local write (Set/Delete): mutate map → write-through optional Store → broadcast a {op,key,value} delta on state.<name>.delta.
  • Apply (peer delta): memory-only update + OnApply hook; never re-publishes, never re-writes the Store (structural echo-loop guard, same split as galleryop.mergeStatus).
  • Convergence: on Start and on NATS reconnect, re-hydrate from the source (Store, else Loader); deltas apply in between; optional periodic Reconcile. NATS core pub/sub is at-most-once, so hydrate-from-source is how a late/reconnecting replica catches up.
  • Standalone (nil NATS client): strict in-memory no-op — no broadcast/subscribe.

Reconnect re-hydrate is wired via a new *messaging.Client.OnReconnect callback, consumed through an optional type-assertion so the MessagingClient interface stays minimal. Adds messaging.SubjectSyncStateDelta and a reusable testutil.FakeBus (synchronous in-process MessagingClient with wildcard matching) for adopter tests.

Migrations in this PR

Service Before After
FineTune (core/services/finetune) wrote Postgres but ListJobs/GetJob never read it back; natsClient wired but unused SyncedMap[string,*schema.FineTuneJob] over a FineTuneStore adapter (+ ListAll, idempotent Upsert); state.json becomes the standalone Loader
Quantization (core/services/quantization) process-local map, only a local state.json SyncedMap[string,*schema.QuantizationJob] + new durable distributed.QuantStore (GORM, AutoMigrated); state.json kept as standalone Loader
Agent tasks (core/services/agentpool) ListTasks read in-memory only (jobs already synced) SyncedMap[string,schema.Task] over the existing JobPersister; NATS injected via SetTaskSyncNATS before hydrate at the main/restart/per-user sites

Jobs in agentpool were already consistent (dispatcher NATS + DB read-through) and are left untouched. REST response shapes are unchanged in all three.

Deferred (separate PRs)

Tests

  • syncstate: propagation, delete-prune, hydrate-on-start, Loader fallback, echo guard, Store write-through, OnApply, standalone no-op, reconnect re-hydrate.
  • Each migrated service: two instances sharing a FakeBus converge on create/update/delete; record↔schema round-trips; new GORM stores tested against real Postgres via testutil.SetupTestDB.

All written test-first. go vet, gofmt, and golangci-lint --new-from-merge-base are clean across core/...; agentpool suite passes under -race.

Assisted-by: Claude:claude-opus-4-8 [Claude Code]

mudler added 4 commits June 26, 2026 23:49
Introduce core/services/syncstate.SyncedMap[K,V]: a thread-safe in-memory map
that keeps itself consistent across frontend replicas via NATS, with an optional
pluggable durable Store and hydrate-from-source convergence.

Several features keep process-local state surfaced to the API (finetune/quant
jobs, agent tasks, model configs) and each hand-wired the same in-memory + NATS
broadcast + read-through-store legs - or forgot to, reintroducing cross-replica
staleness. SyncedMap makes that consistency a configuration choice:

- local writes mutate the map, write through the Store, then broadcast a delta;
- the apply path is memory-only and never re-publishes or re-writes the Store
  (structural echo-loop guard, mirroring galleryop.mergeStatus);
- on Start and on NATS reconnect the map re-hydrates from the source (Store, else
  Loader); an optional periodic Reconcile repairs silent drift;
- standalone mode (nil NATS client) is a strict in-memory no-op.

Reconnect re-hydrate is wired via a new *messaging.Client.OnReconnect callback,
consumed through an optional type-assertion so MessagingClient stays minimal.
Adds messaging.SubjectSyncStateDelta and a reusable testutil.FakeBus (synchronous
in-process MessagingClient with wildcard matching) for adopter tests.

Component only; service migrations follow in subsequent commits.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
…tency

FineTuneService kept jobs in a process-local map and, although it wrote them to
Postgres, ListJobs/GetJob never read the store back and the wired natsClient was
never used - so in distributed mode a job created on one replica was invisible to
the others. Replace the map and the dead client with a syncstate.SyncedMap keyed
by job ID, value *schema.FineTuneJob (the exact REST shape, so responses are
unchanged).

- Add a Store adapter (core/services/finetune/syncstore.go) over FineTuneStore,
  plus FineTuneStore.ListAll (global hydrate; per-user List kept) and an
  idempotent Upsert (create-or-update; Create alone fails on dup key).
- Writes go through SyncedMap.Set/Delete (write-through + broadcast); reads use
  List/Get. The on-disk state.json path becomes the standalone Loader, keeping
  single-node restart recovery (stale->stopped / exporting->failed fixups).
- Fold SetNATSClient/SetFineTuneStore into NewFineTuneService; app.go passes the
  distributed NATS client + store when distributed, nil otherwise.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
…a consistency

AgentJobService.ListTasks read the process-local tasks map only, while ListJobs
already read through the DB persister + dispatcher NATS - so in distributed mode
a task created on one replica was invisible to the others. Back tasks with a
syncstate.SyncedMap keyed by task ID (value schema.Task, the exact REST shape);
jobs are left untouched.

- Store adapter (task_syncstore.go) over the existing JobPersister
  (LoadTasks/SaveTask/DeleteTask); reads svc.persister/userID live so a persister
  swap needs no rebuild. No new persister methods required.
- Task reads -> SyncedMap.List/Get; create/update -> Set (write-through +
  broadcast); delete -> Delete. The file persister now owns its own task set so
  the write-through path does not re-enter the SyncedMap lock (deadlock guard).
- The distributed NATS client is not available at construction (start() precedes
  initDistributed), so it is injected via SetTaskSyncNATS, which rebuilds the
  still-empty map before Start/hydrate. Wired at the main, restart, and per-user
  (UserServicesManager) distributed sites.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
QuantizationService kept jobs in a process-local map persisted only to a local
state.json, so in distributed mode jobs were neither visible across replicas nor
durable cluster-wide. Back jobs with a syncstate.SyncedMap keyed by job ID
(value *schema.QuantizationJob, the exact REST shape).

- New distributed.QuantStore (GORM, table quantization_jobs) mirroring
  FineTuneStore: Create/Get/ListAll/Upsert(idempotent)/Delete, registered for
  AutoMigrate via distributed.InitStores (Stores.Quant).
- New adapter (quantization/syncstore.go) over QuantStore implementing
  syncstate.Store, with record<->schema conversion.
- Reads go through List/Get, writes through Set/Delete (write-through +
  broadcast); state.json is kept as the standalone Loader for single-node restart
  recovery (stale-job fixups preserved).
- app.go passes the distributed NATS client + QuantStore when distributed, nil
  otherwise; Start/Close lifecycle mirrors finetune.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
Comment thread core/services/syncstate/syncstate.go Fixed
mudler added 2 commits June 27, 2026 07:05
gosec flagged the WithCancel in Start as "cancellation function not called"
because the returned cancel is stored on the struct rather than called/deferred
in scope. It is invoked in Close (covered by tests), and lifeCtx must outlive
Start to drive the reconnect/reconcile goroutines. Suppress the verified false
positive with a justified #nosec G118.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
…stgres

Adds the real-infrastructure counterpart to the fake-bus unit tests, in the
existing distributed e2e suite (testcontainers NATS + PostgreSQL). Two SyncedMap
instances stand in for two frontend replicas - each with its OWN NATS connection
to a shared server and a SHARED Postgres store (the distributed-mode invariant) -
and assert, over the wire:

- a create on replica A is observed by replica B;
- an update and a delete propagate A -> B (delete prunes, which a reload cannot);
- a late-joining replica recovers a job it never received a delta for, via store
  hydrate on Start (the at-most-once gap a fake bus cannot exercise);
- a local Set is written through to the shared Postgres store.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]
@mudler
mudler merged commit d7d7721 into master Jun 27, 2026
61 checks passed
@mudler
mudler deleted the feat/syncstate-component branch June 27, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants