Skip to content

feat(distributed): switch the cluster transport between NATS and the database at runtime - #12549

Draft
mudler-agent wants to merge 168 commits into
masterfrom
feat/live-carrier-b1-holders
Draft

mudler-agent wants to merge 168 commits into
masterfrom
feat/live-carrier-b1-holders

Conversation

@mudler-agent

@mudler-agent mudler-agent commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

What this changes

Distributed mode can now switch its transport between NATS and a database-backed carrier at runtime, with no restart and no boot-time mode flag. Exactly one carrier is active cluster-wide. The active carrier and its epoch live in a cluster_carrier row.

The database-backed carrier has three parts:

  • pgbus: PostgreSQL LISTEN/NOTIFY, with a spill table for payloads of 8000 bytes or more.
  • Claim queue: SKIP LOCKED, a 2 s poll plus a NOTIFY wake hint.
  • Tunnel: a websocket-carried yamux tunnel with a separate bulk lane for file transfers. Workers dial the frontend, so they need no inbound port.

NATS keeps working as before. Saving a NATS URL only stores it and checks reachability. The switch is an explicit step with a dry-run preflight.

Switching

  • POST /api/cluster/carrier with dry_run, force and abort; GET /api/cluster/carrier for status; /api/cluster/settings for the NATS URL and timings.
  • State machine: stable, prepare, commit, with compare-and-set transitions. Epochs are compared by equality only.
  • Broadcast listens on both carriers during a switch and publishes on one. The old carrier drains for up to max_drain (default 15 min).
  • A switch is blocked by workers that cannot follow. force overrides; those workers report a follow_error and are not routed to until they can follow.
  • Two worker profiles: tunnel-only (cannot follow a switch to NATS without a restart) and dual-capable (started with --addr).
  • Workers follow the switch themselves: the heartbeat reply carries the carrier and epoch, the worker re-registers for credentials after a random 0-10 s delay, and keeps the old carrier until it is drained.
  • The router does not send requests to a worker that cannot be reached on the active carrier.
  • Frontends older than this change cannot be detected. The preflight prints the live replica list, and the docs say to upgrade all frontends first.

Index of the change (commit series)

Slice Content
B1 Carrier holders and the cluster_carrier store
B2 Cluster registry (instances, membership, node connection claims)
B3 pgbus
B4-B6 Tunnel wire, connect endpoint, per-node credentials, bulk lane, worker client
B7-B10 Worker HTTP control, peer relay, tunnel control carrier, dial seam, tunnel set
B11-B12 Agent workers and claim queue
B13-B14 Switch protocol, swap and drain
B15 Workers follow the switch
B16 Docs, swagger, MCP read-only tool, CLI, UI panel, CI step, real-process switch e2e

Credentials are per node; open registration is allowed but logs a warning. The peer link supports --peer-address, --peer-tls and --peer-tls-ca.

Testing

  • Unit and integration specs per slice, including switch and tunnel integration specs run under CPU stress.
  • make test-e2e-distributed-switch: real processes (frontend, workers, Postgres, NATS). It switches NATS to database and back with zero chat failures through a forced flip, and covers interrupted frontend and worker changes.
  • make test-e2e-distributed (existing NATS suite): 237 passed, 0 failed.
  • golangci-lint clean with and without the auth tag; go vet, and Darwin and Windows vet of the touched packages.
  • UI: unit tests and Playwright specs for the Cluster transport panel.

Known follow-ups

  • MaxFailures has no CLI flag.
  • Some planned metrics are not implemented.
  • The work task has no worker on either carrier.
  • The pgbus "past the cap" spec flaked once under -race load; it did not reproduce in isolation.
  • DCO: one old merge commit lacks a sign-off.

This supersedes #11812, which was used as a reference only.

🤖 Generated with Claude Code

mudler added 30 commits October 7, 2026 16:18
Add table cluster_carrier and a store for it. The row names the carrier
that the whole cluster uses, an epoch that grows with every change, and
the fields a later change of carrier needs: state, target and the
carrier that is draining.

Seed inserts the row only when it is absent, so replicas that start
together agree on one writer. Transition is a compare-and-set on the
epoch, so two writers cannot interleave. A check constraint keeps the
table at one row.

Nothing reads the row yet.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The backend and model managers and the replica reconciler took the NATS
sender by its concrete type. Add NodeControl, the interface of every
control call they make, and take it instead. The sender implements it, so
nothing changes at run time.

NodeControl names the three unload interfaces that the model loader reads
by type assertion. A sender that lacked one would still compile and would
silently lose that behaviour.

Export the install-with-force fallback so a sender in another package can
implement it. Add a ClientFactory option to the reconciler, so its default
probe dials the way the router does. Add NewTokenClientFactory for the
direct factory.

Narrow the agent event bridge from MessagingClient to Broadcaster. It only
publishes and subscribes. Pin that the NATS client returns a client for a
server that is not up yet, which startup relies on.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A carrier.Set is the implementation of every seam for one carrier. The
holders implement the same interfaces as the seams (fan-out, work queue,
control verbs, file staging, backend clients, worker dialer, agent RPC)
and forward each call to the set that an atomic pointer names. They take
no lock and add no allocation.

The fan-out holder keeps a registry of its subscriptions. Listen attaches
every subscription to a second set, so each handler listens on both
carriers while a swap runs. Release drops the first set again. Publish
always uses the set the pointer names. The holder also forwards the
reconnect hook that syncstate and the failover sync register.

NewNATSSet builds the set for the NATS carrier. A spec fails when any
other non-test file imports the NATS libraries.

Nothing stores a second set yet, so no behaviour changes.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…lders

Read the cluster carrier row at startup, or insert it when it is absent,
and build the set it names. An existing deployment has a NATS URL, so it
seeds nats and runs as before. A row that names a carrier this build does
not run stops the start with a message, and does not fall back.

DistributedServices no longer holds the NATS client. The router, the
health monitor, the reconciler, the job dispatcher, the agent bridge, the
prefix cache and the other users of the bus take holders over the active
set. The file manager is built before the set because the NATS file
stager needs it.

The NATS URL keeps its meaning. A URL the client cannot use still stops
the start. A server that is not up does not: the client retries, as it did
before.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
make test-e2e-distributed was not run by any workflow. Add one, with
continue-on-error so a failure is visible on the pull request and does
not block a merge until the job has a track record. The suite starts its
own PostgreSQL and NATS servers, so the job pre-pulls both images.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Add a section on the holders, the carrier set, the subscription registry
and the cluster_carrier row. Record the import boundary of the NATS
libraries and the startup rule for an unreachable NATS server. Remove the
open item for a reconciler client factory, which now exists.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The fan-out holder is a Broadcaster, so it has to pass the suite that
every carrier passes. Run it on a holder as built, and on a holder whose
carrier was swapped.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…nnections

Each frontend replica records itself in the instances table and
refreshes the row from a membership loop. The database clock stamps
every row, so no replica decides on the clock of another. The row also
carries the version and the epoch of the carrier that the replica has
built, with a reason when it could not.

The node_connections table records which live replica holds the
connection of a worker. Every claim takes a fresh epoch from a
sequence, so a replica that lost a worker cannot clear the claim of the
replica that won it. A released or orphaned row is kept as a departure,
and Presence tells a worker that is reconnecting from one that is gone
only after a grace period.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A worker that follows a change of carrier is attached to two carriers
for a while. Add the columns that hold what the worker reports and the
epoch it saw when it reported.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…istry

Run the membership loop in distributed mode, on every carrier, and
remove the row when the replica stops.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…yloads

Move the subject matcher out of the test helper into messaging, so a
carrier and the in-memory doubles use one rule. Add MaxBroadcastBytes,
checked by the NATS client and by the fake bus, so a message that one
carrier accepts is not refused by another.

The conformance suite now takes two ends of a carrier and runs every
broadcast root, the sizes around the notify cap and the bound. It also
asserts what a carrier does with the control roots, and reads the drop
counter when the carrier has one.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… string

The postgres image declares its data directory as a volume. A removed
container leaves that anonymous volume behind, so every spec that
started a database left tens of megabytes on the disk of the host. Put
the data directory on a tmpfs.

Also return the connection string, for code that needs a connection of
its own, such as a LISTEN connection that cannot come from a pool.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…t and reply

A carrier that has fan-out and no request and reply serves the broadcast
roots only. ValidateBroadcastSubject applies the shared rules and then
refuses the nodes and mcp roots with the same class of error as a root
that is not served.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The second implementation of messaging.Broadcaster. A message goes out
with pg_notify. When its encoded notification is 8000 bytes or more, the
payload is written to the bus_messages table and the notification
carries the row id. Every replica deletes the rows that are older than
the retention, by the clock of the database.

Delivery is at-most-once, as with NATS. The listener copies each
notification into a bounded queue and never blocks, so the server is not
held up by a slow consumer. A lost connection is dialled again and every
channel is listened again, then the reconnect callbacks run.

The subject rules are the shared ones, and the carrier refuses the
control roots. It checks the shared payload bound. Counters for the
messages published by path, the messages lost by stage, the spilled rows
deleted and the reconnections go to OpenTelemetry, because a lost
broadcast is otherwise visible only in a log.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… order

A broadcast of 64 KiB is written to a row, and every replica reads that
row with a SELECT. One reader at a time limited a replica to about a
thousand such broadcasts per second, and a burst above that was dropped.
Read up to eight rows at the same time and hand the notifications to the
handlers in the order they arrived, so the order of one subject does not
change.

In a burst of 4000 broadcasts of 64 KiB from 16 publishers, the replica
with one reader delivered 1860, and the replica with eight readers
delivered all of them.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
NewPgbusFanout opens the LISTEN connection and returns the broadcaster,
the reconnect hook, a ready check and the close that a set needs. It
opens the connection when it is called, so a deployment on NATS holds no
LISTEN session until a change of carrier asks for one.

Run the broadcaster conformance suite on holders that were swapped from
one carrier to this one on a real database, and check that a message
published after the swap reaches a subscriber made before it. The
import-boundary spec now also keeps the PostgreSQL driver in the pgbus
package, and the pgbus package behind the carrier builder.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A worker holds one outbound websocket to a frontend and a yamux session
runs on it. The frontend opens a stream for every request. The first
frame of a stream names the local service of the worker, and the worker
always answers with a reply frame before the carried protocol starts.
The four refusals stay apart, so that a caller acts on the three that
are evidence about a backend and does nothing about the one that says
the worker learned nothing.

The upgrader and the dialer come from one constructor each, with 64 KiB
buffers. With the 4 KiB default of gorilla, a stream that sends from the
worker to the frontend ran at 560 to 600 MB/s on a loopback socket and
the other direction at 3,200 to 3,800 MB/s. With 64 KiB the first
direction runs at 2,900 to 3,000 MB/s. BenchmarkTransfer repeats the
measurement.

A session has a lane. The inference lane keeps the yamux windows. The
bulk lane uses 4 MiB and 32 MiB, which a transfer needs on a link with
a long round trip, and which would let more data queue ahead of a small
call on a session that is shared.

Only the files of the tunnel package import yamux. The import boundary
spec lists it.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Splice used to close both streams as soon as one direction ended. A
protocol that closes its sending side and then waits for the reply lost
the reply that was still in flight. Now a direction that ends clean
passes the end on with CloseWrite and the other direction goes on until
it ends too. When the destination cannot half-close, or a direction
fails or ends because a stream was closed, both streams are closed at
once, as before. This is what wakes a copy that is parked in a read or
in a write.

A copy into a TCP connection wraps the error in a net.OpError. The
classifier of the endings of yamux now removes that layer, so a stream
that this side reset is not reported as a failure of the transport.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A proxy in the spec works as one link of 1 Gbit/s with 10 ms of delay in
each direction and one queue that every connection shares. A transfer of
256 MiB runs from the worker to the frontend while a small call goes
every 5 ms in the other direction, and its reply has to wait in the
queue of the loaded direction.

The spec runs three set-ups: a probe on its own TCP connection, a probe
on the session of the transfer, and a probe on the inference session
with the transfer on the bulk session. It requires the bulk lane to stay
within 10 ms of the TCP baseline at the 99th percentile, and the shared
session to be worse than the bulk lane. A build with the race detector
moves 128 MiB.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The registry keeps the sessions that workers opened to this replica and
keeps the node_connections table in agreement with them. Attach writes
the claim first and stores the session second, so a failed claim leaves
the replica as it was, and the claim and its record are one step for
each node. Detach matches the token of the attachment by equality, so a
session that was replaced cannot remove the one that replaced it. Open
returns ErrNotOwner only when the node is not held here. A session that
ended under a held entry, and a caller whose budget ran out, are
reported as themselves.

A node has two lanes. The inference lane owns the claim. The bulk lane
is stored under the claim of the inference lane, is refused with
ErrNotOwner on a replica that does not hold it, and is closed with it.
Open on the bulk lane uses the inference lane when the node has no bulk
session, so a transfer on an older worker or during a reconnect works.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
When a peer sweeps a replica that stalled, it deletes the instance row of
that replica and records a departure for every connection it owned. The
replica registers again and rebuilds the instance row, but the sockets
are still open on it. The other replicas then answer "not connected" for
workers that are connected.

The membership loop now takes a Reclaimer and calls it after a
successful registration that follows a sweep. The interface keeps the
package free of the code that holds the sockets.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A node gets its own secret for the tunnel. Registration mints a new random
token with 128 bits or more, returns it once, and stores only its SHA-256
in a new column. The registration token of the deployment never opens a
tunnel, so a leak of it does not let its holder be any worker. Every
registration mints a new token, and the worker reads it each time it
dials. A node waits for approval with a token that the connect route
refuses with 403 until the approval. A node type that holds no credential
has the column cleared.

The answer to a registration now names the active carrier and its epoch
when the endpoint has a carrier reader, and hands over the credential for
that carrier only: the tunnel token when the tunnel is active, the NATS
user JWT otherwise. Without a reader, or when the row cannot be read, the
answer is the one that a deployment with only NATS always got. NATS still
requires the address of a backend worker. With the tunnel active the
address is optional, and a worker that says that its address is not
routable has it dropped and cleared.

When the tunnel is active and registration is open, the frontend logs one
warning. It does not refuse.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
GET /api/cluster/connect takes the dial of a worker. It reads the bearer
token before it checks anything else, so an anonymous dial gets 401. A
frontend with no registry answers 503 to a caller that has a token. An
unknown node gets 401 and not 404, a lookup that fails gets 500, and a
node that waits for approval gets 403. The token is compared in constant
time with the hash on the row of the node. The registration token of the
deployment is never accepted.

The upgrade uses the shared upgrader with 64 KiB buffers. The query
parameter lane picks the session. A bulk dial to a replica that does not
hold the inference session of the node gets 409 before the upgrade, so
the worker can dial again at once and land on the owner.

The route is registered on every replica. The global auth middleware lets
exactly this path through to its own check. The prefix /api/cluster/ is
not delegated. The route coverage test walks the route and a new spec
fails if the route is removed.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The proxy that works as a link with a rate and a delay is now a package,
so that the specs of the worker can use it on the real endpoint. It also
passes the end of a connection on behind the data that is still queued.
Before, a connection that ended was closed at once and the data in the
queue was lost.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A worker that is attached to the tunnel is ready when it holds a tunnel
session. The probe is the counterpart of the one for NATS, and its error
is a different one, so that /readyz says which link is down.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…amed

The credential manager is no longer only for NATS. The answer to a
registration names the carrier and its epoch. When the carrier is the
tunnel, the manager waits for the tunnel token and not for the NATS JWT,
and it does not wait for the approval of the node: the frontend mints the
token at registration and refuses the dial of a node that is still
pending, so the tunnel client waits for the approval itself. The token
of the latest registration is the one that the client presents, because
every registration mints a new one.

For NATS, or for a frontend that names no carrier, the manager works as
before. RegisterWithRetry now uses RegisterFullWithRetry, which returns the
whole answer.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The worker dials the frontend and serves the streams that the frontend
opens. It holds two sessions. The inference lane carries model calls and
health. The bulk lane carries file transfers and lives only as long as the
inference lane. A dial of the bulk lane that lands on a replica which does
not hold the inference lane gets 409, and the worker tries again soon. A
frontend that refuses the bulk lane does not cost the worker its
inference lane.

A stream is routed by the tag in its first frame. The host that the
frontend names is dropped, and the port must be in the range of the
allocator of the worker, so the tunnel cannot reach the LAN of the worker.
A refusal says which of four things is true, and a late frame, a timeout
and a cancelled context are never evidence about a backend. The wait
between two attempts grows from half a second to thirty seconds with
jitter, and it goes back to the floor only after a session that lasted.
The token is read at every dial.

The specs run the client against the real connect endpoint and a real
PostgreSQL. Behind a link of 1 Gbit/s with 10 ms of delay in each
direction, a transfer of 256 MiB delays a small call on the same session
to a p99 of 88 ms, and on the bulk lane to 34 ms, against 31 ms for a
call on its own TCP connection.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A spec reads the files of the connect endpoint and of the worker client
and fails if one of them builds a gorilla upgrader or dialer by hand. Such
a websocket gets the 4 KiB buffers, and a transfer from the worker runs
five times slower than in the other direction.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The worker registers first and learns the carrier from the answer. For the
tunnel it starts the tunnel client and needs no NATS URL. For NATS, or for
a frontend that names no carrier, it connects to NATS as before, and it
stops with a message that says so if it has no URL. The flag is no longer
required, because for a cluster on the tunnel nothing needs it.

The heartbeat and the readiness probe look at the link of the carrier that
the worker is attached to. A worker whose link is down does not send its
heartbeat, so the registry does not show a node that looks healthy and
cannot be reached.

A worker that is reached only through its tunnel and was started with no
address binds its backends and its file-transfer server to loopback, and
tells the frontend that its address is not routable. A worker with an
address, or any worker on NATS, binds all interfaces as before. The
lifecycle verbs have no route on the tunnel yet, and the worker says so
when it starts.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
mudler added 12 commits October 8, 2026 16:10
…nd purge what moved

MigratePending marks the rows as migrated before they are published. A row that the new carrier refused stayed migrated and was lost. It goes back to pending now, so the next sweep takes it, and only the refused rows go back.

Rows that moved stay for a day as evidence and are then deleted with their payloads, in the handoff and on the poll of a consumer.

The seams document now says what the claim queue stores: the payload of a row is the whole request of the work, including the configuration of the MCP servers of a job, in plain text until the work completes or the row is purged. It also states the release rules, the failure cap and the cancel path.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…rol token

A worker may ask the frontend to publish a progress line on a subject. The allow list of the node type was the only limit, so a worker that held one job could publish progress, results or agent events of any other job or user, and a subject with an empty token or white space reached the filters.

- The driver now allows a run only the subjects of the run: the progress and the result of its job, or the events of its agent and user. A run that names no scope keeps the allow list of its node type.
- A subject with an empty token, white space or a control character is refused before the filters see it.
- agentworker.Start refuses an empty control token. The control server listens on loopback, and an empty token left it open to every process of the host.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…indow gap, re-attach a drain

Review of the swap found six robustness defects.

- retire waited for the work of the old set without a bound, so one stuck handler held the hand-over of the queue and the close of the set. The wait is now bounded (StopGrace, 30 s), logged, and left behind. Close stops the sets in parallel under the same bound.
- Close could block for two minutes in the hand-over, which ran on a context that nothing cancelled. The hand-over now runs on a context that Close cancels after CloseGrace (10 s).
- swapTo stored the new set before it opened the window, and a call to a worker attached only to the old carrier failed in the gap. The window opens first.
- The holder kept a reconnect hook entry for every set it had ever hooked. It drops the entry when the set is released, on abort and on close too.
- A replica that started or restarted during a drain never attached the draining carrier, so workers attached only to it were not reached until the drain ended. It now builds that carrier again, opens the window and starts its work, and tries again at the next poll if it cannot.
- A failure to start the work of the active carrier was logged and not reported. The replica now reports not ready with the reason, and ready again once the work starts.

The fakes of the swap specs answer with an error once their set is closed, so that the spec "never reaches a closed set" can fail. It now also calls a worker through the window during the drain.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The ready report of a replica never consumed the check, and only its own spec called it. The LISTEN connection is checked when the carrier is built, and a lost connection is redialled by the bus.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…re storing anything

The endpoint of the cluster settings checked that a wait parsed, and the store refused one that was not positive only after it had stored the settings before it. A request with a valid address and a wait of 0s stored the address, answered 500, and left half of the request in place. A value such as 1ns passed both checks, and it aborted every later change before a replica could see it.

The endpoint now runs the same check as the store on every part of the request before it writes. A wait must be at least five seconds. The timings that a replica reads ignore a stored value below that.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The end of a drain replaced the note of the change with the word "drained", so an admin who looked at the status after a forced change no longer saw that it was forced or which replica it left behind. The drain ending is recorded by the log and the counter. The note stays.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…tabase

The switch of the carrier counted a worker as alive when its last heartbeat was younger than the stale limit by the clock of the replica that read it. The heartbeat is stamped by whichever replica handled it, so a skew between two replicas made a live worker look stale, or a dead one look alive. The age is now read on the clock of the database, which every replica shares, and falls back to the local clock only when the database does not answer.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
gosec flagged the plain conversion of the replica index in four requests (G115). The index is a small count, so the conversion now clamps a value that does not fit and does not wrap it, which could have named another replica.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…k was asked

A dry run or a request asks every replica what it can build, waits up to five seconds, and then reads the reports. The preflight accepted a report up to 30 seconds old, so a replica that never heard the question passed on the strength of an old answer.

The endpoint now measures how long the wait took, and the preflight counts a report only if it is younger than that. A replica that did not answer shows as a blocker that can be forced. A caller that asked nothing keeps the old age.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…is closed

The swapper closed the released set at once after the hand-over, and only then waited for the producers that were slow to flip. A call that had taken the draining set from the window, or the old set from the pointer, a moment before they moved could meet a closed set, and a slow producer's work on the old carrier met a closed queue. Under CPU load a spec that calls through the window during the drain failed in 1 run of 10.

The set now stays open for the settle time, the second hand-over runs, and the set is closed after it.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The assistant can now read which transport a distributed cluster uses, the state and epoch of a change, the live frontends and the workers that cannot follow. It reads the same report as GET /api/cluster/carrier, in process or over HTTP.

No tool changes the carrier. A change moves every frontend and worker and needs a dry run and a check of the frontend list by an admin, so the skill tells the assistant to point the user to the dashboard or the CLI.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…gger

Add swagger blocks to the four admin routes under /api/cluster/ and regenerate the spec. Export the request and settings types so the spec can name them, and mark the duration fields as integers (nanoseconds) so the generator can read them. The JSON that the routes send does not change.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Comment thread core/services/carrier/swap.go Fixed
mudler added 15 commits October 8, 2026 16:55
The new command group calls the admin API of a running frontend:

  local-ai cluster carrier status
  local-ai cluster carrier switch --to nats|tunnel [--dry-run] [--force] [--yes] [--wait]
  local-ai cluster carrier abort
  local-ai cluster settings get
  local-ai cluster settings set [--nats-url ...] [--nats-worker-url ...] [--max-drain ...]

A change runs the dry run first and prints the preflight with the list of live replicas. It then asks whether the list is complete and whether all frontends run this release, because a frontend of an older release cannot be detected. A command with no terminal needs --yes. --force is needed for blockers, and --wait follows the change until it commits or is aborted.

Saving a NATS address does not change the carrier, and the command says so.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The panel shows the active carrier, the epoch, the state of a change
(stable, prepare, commit, draining), the readiness of each frontend
replica, and each worker with its profile, the carriers it is attached
to and its follow error. It shows only when the cluster routes answer,
so it does not appear on a frontend that is not distributed.

The switch dialog runs a dry run and lists the blockers and the live
replica list. The admin must confirm that the list is complete and that
all frontends are upgraded. A forced change needs a second confirmation.
An admin can abort a change during prepare, and a drain shows a
countdown. The NATS settings form saves the address and reports whether
this frontend reaches it. Saving does not switch the cluster.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Rewrite the distributed mode page for carriers. A cluster runs on one carrier at a time: a NATS broker, or the database with a websocket tunnel, the pgbus and a claim queue. A new deployment with only PostgreSQL starts on the tunnel, and one with a NATS URL stays on NATS. An admin changes the carrier while the cluster runs.

The page covers the dry run and its blockers, the confirmation of the frontend list, force, abort, the drain time and what is cut after it, worker profiles and how workers follow, the credential handover, the peer link, security, rolling upgrades and rollback, metrics and logs, the limits that were measured, troubleshooting and a short FAQ. The worker no longer needs a NATS URL, and the page says so.

Redraw the architecture diagram, name the peer CA flag --peer-tls-ca as documented, and list get_cluster_carrier for the assistant.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A deployment with only PostgreSQL runs on the tunnel carrier, so the flag no longer needs NATS.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Add a Ginkgo scenario that starts two frontends and two workers as child
processes, with PostgreSQL and NATS in containers. One worker has an address
and one is tunnel-only. The scenario runs a dry run, a change from NATS to the
tunnel while both frontends answer chat, a change back that the tunnel-only
worker blocks, a forced change that leaves that worker unroutable but
registered, a frontend that dies during prepare, a worker that dies while it
follows, and an abort. It asserts that the backend process of a worker keeps
its PID across a change.

The spec has the label CarrierSwitch and runs with
make test-e2e-distributed-switch. It skips without docker or a Go toolchain.
The Distributed label filter of test-e2e-distributed does not select it.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Add a step that runs make test-e2e-distributed-switch after the distributed suite. It runs with real processes, PostgreSQL and NATS in containers, and it needs no secret, so a pull request from a fork runs it too. The job stays advisory like the suite.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Release removed the set from the hooked map, so the next Listen of the same
set registered NotifyReconnect a second time. A set has no way to drop a
callback, and the first one stayed live. A set that was released and attached
again, as in a drain that re-attaches, ran every reconnect hook twice.

Give each registration a live flag. Release switches it off, and the map still
does not keep the set alive. The existing spec for one run per reconnect is
the regression test.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Adopt ignored the error of the first start of the work. The first poll
reports it, but on a stable row the report was never cleared when a later
poll started the work, so the replica stayed "not ready" until the next
change.

Log the error in Adopt, and report ready on a stable row when the last
report carried a reason. A spec covers a replica whose first start fails.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…lag from the worker hint

The workers table showed "ok" for a worker that predates carrier switching,
although it cannot follow a change to the tunnel. It now says that the worker
predates carrier switching, and it names the carrier that a worker with
limited capabilities cannot follow. A reason that the worker reported is
shown as before.

The worker hint printed --nats-url and LOCALAI_NATS_URL. A worker learns the
carrier from the frontend, so the flag is an optional override. The hint no
longer shows it in the commands and says so in one line. The commands also
lost their line breaks, because a backslash at the end of a line in a
template literal joins the lines; they now keep one flag on each line.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The "active carrier" label of the architecture diagram sat on the edge of
the Worker #2 box. Move the workers to the right, so the gap between the
control plane and the workers holds the label, and put the label on the
middle dashed line. The "embeddings" chip of Worker #3 was wider than its
box, so it now reads "embed". The PNG is rendered again from the HTML.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…tive carrier

After a forced change back to NATS, a worker that holds only the tunnel stays
registered, and its replicas keep the address that they were loaded with. The
backend of such a worker listens on its own loopback address, so a request that
picked one of its replicas failed with a refused connection and a 500. The
guard of the direct client factory did not help, because the recorded address
is a host name and not an IP literal.

SmartRouter now takes the active carrier. It leaves out of the candidate set
every node that is not attached to that carrier and cannot follow it, both when
it looks for a loaded replica and when it places a new one, so the replica
falls through to another node. When no node is left the request ends with
ErrNoAvailableNodes and says why, which is a 503. No row is changed. The set
stays "any healthy node" while every node can be reached, so a deployment on
NATS routes as before. The list of unreachable nodes is cached for two seconds.

The switch e2e no longer tolerates a failed chat during the forced change.
Before the fix that spec failed in one of three runs; it now passes in three
of three.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…n the status is read

A read of the carrier state ran the preflight without asking the replicas to
look again. A replica writes what it can build only every few minutes, so for
most of that time its report was older than the age that a change accepts.
The state then listed it as a forceable blocker for a change back to NATS,
although the dry run, which asks and waits, found nothing wrong.

Add Switch.PreflightForStatus. It keeps the age in the report and does not list
an old report as a blocker. A replica that says it cannot use the carrier, and
one that never reported, still are. The status endpoint uses it, and it asks the
replicas to look again, at most once in 15 seconds, because the page polls it.
The dry run is unchanged: it asks the replicas and waits for them, bounded.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… interrupts

The spec for a frontend that dies during prepare only waited until the change
left prepare. The change is forced, so the product commits it on the tunnel
without the dead frontend. The spec now asserts that the row is stable on the
tunnel and not aborted, that both workers follow, that the frontend that is
left answers chat all along with no failure, and that the restarted frontend
follows the row.

The spec for a worker that dies while it follows now asserts that the row
commits on the tunnel, that the dead worker stays registered, that both
workers are attached to the tunnel after the restart, and that chat through
both frontends then runs with no failure. Requests that need the worker while
it is down can fail, so the spec asserts nothing about that window.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… write

The spec for streams that the worker opens wrote to each stream and expected
the write to work. The session resets such a stream as soon as it is opened,
and under load the reset can arrive before the write, so the write failed with
a reset by the remote. A reset is the behaviour under test. The spec now
accepts a write that fails with a reset from the remote and fails on any other
write error. Under 40 busy loops, 8 of 48 runs failed before and none of 48
fail now.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
@mudler-agent mudler-agent changed the title feat(distributed): add carrier state and switchable holders feat(distributed): switch the cluster transport between NATS and the database at runtime Oct 13, 2026
@mudler-agent
mudler-agent changed the base branch from feat/distributed-live-carrier to master October 13, 2026 02:06

This branch has not been deployed

No deployments
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