Repository navigation
feat(distributed): run distributed mode without a message broker - #11812
localai-org-maint-bot wants to merge 158 commits into
Conversation
|
@localai-org-maint-bot fix tests |
|
Pushed two test fixes on top of the latest master:
Verification: the maintenance test fails before the change and skips afterward with a Go overlay selecting the macOS path; four FakeBus specs and Still unresolved: the original Linux timeout in the Update 2026-09-08: resumed this request and pushed Reproduced the exact failure on Linux with a symlinked TMPDIR before the fix. Afterward, all 13 file-stager round-trip specs and four new containment cases pass with that TMPDIR, including rejection of sibling paths and symlinks escaping the allowed root. The focused worker file-staging tests, incoming virtual-model companion tests, The existing Linux, distributed, cluster, and VibeVoice test runs were cancelled, so full verification remains incomplete. No CI jobs were restarted or polled, and this request remains in progress. |
|
This is a large PR (100 files) with tests-linux and tests-apple CANCELLED (required checks), plus a gosec failure and backend build failures (magpie-tts-cpp, face-detect). Could you check if the cancelled tests are due to a cascading failure or a real issue? The gosec failure also needs investigation. |
715ae99 to
8c47c5e
Compare
4024ae5 to
bab6da2
Compare
2b10294 to
59fab0b
Compare
6620f49 to
d992f8c
Compare
Starting a Postgres and a NATS container per spec cost roughly 48 minutes of startup across the 213 specs behind SetupInfra, which is why this suite was never wired into CI. Containers move to BeforeSuite and isolation comes from CREATE DATABASE, which the dbName argument already described. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
A failed CREATE DATABASE panics out of the assertion before closeDB runs, leaking a pgx pool per attempt. With --flake-attempts 5 that exhausts postgres:16-alpine's 100 connection slots, at which point the cleanup path's own Expect fails the spec and one hiccup cascades across the suite. Scope the admin handle so the panic unwinds through defer closeDB, and let cleanup use a fallible tryAdminDB that reports rather than asserts. Register DeferCleanup immediately after CREATE so a later failure cannot leave the database behind, and warn on TestInfra that the container handles are now suite-wide. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The WebSocket log handler writes its "initial" batch before it calls Subscribe, so a line appended the instant that batch arrives lands in the circular buffer with no subscriber to receive it. Three backend-logs specs append exactly there and then wait out a 5s read deadline; once a gorilla read hits its deadline the connection is unusable, so the spec cannot retry. `--focus='Worker WebSocket log streaming' --repeat=25` failed on attempt 17 with nothing else running, which is far too often to wire into CI. Add BackendLogStore.SubscriberCount, resolving a model ID by the same exact-key and replica-prefix rules Subscribe uses, and have the specs poll it until the handler has attached. Nothing in production calls it and no assertion is weakened; the handler's own snapshot/subscribe window is left as it is, being a production streaming question rather than a test one. Verified with 60 repeats of the WebSocket specs and three consecutive --randomize-all runs of the whole distributed suite, all at --flake-attempts 1: 239 of 240 specs pass in about 80 seconds. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… works around Three corrections from review of the previous commit. The lock-order comment on SubscriberCount claimed no path takes s.mu and a buffer lock together. Subscribe does exactly that, holding s.mu.RLock across replica registrations that take buf.mu. State the rule that is actually true — s.mu precedes any buffer lock, so counting after releasing it preserves the order — and say what follows from it: the total is a sample, not a snapshot. waitForLogSubscriber read as general-purpose but unblocks on the first registered subscription. Subscribe attaches the exact-key buffer and each replica buffer one at a time, so for a replicated model the count goes positive while later replicas are still unattached and the race survives. Rename it waitForSingleLogSubscriber, document that it holds only where Subscribe resolves to one buffer, and assert on exactly 1: misuse then fails loudly on the count rather than going quietly back to being flaky. Taking the expected count as a parameter was the alternative, but that makes callers predict a store-internal number and an under-count fails the same silent way as the original bug. The snapshot-then-subscribe race had no artifact outside a report, and review found a second site carrying it. Mark both handlers identically, including the point that swapping the two calls duplicates rather than drops and so is not the fix. The race itself is left alone; this branch stays test infrastructure. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The suite has never run in CI, so 239 specs across 32 files were verified only by hand. Path-filtered to distributed code, advisory until it earns a track record, and with flake retries at 1 rather than 5 so nondeterminism surfaces instead of being retried away. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The path allowlist covered 13 of the 99 packages the suite reaches. Commit 1dc3aee touched core/config, core/services/modeladmin and core/backend and matched no entry, so it would have merged without running the very specs that cover it. Use the paths-ignore denylist tests-e2e.yml already uses. Disable the testcontainers reaper: the runner is ephemeral, so the reaper buys nothing and its unpinned image was pulled mid-suite, defeating the pre-pull. Drop continue-on-error, which no other workflow uses and which reports a failed run as green. The job is advisory by staying out of branch protection instead. Pin Go to 1.26.0 to match go.mod, and add the tmate-on-failure step. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Runs local-ai as real child processes, one per frontend replica and one per worker, against containerised infrastructure. The in-process suites cannot express frontend-replica failure: there is no process to kill and no real HTTP boundary between a worker and the frontend it registered with. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Restarting a frontend replica must not move it: workers read LOCALAI_REGISTER_TO once at boot and never re-resolve it, so a replica that returns on a fresh port is unreachable by the workers that registered with it. startFrontend now takes the port, with <= 0 meaning "allocate". Process logs are opened for append rather than truncated, so a restarted process cannot erase the log of the instance that died, which is the log a failover post-mortem needs. The post-SIGKILL wait is bounded, so one stuck child no longer becomes a suite-wide timeout that names nothing. Stop is nil-safe because Start returns a nil cluster after stopping itself. Start's doc comment no longer claims to wait for worker registration; that needs an authenticated admin session, so it now says callers must poll /api/nodes themselves. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The register handler answers 201 both for "user created, here is your session" and for "this email already exists", so the status code cannot tell a fresh registration from a repeat one. Key on the session cookie instead and fall through to login when it is absent. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
… secret
Session rows are keyed by an HMAC of the token under a secret generated
per instance into {DataPath}/.hmac_secret. The replicas shared that
secret only because they shared a working directory, and that directory
was the source tree. Give each frontend LOCALAI_DATA_PATH under its own
baseDir and pin LOCALAI_AUTH_HMAC_SECRET, so a session minted at one
replica resolves at every other one by construction.
Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…ness
The point of running LocalAI as real child processes is to be able to take
one away. Add KillFrontend (SIGKILL, the lost replica), StopFrontendGracefully
(SIGTERM, the rolling update), KillWorker, RestartFrontend and FrontendAlive.
RestartFrontend pins the dead replica's original port. Workers read
LOCALAI_REGISTER_TO once at boot and never re-resolve it, so a replica that
returns on a fresh port is unreachable by exactly the workers that registered
with it and the failover under test never happens.
It also wipes the replica's data directory, so the process comes back with
empty local state and has to rehydrate node, session and job state from the
shared Postgres and NATS. Reusing the directory would model a pod with a
persistent volume and hide the class of bug these tests exist to find. That
is only safe because the harness pins LOCALAI_AUTH_HMAC_SECRET; otherwise the
wipe would take {DataPath}/.hmac_secret with it and every session minted
before the restart would 401 afterwards.
FrontendAlive consults the reaper's exited channel before signal 0: a child
that has died but has not yet been waited on is a zombie, and signal 0 to a
zombie succeeds, which would report a dead replica as alive.
The new specs cover argument validation only. Killing, stopping and
restarting a live process needs a built binary plus Postgres and NATS, so
those paths stay unexecuted until the failover suites land.
Assisted-by: Claude Opus 5 [claude-code]
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
…he wipe Review round 1. Comments only, plus one guard. The note on Process.alive claimed the exited check closed the zombie window. It does not. The reaper closes exited only after Cmd.Wait returns, and Wait marks the os.Process done before returning, so exited being closed implies signal 0 already errors and the branch cannot fire earlier than the one it precedes. The window between the child exiting and waitid collecting it stays open in both versions, and the only real mitigation is for callers to poll with Eventually rather than sample once. Keep the check as hygiene, say what it actually does, and say it again on the exited field, so nobody reads the old claim and drops the Eventually. Record what the cold wipe destroys. The harness sets no LOCALAI_STORAGE_URL, so the object store is a directory under DataPath, and quantization and fine-tune outputs live there too. Postgres keeps the job row; the artifact it points at does not survive the restart. A spec that asserts otherwise will fail for a storage reason wearing a failover costume. Tell callers to let a graceful stop finish before restarting: RestartFrontend terminates with SIGKILL, so pairing it straight after StopFrontendGracefully cuts the drain short and silently converts the rolling-update case into the crash case. Refuse to wipe when the cluster has no work dir. frontendDataDir is relative when baseDir is empty, so a Cluster built by some future test helper without one would have RemoveAll walking frontend-N/data inside the source tree. The guard sits before terminate, so a refusal leaves the cluster as it was. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Tasks 4 to 6 built a harness that runs local-ai as real child processes, but none of it had ever started a process: every spec so far returned inside argument validation. These two specs are the first to run it against a real binary, a real Postgres and a real NATS. Two frontends against one database both see a worker that registered through only one of them. Every failover spec assumes this, so it is asserted first. One admin session is minted at frontend 0 and reused for both replicas rather than registering per frontend. The auth routes share a five-per-minute-per-IP limiter and all e2e traffic is 127.0.0.1, so a session per frontend would exhaust the budget as soon as a spec needs a third one. Reuse is sound because sessions live in the shared Postgres and the harness pins one HMAC secret across replicas; frontend 1 answering /api/nodes with 200 on a cookie minted at frontend 0 is what proves it. The binaries are resolved before SetupInfra so a missing build skips without first provisioning a database the skip would then have to tear down. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The Cluster label partition is these two specs and nothing else, so a missing binary skipped the entire job. Ginkgo exits 0 on skips, so a build step that broke or moved its output would have left the job reporting "0 Passed | 2 Skipped" and going green without ever starting a cluster: the silent pass this suite exists to make impossible. Skipping stays the local default, which is the right courtesy for someone who has not run `make build`, but LOCALAI_E2E_REQUIRE_BINARIES turns it into a failure that names the missing path and the target that builds it. A value that is set but unparseable counts as on, since reading it as off would restore the very skip it disables. Failures also name themselves now. The roster poll kept returning a bare nil on error, so a 401 at the second replica, a decode failure and "the worker never registered" all presented identically as an empty list. It now retains the last error and the last roster and reports whichever happened, through a lazily evaluated Gomega description that costs nothing until something fails. Finally, the two-frontend spec no longer depends on the harness to mean what it says. It asserts an unauthenticated GET /api/nodes at frontend 1 is refused, which observes the admin gate instead of assuming it, and it compares the worker's registration id across the two replicas rather than its name. A future harness that registered every worker with every frontend would have kept a name-only assertion green while it quietly stopped proving anything about shared state. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Separate hosts cannot use frontend paths directly. Stage the remaining path-bearing requests and the original model config. Async quantization cleanup can span replicas and requests. Persist each cleanup phase so failures retry without fetching completed output twice. Reject root aliases and untrusted export entries before they can mutate frontend or worker files. The compiled cluster suite now exercises these paths through both public routes and the authenticated peer relay. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Bind cleanup to storage namespaces and generation-scoped quantization state. Validate backend outputs, prove inputs reach worker staging, and make failed-start cleanup retryable. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Reconcile the binary conformance inventory with the live backend interfaces, generated gRPC descriptor, staging overrides, and topology table so protocol growth cannot pass without an explicit coverage decision. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Discover direct FileStagingClient backend overrides from every non-test source file in the nodes package, and locate that package without relying on runtime source paths so coverage inventory checks survive trimpath builds. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Exercise WebUI sessions, fail-closed registration, per-node tunnel credentials, manual approval, heartbeats, and scoped agent credentials through real frontend and worker processes. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Keep pending agent workers alive with authenticated heartbeats while preserving approval as the tunnel and execution boundary. Exercise the real binary credential handoff through an authenticated inference and verify that credential remains non-admin. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The cluster target identifies feature and machine-auth scenarios while retaining one shared binary build. CI requires binaries, so neither scenario can skip silently. Record the current-master fixture baseline and measured local suite budgets. Keep new conformance tests within the branch lint policy. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Register the new quantization, mock fixture, and protocol inventory tests through package-consistent Ginkgo suites. Keep table case names in Ginkgo diagnostics and label the protocol inventory for distributed CI selection. Refresh the current-master fixture baseline using the final test structure. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Exercise normal cluster shutdown through the real binary signal handlers and retain the loaded mock backend PID so the conformance scenario proves teardown leaves no orphan. Keep SIGKILL as a bounded fallback and leave the explicit crash primitives unchanged. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Record the authoritative Cluster label count and describe the restart helper's bounded graceful shutdown path accurately. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Upgrade the GGUF parser to reject string and array lengths larger than the remaining remote file before allocating them. This prevents malformed or inconsistent range responses from terminating LocalAI during background gallery warming. Assisted-by: Codex:GPT-5 [apply_patch] [golangci-lint] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Keep go.sum aligned with the v0.26.3 parser version selected by go.mod. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Tighten the permissions that can be tightened and justify the ones that cannot. The e2e harness creates its directories 0750 and writes its model config and process logs 0600; the copied binary keeps 0755 because the suite executes it. Everything else is a gosec rule firing on code that is doing what it is supposed to: jitter drawn from math/rand for a reconnect backoff, a fixed credential for a throwaway cluster, and subprocesses launched from the binary the suite just built. Each carries a #nosec naming the rule and the reason, in the form the tree already uses. Scanned before and after: 13 findings in the packages this branch added, now 0. The one that remains in core/services/worker is byte-identical on master and is not this branch's to silence. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
The frame length conversion is already bounded by the guard three lines above it, which caps a payload at 1024 bytes and so cannot truncate a uint16. The agent pick spreads work across workers that are equivalent by construction, and predicting it buys an attacker nothing. Both carry a #nosec naming the rule and the reason rather than a silent suppression, matching the rest of the tree. Assisted-by: Claude Opus 5 [claude-code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Master added Animate3D, negative_prompt, and context_size after this branch diverged. The old suite did not exercise those paths, and Kokoros no longer implemented the generated service trait. Extend binary conformance across the tunnel owner and peer relay. Allow long development versions so rebased binaries can register in PostgreSQL. Clear the security findings introduced by the branch's new code. Assisted-by: Codex:GPT-5 [apply_patch] [exec_command] Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Use the existing canonical temp directory helper for the combined HTTP and S3 staging fixture. macOS exposes its temp root through /var, which the ephemeral-root validation rejects as a symlink. Assisted-by: Codex:gpt-6 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Open the worker drawer and local-machine scale-out card before checking the commands they contain. Assisted-by: Codex:gpt-6 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
An empty roster opens the worker drawer automatically. Clicking Add worker again targets the button behind the drawer and times out. Wait for the drawer before checking the backend and agent commands. The original test reproduces the CI failure. All six nodes-roster Playwright tests pass with the visibility assertion. Assisted-by: Codex:gpt-6 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
d992f8c to
1d4d19e
Compare
Keep the newer SQLite dependency from master to resolve the conflict. Assisted-by: Codex:gpt-6
Keep the version-reporting import from master and omit the unused sanitize import after the transport changes. Assisted-by: Codex:GPT-6
Preserve failover support alongside the PostgreSQL broadcast carrier. Assisted-by: Codex:gpt-6 golangci-lint
Bring the distributed branch onto current master before the CI fix. Assisted-by: Codex:gpt-6
A validated model path does not validate its adjacent hash metadata. Reject symlinked metadata on reads and replace sidecars atomically through a directory handle, without writing to symlink targets. Regression tests reproduce forged hashes from external sidecars. Focused file-transfer tests pass. The targeted gosec scan reports 20 fewer findings; remaining findings are not suppressed. Assisted-by: Codex:gpt-6 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Read event snapshots through the fake transport accessor. The merged scene and diarization tests still used removed fields and methods. Assisted-by: Codex:GPT-6 go-vet Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Convert the hash sidecar tests to the required Ginkgo and Gomega style. This resolves the forbidigo CI failures and preserves all four cases. Assisted-by: Codex:gpt-6 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Preserve multipart language hints alongside JSON diarization and speaker profiles when resolving the endpoint conflict. Assisted-by: Codex:gpt-6
ba75324 to
b941fc4
Compare
Distributed mode without a message broker
Running LocalAI distributed used to mean running and managing a NATS cluster, and giving every worker a routable address with inbound ports open back to it. Both are gone.
A worker now dials the frontend load balancer over ordinary HTTP and holds one multiplexed yamux tunnel that carries gRPC, HTTP and websocket traffic together. That tunnel lands on exactly one frontend replica; every other replica reaches the worker by relaying through the owner. Workers bind loopback only and advertise nothing. A deployment needs PostgreSQL and the frontends' own HTTP listener, and nothing else.
github.com/nats-io/...is out ofgo.modandgo.sum.What a worker needs now
One outbound HTTPS connection to the same load balancer a browser would use. No routable address, no inbound ports, no broker credentials.
How it works
GET /api/cluster/connectand holds a yamux session. Control RPCs, file staging and inference all ride it.LISTEN/NOTIFY. Payloads above the 8000-byte notification cap become a row plus a notify-with-id, never a truncated payload.SELECT ... FOR UPDATE SKIP LOCKEDclaim queue. A claim is released when the replica holding it stops being live, with no age term, so a slow claimant is never robbed.The invariant this rests on
Four conditions must never be reported as each other: a routing fact, a connection absent within the reconnect grace, an unreachable peer, and the worker's own answer. Only the first and last may be acted on. Absence makes the scheduler reap rows and evict models, and one of those paths runs during inference, so a collapse between them is not a cosmetic bug.
Review found and fixed fourteen instances of that collapse, including one created by the fix for another, one where an expired caller deadline was blamed on the peer under contention, and one where a worker's own refusal was read as "no route", which left permanently unreapable ghost rows.
Operator impact
LOCALAI_ADVERTISE_ADDRandLOCALAI_ADVERTISE_HTTP_ADDRare no longer used.--nats-urlis accepted and ignored so existing command lines keep starting.--nats-require-authno longer gates agent-worker approval.Testing
A real-process end-to-end suite runs a two-frontend, two-worker cluster of live
local-aibinaries, which is the configuration where the owner path and the relay path are live at once:The suite previously could not fail honestly: with no binary present it ran zero specs, exited zero and printed "Test Suite Passed". It now builds the binary and refuses to run against one older than the tree. That guard has since caught three real mistakes.
The race detector runs clean across 98 suites and 7095 specs with no known-failure allowance.
Bugs found in existing code and fixed
Follow-ups closed before merge
The gaps found during the work were closed rather than deferred:
/api/cluster/peertook a self-declared replica id, so anything holding the shared registration token could relay to every worker a replica owned and repeatedly evict its inbound link. Each replica now mints its own secret at startup and publishes only the hash; the plaintext never leaves the process that minted it. A mixed-version rollout refuses rather than falling back, because an old replica and an attacker send byte-identical requests.Known remaining
DistributedManager.Listtruncates skill content to 500 bytes for agents, a separate pre-existing defect.🤖 Generated with Claude Code
https://claude.ai/code/session_01Y2TjpXdY7SszRrM5PhSp1e